Integration: local models off by default in builds without them (#1199)

- Preferences > Integrations: default and web builds showed the section
  with two single-option "Classical" dropdowns, "Local models aren't
  available in this build" and disabled Download buttons; main hides it
  (#204). The model choices are now hidden where no model can run
  (prefs::LOCAL_ML_ONLY), and main's hidden-section test is back.
- A model choice saved by a local-ml build (the preferences file is
  shared) made every Select Subject, Object Selection box and Remove
  Background fail in a default build, with no control left to switch
  back. There it now means Classical; local-ml builds stay strict, and
  an explicit "model" param still fails.
- models.download / models.remove: the automation gate let a control or
  MCP agent start about 1.8 GB of downloads or delete installed models
  outside its workspace. Both are now refused like other ambient file
  effects; models.list stays allowed.
- Select Subject / Object Selection with a model run as jobs, so "No
  subject found" / "found nothing there" never showed. The job result
  now gets the same message as the classical path, including the
  Object Selection options bar's "try a rectangle" hint.
- local-models.yml pins its third-party actions by SHA, as ci.yml does.
- docs/development.md: Windows local-ml builds fail to link ONNX Runtime
  1.28's prebuilt with MSVC 14.42 (unresolved __std_search_1 etc.), and
  Linux ones need glibc 2.38 / GCC 13 (Ubuntu 22.04 fails with
  __isoc23_strtol).

Tests that fail without the fix: model_cmds
a_model_saved_by_a_local_ml_build_means_classical_in_a_build_without_models,
prefs_ui unimplemented_preferences_are_hidden_from_the_dialog, workspace
local_model_downloads_and_removals_are_not_automation_commands, jobs_ui
a_model_selection_that_finds_nothing_says_so_like_the_classical_one.

Co-authored-by: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-10-22 18:04:32 +02:00
1 parent a5c70cb0ee
commit d5139d177a
11 files changed
+168 -25

No files matched your search

+2 -2
View File
@@ -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
Generated
+1
View File
@@ -5146,6 +5146,7 @@ dependencies = [
"photocraft-geom",
"photocraft-gpu",
"photocraft-io",
"photocraft-ml",
"photocraft-paint",
"photocraft-plugins",
"photocraft-psd",
+15
View File
@@ -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 [
+40 -6
View File
@@ -29,22 +29,29 @@ pub(crate) fn backend(s: &Session) -> Result<Arc<dyn InferenceBackend>> {
/// 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<Option<ModelId>> {
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<Option<ModelId>> {
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();
+8 -1
View File
@@ -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"];
+2
View File
@@ -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.
+16
View File
@@ -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;
+45
View File
@@ -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::ModelStatus> {
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<photocraft_ml::AlphaMask> {
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");
}
}
+28 -9
View File
@@ -1759,7 +1759,10 @@ fn interface_groups(ui: &mut egui::Ui, obj: &mut Map<String, Value>, 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<String, Value>, 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<String> = 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"));
+5 -1
View File
@@ -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`.
+6 -6
View File
@@ -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 |
|---|---|---|---|---|