#1539: retain checkpoint provenance and clarify model waits

This commit is contained in:
Kristian Myram
2026-10-30 08:43:33 +01:00
parent 33f545cf64
commit a21b9b175e
6 changed files with 69 additions and 9 deletions
+32
View File
@@ -575,6 +575,8 @@ fn list(s: &mut Session, task: Task) -> Result<Value> {
"licenceUrl": m.licence_url,
"homepage": m.homepage,
"url": m.url,
"source": m.source,
"sourceFile": m.source.rsplit('/').next().unwrap_or(m.file_name),
"size": m.size,
"sha256": m.sha256,
"installed": s.model_path(m).is_some(),
@@ -600,6 +602,9 @@ fn select(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
model_p(p, &cmd, task)?;
}
let wait = b_p(p, "wait").unwrap_or(false);
if wait && id != seg::CLASSICAL && s.models.status(task).active.as_deref() != Some(id) {
s.wait_model_worker(task, id).map_err(|error| bad(&cmd, error))?;
}
s.choose_model(task, id);
if wait {
s.apply_model(task, true);
@@ -756,11 +761,38 @@ mod availability_tests {
#[test]
fn sam2_is_install_from_file_only() {
let mut session = Session::default();
let listed = session.execute("roto.models", json!({})).unwrap();
for model in [seg::SAM2_TINY, seg::SAM2_SMALL, seg::SAM2_BASE_PLUS, seg::SAM2_LARGE] {
let error = session.execute("roto.model.download", json!({"id": model.id})).unwrap_err();
assert!(error.to_string().contains("Install from File"));
assert!(session.models.status(Task::Mask).busy.is_none());
assert!(install_bytes(Path::new("unused"), &model, b"invalid weights").unwrap_err().contains("SHA-256 differs"));
let row = listed["models"].as_array().unwrap().iter().find(|row| row["id"] == model.id).unwrap();
assert_eq!(row["source"], model.source);
assert_eq!(row["sourceFile"], "model.safetensors");
}
}
/// A waiting selection cannot claim completion while unrelated work prevents its model loading.
#[test]
fn waiting_selection_refuses_an_unrelated_worker_unless_the_model_is_active() {
crate::tests::roto::hold_roto_cache_test_lock();
for active in [false, true] {
let mut session = Session::default();
session.choose_model(Task::Mask, if active { "mobilesam" } else { seg::CLASSICAL });
set(session.models.lane(Task::Mask), |status| {
status.active = active.then(|| "mobilesam".into());
status.busy = Some("Downloading SAM 2".into());
status.busy_model = Some("sam2.1-hiera-tiny");
});
let result = session.execute("roto.model.select", json!({"id": "mobilesam", "wait": true}));
if active {
assert_eq!(result.unwrap()["active"], "mobilesam");
} else {
assert!(result.unwrap_err().to_string().contains("another model operation"));
assert_eq!(session.chosen_model(Task::Mask), seg::CLASSICAL, "a refused selection must not change settings");
}
assert!(session.models.status(Task::Mask).busy.is_some());
}
}
+2
View File
@@ -14,6 +14,8 @@ Trained models for Roto Brush and face tracking, each kind behind one swappable
EffectCraft's MIT OR Apache-2.0 (`ALLOWED_LICENCES`), with its task, authors, homepage, licence
URL, an app download URL when available, size and SHA-256. `load` refuses anything else.
SAM 2 is Install from File only until a storytold-controlled mirror is available.
The checkpoint `source` stays separate from the app download URL, so file-install guidance and
licence notices identify the verified artifact even when app downloads are unavailable.
- **Weights are never bundled.** They are downloaded on demand (or installed from a file) by the
engine (`roto.model.*` / `face.model.*`, Settings ▸ Roto Brush / Face Tracking), verified
against the registry's SHA-256 (`sha256`, dependency-free) and kept with a notice naming their
+14 -4
View File
@@ -143,6 +143,8 @@ pub struct ModelInfo {
pub homepage: &'static str,
/// App download URL; empty when only Install from File is available.
pub url: &'static str,
/// Checkpoint source for manual installation and notices; never used by the app downloader.
pub source: &'static str,
pub file_name: &'static str,
pub size: u64,
/// SHA-256 of the weights file (lower-case hex).
@@ -162,6 +164,7 @@ pub const MOBILE_SAM: ModelInfo = ModelInfo {
licence_url: "https://github.com/ChaoningZhang/MobileSAM/blob/master/LICENSE",
homepage: "https://github.com/ChaoningZhang/MobileSAM",
url: "https://raw.githubusercontent.com/ChaoningZhang/MobileSAM/master/weights/mobile_sam.pt",
source: "https://raw.githubusercontent.com/ChaoningZhang/MobileSAM/master/weights/mobile_sam.pt",
file_name: "mobile_sam.pt",
size: 40_728_226,
sha256: "6dbb90523a35330fedd7f1d3dfc66f995213d81b29a5ca8108dbcdd4e37d6c2f",
@@ -180,6 +183,7 @@ pub const SAM2_TINY: ModelInfo = ModelInfo {
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
homepage: "https://github.com/facebookresearch/sam2",
url: "",
source: "https://huggingface.co/facebook/sam2.1-hiera-tiny/blob/de431c4043854a71d8101e17995dfe596bf101a5/model.safetensors",
file_name: "sam2.1-hiera-tiny.safetensors",
size: 155_908_064,
sha256: "48c14467e5cf9e51870511feb72c89688e82dd74523142c0538b663e193ac2a7",
@@ -196,6 +200,7 @@ pub const SAM2_SMALL: ModelInfo = ModelInfo {
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
homepage: "https://github.com/facebookresearch/sam2",
url: "",
source: "https://huggingface.co/facebook/sam2.1-hiera-small/blob/ee5bba1d82bb8749febdf90f45e84b687142ba03/model.safetensors",
file_name: "sam2.1-hiera-small.safetensors",
size: 184_305_280,
sha256: "0a4067b11ce1e23d5229203f11c718a823060d15a4b23fa2372a7d4b77cbbc60",
@@ -212,6 +217,7 @@ pub const SAM2_BASE_PLUS: ModelInfo = ModelInfo {
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
homepage: "https://github.com/facebookresearch/sam2",
url: "",
source: "https://huggingface.co/facebook/sam2.1-hiera-base-plus/blob/b7320756a13354e7530a63935656d35b2f91a290/model.safetensors",
file_name: "sam2.1-hiera-base-plus.safetensors",
size: 323_476_296,
sha256: "2012733a0de5d03efd1bba550a2847c4551be9ef2e0d497c83074df66189f780",
@@ -228,6 +234,7 @@ pub const SAM2_LARGE: ModelInfo = ModelInfo {
licence_url: "https://github.com/facebookresearch/sam2/blob/main/LICENSE",
homepage: "https://github.com/facebookresearch/sam2",
url: "",
source: "https://huggingface.co/facebook/sam2.1-hiera-large/blob/665f8e2ad61cf5f53d65644ff27c8ee525124610/model.safetensors",
file_name: "sam2.1-hiera-large.safetensors",
size: 897_897_416,
sha256: "dc407dce21301fd94abb395c5099b4f2c455fdc8a8f261ac3d0ea6d4cd197230",
@@ -243,6 +250,7 @@ pub const FACE_LANDMARKER: ModelInfo = ModelInfo {
licence_url: "https://storage.googleapis.com/mediapipe-assets/Model%20Card%20MediaPipe%20Face%20Mesh%20V2.pdf",
homepage: "https://developers.google.com/edge/mediapipe/solutions/vision/face_landmarker",
url: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
source: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
file_name: "face_landmarker.task",
size: 3_758_596,
sha256: "64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff",
@@ -314,10 +322,9 @@ fn loader(id: &str) -> Option<Loader> {
/// The notice kept next to installed weights: what they are, who made them, their licence.
pub fn notice(m: &ModelInfo) -> String {
let source = if m.url.is_empty() { m.homepage } else { m.url };
format!(
"{}\n\nAuthors: {}\nLicence: {} (full text: https://spdx.org/licenses/{}.html; as stated by the authors: {})\nSource: {}\nProject: {}\n\nInstalled unmodified. EffectCraft runs it with its own implementation\nand is not affiliated with or endorsed by its authors.\n",
m.name, m.authors, m.licence, m.licence, m.licence_url, source, m.homepage
"{}\n\nAuthors: {}\nLicence: {} (full text: https://spdx.org/licenses/{}.html; as stated by the authors: {})\nSource: {}\nSHA-256: {}\nProject: {}\n\nInstalled unmodified. EffectCraft runs it with its own implementation\nand is not affiliated with or endorsed by its authors.\n",
m.name, m.authors, m.licence, m.licence, m.licence_url, m.source, m.sha256, m.homepage
)
}
@@ -420,6 +427,7 @@ mod tests {
assert!(ALLOWED_LICENCES.contains(&m.licence), "{}", m.id);
assert_eq!(m.sha256.len(), 64);
assert!(m.url.is_empty() || m.url.starts_with("https://"));
assert!(m.source.starts_with("https://"));
assert!(m.licence_url.starts_with("https://") && m.homepage.starts_with("https://"));
assert!(!m.authors.is_empty() && notice(m).contains(m.authors));
}
@@ -430,7 +438,9 @@ mod tests {
for model in [SAM2_TINY, SAM2_SMALL, SAM2_BASE_PLUS, SAM2_LARGE] {
assert!(model.url.is_empty(), "SAM 2 has no app download until a storytold mirror is available");
assert!(model.file_name.ends_with(".safetensors"));
assert!(notice(&model).contains(&format!("Source: {}", model.homepage)));
assert!(model.source.contains("/blob/") && model.source.ends_with("/model.safetensors"));
assert!(notice(&model).contains(&format!("Source: {}", model.source)));
assert!(notice(&model).contains(model.sha256));
}
assert_eq!(models(Task::Face).map(|m| m.id).collect::<Vec<_>>(), ["mediapipe-face"]);
assert!(load("mediapipe-face", b"nope").err().is_some_and(|e| e.contains("does not match")));
+8
View File
@@ -508,6 +508,13 @@ fn models_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut
}
}
}
if let (Some(source), Some(file)) = (m["source"].as_str(), m["sourceFile"].as_str()) {
let response = ui.link(file).on_hover_text(crate::i18n::tr("Source"));
reg(app, &format!("settings.{prefix}.source.{id}"), &response, file);
if response.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(source));
}
}
});
if classic {
return;
@@ -944,6 +951,7 @@ mod tests {
for id in ["sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"] {
assert!(app.auto.find(&format!("settings.roto.download.{id}")).is_none());
assert!(app.auto.find(&format!("settings.roto.install.{id}")).is_some());
assert_eq!(app.auto.find(&format!("settings.roto.source.{id}")).unwrap().label, "model.safetensors");
}
assert!(actions.is_empty());
}
+9 -4
View File
@@ -395,8 +395,9 @@ Face tracking's trained model works like Roto Brush's (below): `face.models` lis
tracker and MediaPipe Face Landmarker with its authors and licence; `face.model.download {id}` /
`face.model.install {path, id?}` install it (verified); `face.model.select {id:
classical|mediapipe-face}` chooses it; `face.model.remove {id}` deletes it. Downloads and loading
run in the background; `"wait": true` on download, install or select returns once the model is in
use (or with its error), which scripts need before tracking. `track.mask` with a
run in the background; `"wait": true` waits for the requested operation (or returns its error).
Download/install do not change the selected model. After installing, use
`face.model.select {id, wait:true}` to select and load it before tracking. `track.mask` with a
face method returns `faceModel`, the engine it tries first (the classical one when the model finds
no face in the mask).
@@ -503,12 +504,16 @@ reference. Matte settings are ordinary properties (`effects/#1/rotoBrushMatte/se
Trained model (Roto Brush 2.0 / 3.0):
- `roto.models` lists the built-in engine and every registered model, with its authors,
licence, size, URL, SHA-256 and whether it is installed, selected and active.
licence, size, app download URL (empty for install-only models), checkpoint `source` and `sourceFile`,
SHA-256 and whether it is installed, selected and active.
- `roto.model.download {id}` fetches and verifies weights when the registry provides an app download URL;
`roto.model.install {path, id?}` installs a file you already have. SAM 2 is Install from File only until
a storytold-controlled mirror is available; its size and SHA-256 are verified before installation.
- `roto.model.select {id: classical|mobilesam|sam2.1-hiera-tiny|sam2.1-hiera-small|sam2.1-hiera-base-plus|sam2.1-hiera-large}` chooses which one Roto Brush uses (SAM 2 also tracks the object through the clip, see [roto-brush-sam2.md](roto-brush-sam2.md); `roto.status` then reports `source` and per-frame `tracker.frames` with `confidence` and `occluded`). Loading runs
in the background; `"wait": true` (also on download and install) returns once it is in use.
in the background. `"wait": true` waits for the requested operation; download/install load the model
only if it is already selected. They do not activate an unselected model: after installation, use
`roto.model.select {id, wait:true}` to select and load it. Unrelated model work can return a retryable
busy error rather than reporting that a waiting selection has finished.
- `roto.propagate` never falls back to the classic engine silently: a chosen model that is not installed, or failed
to load, is an error, and without `"wait": true` so is a model still loading. `roto.status` reports `model`
(`selected`, `installed`, `active`, `loading`, `error`) and a `notice` while the chosen model is not the one in use.
+4 -1
View File
@@ -8,7 +8,10 @@ Apache-2.0). It is optional: nothing changes until you install the model and cho
(155.9 MB). The file's size and SHA-256 are checked and the licence notice is kept next to it.
Agents: `roto.model.install {id: "sam2.1-hiera-tiny", path: "model.safetensors"}`.
SAM 2 has no in-app download until the weights are mirrored under storytold control.
2. Choose it (`roto.model.select {id: "sam2.1-hiera-tiny"}`).
Each model card links its pinned Hugging Face `model.safetensors` checkpoint, rather than the
project's different `.pt` files. Its checkpoint source and SHA-256 are retained in the notice.
2. Choose it (`roto.model.select {id: "sam2.1-hiera-tiny", wait: true}` for agents). Installing an
unselected model does not activate it, even with `wait: true` on installation.
3. Paint strokes on the base frame as usual, then **Propagate**. One tracking pass follows the object through the
whole span in both directions, using SAM 2's streaming memory, so it keeps tracking through occlusion and when the
object leaves the frame and returns. Each frame's probability map is then refined by the same graph cut as every other