diff --git a/.github/workflows/local-models.yml b/.github/workflows/local-models.yml index bf382759a0..c491db5e78 100644 --- a/.github/workflows/local-models.yml +++ b/.github/workflows/local-models.yml @@ -26,10 +26,10 @@ jobs: CARGO_PROFILE_TEST_DEBUG: "0" steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: optional-local-ml - name: Install Linux UI dependencies diff --git a/Cargo.lock b/Cargo.lock index 0fc85ba369..64e3a2db45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5146,6 +5146,7 @@ dependencies = [ "photocraft-geom", "photocraft-gpu", "photocraft-io", + "photocraft-ml", "photocraft-paint", "photocraft-plugins", "photocraft-psd", diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index fa92cdd452..9fe93a990c 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -297,6 +297,10 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati | "layer.videoLayers.newVideoLayerFromFile" | "layer.videoLayers.replaceFootage" | "layer.videoLayers.reloadFrame" + // Downloading (about 1.8 GB) or deleting local models writes the app's model + // folder, outside any workspace; Preferences › Integrations does it for the user. + | "models.download" + | "models.remove" ) || command_uses_ambient_path(id, params) || profile_command_may_read_ambient(id, params) @@ -731,6 +735,17 @@ mod tests { let _ = std::fs::remove_dir(link); } + #[test] + fn local_model_downloads_and_removals_are_not_automation_commands() { + for id in ["models.download", "models.remove"] { + for model in ["birefnet-hr-matting", "sam2.1-large"] { + assert!(authorize_engine_command(id, &serde_json::json!({"id": model})).is_err(), "{id} {model}"); + assert!(authorize_engine_step(id, &serde_json::json!({"id": model})).is_err(), "{id} {model} as an action step"); + } + } + assert!(authorize_engine_command("models.list", &serde_json::json!({})).is_ok()); + } + #[test] fn filesystem_commands_fail_closed() { for id in [ diff --git a/crates/engine/src/model_cmds.rs b/crates/engine/src/model_cmds.rs index 1d2e923cd5..0472117b14 100644 --- a/crates/engine/src/model_cmds.rs +++ b/crates/engine/src/model_cmds.rs @@ -29,22 +29,29 @@ pub(crate) fn backend(s: &Session) -> Result> { /// An explicit command choice overrides the saved preference, allowing an action or script to /// request the same method every time. Inference never silently falls back or starts a download. pub(crate) fn subject_model(s: &Session, p: &Value) -> Result> { - model_choice(p, s.prefs().integrations.subject_model.name(), ModelId::BiRefNet) + model_choice(p, saved(s, s.prefs().integrations.subject_model.name()), ModelId::BiRefNet) } pub(crate) fn object_model(s: &Session, p: &Value) -> Result> { - model_choice(p, s.prefs().integrations.object_model.name(), ModelId::Sam2) + model_choice(p, saved(s, s.prefs().integrations.object_model.name()), ModelId::Sam2) +} + +/// The saved choice. Default and web builds can't run a model and hide the choice, so one saved +/// by a build with local models (the preferences file is shared) means Classical there instead of +/// failing every Select Subject. Builds with local models keep it strict; explicit params too. +fn saved(s: &Session, name: &'static str) -> &'static str { + if crate::prefs::NATIVE_MODELS || s.model_backend.is_some() { name } else { "classical" } } /// Resolve the preference before dispatch so recorded actions keep the method used, even if /// the preference changes before playback. Explicit choices (including invalid ones) stay intact. pub(crate) fn inject_model(s: &Session, command: &str, mut params: Value) -> Value { let model = match command { - "select.subject" | "layer.removeBackground" => s.prefs().integrations.subject_model.name(), + "select.subject" | "layer.removeBackground" => saved(s, s.prefs().integrations.subject_model.name()), // Point clicks commit the built-in Object Finder's hover result. SAM supports boxes, // so record Classical for this route even when the saved box preference is SAM. "select.object" if params.get("rect").is_none() && params.get("point").is_some() => "classical", - "select.object" => s.prefs().integrations.object_model.name(), + "select.object" => saved(s, s.prefs().integrations.object_model.name()), _ => return params, }; if params.is_null() { @@ -375,6 +382,28 @@ mod tests { assert!(s.execute("select.subject", json!({"model":"birefnet-hr-matting"})).is_err()); } + #[test] + fn a_model_saved_by_a_local_ml_build_means_classical_in_a_build_without_models() { + if crate::prefs::NATIVE_MODELS { + return; + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":4,"depth":8,"mode":"rgb"})).unwrap(); + s.execute("prefs.set", json!({"path":"integrations.subjectModel","value":"birefnet-hr-matting"})).unwrap(); + s.execute("prefs.set", json!({"path":"integrations.objectModel","value":"sam2.1-large"})).unwrap(); + s.execute("actions.record", json!({"name":"Saved"})).unwrap(); + for (cmd, p) in [("select.subject", json!({})), ("select.object", json!({"rect":[0,0,8,4]})), ("layer.removeBackground", json!({}))] { + let r = s.execute(cmd, p); + assert!(!matches!(&r, Err(EngineError::Other(m)) if m.contains("local models")), "{cmd}: {r:?}"); + } + s.execute("actions.stop", json!({})).unwrap(); + let steps = &s.actions.list[0].steps; + assert!(!steps.is_empty()); + assert!(steps.iter().all(|(_, p)| p["model"] == "classical"), "{steps:?}"); + // Asking for a model by name still fails: only the saved choice falls back. + assert!(s.execute("select.subject", json!({"model":"birefnet-hr-matting"})).is_err()); + } + #[test] fn downloads_need_no_document_never_replay_and_removal_resets_only_its_preference() { let mut s = Session::new(); @@ -503,8 +532,13 @@ mod tests { assert_eq!(fake.calls.lock().unwrap().len(), 2); s.model_backend = None; let result = s.execute("file.automate.batch", called_action).unwrap(); - assert_eq!(result["files"], json!([]), "a called action cannot grant a model backend"); - assert_eq!(result["errors"].as_array().unwrap().len(), 1); + if crate::prefs::NATIVE_MODELS { + assert_eq!(result["files"], json!([]), "a called action cannot grant a model backend"); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + } else { + // A build without local models reads the saved model as Classical. + assert_eq!(result["errors"], json!([]), "{result}"); + } assert_eq!(fake.calls.lock().unwrap().len(), 2); let result = s.execute("file.automate.batch", json!({"steps":[["select.subject",{"model":"birefnet-hr-matting"}]],"input":[input],"output":output})).unwrap(); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 96683c0406..6303fca2de 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1080,9 +1080,16 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ /// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog? pub fn is_hidden(path: &str) -> bool { - HIDDEN_UNTIL_IMPLEMENTED.contains(&path) || (!cfg!(target_os = "linux") && LINUX_ONLY.contains(&path)) + HIDDEN_UNTIL_IMPLEMENTED.contains(&path) || (!cfg!(target_os = "linux") && LINUX_ONLY.contains(&path)) || (!NATIVE_MODELS && LOCAL_ML_ONLY.contains(&path)) } +/// Whether this build can run local models (the `local-ml` feature, native only). +pub const NATIVE_MODELS: bool = cfg!(all(feature = "local-ml", not(target_arch = "wasm32"))); + +/// Model choices; builds without local models don't show them (#204). A choice saved by a build +/// with them (the preferences file is shared) still round-trips, and means Classical here. +pub const LOCAL_ML_ONLY: &[&str] = &["integrations.subjectModel", "integrations.objectModel"]; + /// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They /// still load, save and round-trip on every platform. pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"]; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f15c495a9e..0c1bd39679 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -67,6 +67,8 @@ photocraft-testkit = { workspace = true } # examples/perf_scenarios.rs (a 500-brush .abr for the #210 import row) and large_file_bench.rs # (the generated 2 GB PSD and 4.4 GB PSB of P20, `fixtures`). photocraft-psd = { workspace = true, features = ["testgen"] } +# A deterministic local-model backend for the model-job tests (jobs_ui_tests.rs). +photocraft-ml = { workspace = true } [build-dependencies] # Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them. diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 45fa60d8a3..e299603e87 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -525,6 +525,22 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.sync_views(); crate::file_ui::print_status(app, result); } + // With a local model, Select Subject and Object Selection run as jobs: say when they found + // nothing, as `report_smart_result` does for the classical ones that finish at once. + JobOutcome::Done(ref v) + if matches!(e.command.as_str(), "select.subject" | "select.object") && v.get("changed").and_then(Value::as_bool) == Some(false) => + { + app.sync_views(); + app.ui.status = if e.command == "select.subject" && app.active_tool() == crate::state::Tool::ObjectSelection { + tl!("No subject found — try a rectangle around the object") + } else if e.command == "select.subject" { + tl!("No subject found — try Object Selection or the Lasso") + } else { + tl!("Object Selection found nothing there — try a tighter rectangle") + } + .into(); + app.ui.status_error = false; + } JobOutcome::Done(_) => { app.sync_views(); app.ui.status = e.label; diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 1c82b1e7cf..16bc3f3637 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -937,3 +937,48 @@ fn background_open_warns_about_missing_fonts() { let want = vec!["font 'NoSuchFamily4559' is not installed; text may render using a fallback face".to_string()]; assert!(app.ui.notices.last().is_some_and(|n| n.title.contains("poster.psd") && n.lines == want), "{:?}", app.ui.notices); } + +/// A local model that never finds anything (an all-transparent mask). +struct EmptyModel; +impl photocraft_ml::InferenceBackend for EmptyModel { + fn status(&self) -> Vec { + photocraft_ml::MODELS.iter().map(|m| photocraft_ml::ModelStatus { id: m.id, installed: true, busy: false }).collect() + } + fn download(&self, _: photocraft_ml::ModelId, _: &photocraft_raster::Interrupt<'_>) -> photocraft_ml::Result<()> { + Ok(()) + } + fn remove(&self, _: photocraft_ml::ModelId, _: &photocraft_raster::Interrupt<'_>) -> photocraft_ml::Result<()> { + Ok(()) + } + fn infer( + &self, + _: photocraft_ml::ModelId, + _: &photocraft_algo::segment::RgbImage, + _: photocraft_ml::Prompt, + _: &photocraft_raster::Interrupt<'_>, + ) -> photocraft_ml::Result { + Ok(photocraft_ml::AlphaMask { width: 4, height: 4, values: vec![0.0; 16], kind: photocraft_ml::MaskKind::Alpha }) + } +} + +#[test] +fn a_model_selection_that_finds_nothing_says_so_like_the_classical_one() { + use crate::state::Tool; + // The same hints as the options bars' classical results (retouch_ui.rs). + for (tool, cmd, params, want) in [ + (Tool::QuickSelection, "select.subject", json!({"model": "birefnet-hr-matting"}), "No subject found — try Object Selection"), + (Tool::ObjectSelection, "select.subject", json!({"model": "birefnet-hr-matting"}), "No subject found — try a rectangle"), + (Tool::ObjectSelection, "select.object", json!({"model": "sam2.1-large", "rect": [40, 40, 120, 90]}), "Object Selection found nothing there"), + ] { + let mut h = app_harness(); + h.state_mut().ui.tool = tool; + h.state_mut().session.model_backend = Some(Arc::new(EmptyModel)); + let r = h.state_mut().run(cmd, params).unwrap(); + assert_eq!(r["pending"], true, "{cmd} runs as a job: {r}"); + frames_until(&mut h, 2, |h| h.state().session.jobs().is_empty()); + let app = h.state(); + assert!(app.ui.status.contains(want), "{cmd}: {}", app.ui.status); + assert!(!app.ui.status_error); + assert!(app.session.active().unwrap().doc.selection.is_none(), "{cmd} left the selection alone"); + } +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 89c2226e76..31f2b88b8c 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1759,7 +1759,10 @@ fn interface_groups(ui: &mut egui::Ui, obj: &mut Map, keys: &[Str } if !checks.is_empty() || !other.is_empty() { pref_group(ui, "Options", |ui| { - egui::Grid::new("prefs-grid-interface-checks").num_columns(2).spacing([0.0, 7.0]).show(ui, |ui| field_rows(ui, "interface", obj, checks, lang, &Value::Null)); + egui::Grid::new("prefs-grid-interface-checks") + .num_columns(2) + .spacing([0.0, 7.0]) + .show(ui, |ui| field_rows(ui, "interface", obj, checks, lang, &Value::Null)); grid("interface-options").show(ui, |ui| field_rows(ui, "interface", obj, other, lang, &Value::Null)); }); } @@ -1895,7 +1898,9 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke .unwrap_or(&[]) .iter() .copied() - .filter(|v| !matches!(path.as_str(), "integrations.subjectModel" | "integrations.objectModel") || *v == s || model_choice_available(models, v)) + .filter(|v| { + !matches!(path.as_str(), "integrations.subjectModel" | "integrations.objectModel") || *v == s || model_choice_available(models, v) + }) .collect(); let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); @@ -2755,9 +2760,18 @@ mod tests { use egui_kittest::{Harness, kittest::Queryable}; let values = json!({"memoryUsageMb":50000}).as_object().unwrap().clone(); assert!(!field_visible("performance", &values, "memoryUsageMb", "")); - let mut h = Harness::builder() - .with_size(vec2(680.0, 350.0)) - .build_ui_state(|ui, values| section_fields(ui, "performance", values, &["memoryUsageMb".into()], PreferenceFieldOptions { lang: crate::i18n::Lang::EN, system: None, models: &json!({"available":false}), search: "" }), values); + let mut h = Harness::builder().with_size(vec2(680.0, 350.0)).build_ui_state( + |ui, values| { + section_fields( + ui, + "performance", + values, + &["memoryUsageMb".into()], + PreferenceFieldOptions { lang: crate::i18n::Lang::EN, system: None, models: &json!({"available":false}), search: "" }, + ) + }, + values, + ); h.run_steps(2); h.get_by_label("Memory Usage"); h.get_by_label("Per-document RAM limit (MB)"); @@ -2848,9 +2862,10 @@ mod tests { assert!(contains("placeholder", "type")); assert!(contains("performance", "performance")); assert!(contains("interface.uiScale", "interface")); - assert!(contains("BACKGROUND REMOVAL", "integrations")); - assert!(contains("Object selection", "integrations")); - assert!(contains("integrations.subjectModel", "integrations")); + // The model choices exist only in builds that can run local models. + assert_eq!(contains("BACKGROUND REMOVAL", "integrations"), prefs::NATIVE_MODELS); + assert_eq!(contains("Object selection", "integrations"), prefs::NATIVE_MODELS); + assert_eq!(contains("integrations.subjectModel", "integrations"), prefs::NATIVE_MODELS); assert!(preference_sections(&values, "this setting does not exist").is_empty()); assert_eq!(preference_sections(&values, "").len(), SECTIONS.len()); } @@ -2858,6 +2873,9 @@ mod tests { #[test] fn searched_model_preferences_render_only_the_matching_method_without_changing_choices() { use egui_kittest::{Harness, kittest::Queryable}; + if !prefs::NATIVE_MODELS { + return; // Hidden without local models (unimplemented_preferences_are_hidden_from_the_dialog). + } for code in ["en", "el"] { let lang = crate::i18n::Lang::from_pref(code); let _scope = crate::i18n::language_scope(lang); @@ -4084,7 +4102,8 @@ mod tests { let values = prefs::Preferences::default().to_json(); assert!(has_visible_fields(&values, "general")); assert!(has_visible_fields(&values, "fileHandling")); - assert!(has_visible_fields(&values, "integrations")); + // Model choices and downloads only where models can run; agent control is still unread. + assert_eq!(has_visible_fields(&values, "integrations"), prefs::NATIVE_MODELS); assert!(has_visible_fields(&values, "enhancedControls")); // Every setting of these sections is still unimplemented. assert!(!has_visible_fields(&values, "scratchDisks")); diff --git a/docs/development.md b/docs/development.md index 2a6c64f490..0c3da3005b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -56,7 +56,11 @@ and the optional learned weights. A fresh `local-ml` build therefore requires ne and is **not reproducible offline from Cargo.lock alone**. `cargo --offline` controls Cargo resolution; it does not supply that runtime archive. Default builds and wasm omit this dependency. Enabled distributors must ship the runtime licence and third-party notices; -see [native runtime packaging](local-models.md#build-and-use). +see [native runtime packaging](local-models.md#build-and-use). On Windows that prebuilt runtime +needs a recent MSVC toolset: with MSVC 14.42 (Visual Studio 2022 17.12) the link fails with +unresolved `__std_search_1`, `__std_remove_1` and similar; update the Visual Studio Build Tools. +On Linux it needs glibc 2.38 and GCC 13's libstdc++ or newer (Ubuntu 24.04, not 22.04): older +systems fail with unresolved `__isoc23_strtol` and `_M_replace_cold`. The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. diff --git a/docs/scorecard.md b/docs/scorecard.md index 9fd35b7e25..9051243d1e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,10 +9,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 9 | 12 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 36 | 8 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 24 of 171 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 24 of 173 (target 0) | | [Type](#type) | 5 | 3 | 1 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 31 of 31 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -181,7 +181,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 24 of 171** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 24 of 173** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.cache_levels`, `performance.gpu_adapter`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. @@ -238,14 +238,14 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | -| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | +| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | partial | [#219](https://github.com/storytold/photocraft/issues/219) | local-ml: CPU BiRefNet alpha / SAM 2.1 box selection, pinned verified explicit downloads in Preferences; exploratory 20-target COCO comparison in docs/local-models-validation.md; representative matting quality and web inference remain open | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | ## Reliability -Never-crash attribute on 30 of 30 crate roots. +Never-crash attribute on 31 of 31 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---|