diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6624b485..dd02cb66 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -42,7 +42,11 @@ fn property_group_error_points_to_layer_inspection() { let l = call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"text": "Hi"}}))["layer"].clone(); call_json(&mut s, "execute_command", json!({"command": "comp.renderer", "params": {"renderer": "advanced3d"}})); for tool in ["get_property", "set_property", "add_keyframe"] { - let (content, err) = call(&mut s, tool, json!({"layer": l, "path": "geometryOptions", "value": 0, "time": 0})); + let mut args = json!({"layer": l, "path": "geometryOptions", "time": 0}); + if tool != "get_property" { + args["value"] = json!(0); + } + let (content, err) = call(&mut s, tool, args); assert!(err, "{tool}: {content:?}"); let text = content[0]["text"].as_str().unwrap(); assert!(text.contains("is a property group"), "{tool}: {text}"); @@ -161,11 +165,12 @@ fn headless_workflow() { json!({"layer": l, "path": "transform/scale", "keys": [{"time": 1.5, "value": [50, 50]}, {"time": 2.5, "value": [100, 100]}], "timeBase": "comp", "interpolation": "hold"}), ); let times: Vec = p["keys"].as_array().unwrap().iter().map(|k| k["time"].as_f64().unwrap()).collect(); - assert!((times[0] - 0.5).abs() < 1e-6 && (times[1] - 1.5).abs() < 1e-6, "layer-time keys: {times:?}"); + // Comp times snap to the comp's frames (29.97 fps) before becoming layer time. + assert!((times[0] - 0.5).abs() < 0.02 && (times[1] - 1.5).abs() < 0.02, "layer-time keys: {times:?}"); assert_eq!(p["keys"][0]["out"], "Hold", "the comp-time keys were selected for the interpolation"); call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/scale", "value": [75, 75], "time": 3.0, "timeBase": "comp"})); let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/scale"})); - assert!(p["keys"].as_array().unwrap().iter().any(|k| (k["time"].as_f64().unwrap() - 2.0).abs() < 1e-6), "{p}"); + assert!(p["keys"].as_array().unwrap().iter().any(|k| (k["time"].as_f64().unwrap() - 2.0).abs() < 0.02), "{p}"); // Expression via set_property. let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"})); diff --git a/crates/engine/src/commands/relink.rs b/crates/engine/src/commands/relink.rs index 53e2c019..1c095dda 100644 --- a/crates/engine/src/commands/relink.rs +++ b/crates/engine/src/commands/relink.rs @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Relink Missing Footage...", ["File", "Dependencies"], None, - "{folder: string (recursive desktop search, no symlinks; max 100000 entries), dryRun?: bool (report only), wait?: bool (return results inline; otherwise jobs.list/jobs.wait). Relinks only unique exact filenames with compatible metadata; preserves interpretation and sequence ranges. Reports high/low confidence and ambiguous, incompatible, unsupported or notFound items.", + "{folder: string (recursive desktop search, no symlinks; max 100000 entries), dryRun?: bool (report only), wait?: bool (return results inline; otherwise jobs.list/jobs.wait)} — relinks only unique exact filenames with compatible metadata; preserves interpretation and sequence ranges. Reports high/low confidence and ambiguous, incompatible, unsupported or notFound items.", enabled, run )] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b598e078..0bfa04a5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -31,6 +31,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("繁體中文", "zh-hant"), ("Українська", "uk"), ("Español", "es"), + ("Português (Brasil)", "pt-br"), ]; /// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent). diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs index 109ea236..384f86a0 100644 --- a/crates/host/tests/footage_reload.rs +++ b/crates/host/tests/footage_reload.rs @@ -171,9 +171,13 @@ fn background_scan_reloads_changed_footage() { #[test] fn image_sequences_reload_by_themselves_only_with_all_footage() { - let (a, b) = (tmp("seq_0001.psd"), tmp("seq_0002.psd")); - std::fs::write(&a, flat(RED, false)).unwrap(); - std::fs::write(&b, flat(RED, false)).unwrap(); + let png = |path: &str, v: u8| { + std::thread::sleep(std::time::Duration::from_millis(30)); + image::RgbaImage::from_pixel(8, 8, image::Rgba([v, 0, 0, 255])).save(path).unwrap(); + }; + let (a, b) = (tmp("seq_0001.png"), tmp("seq_0002.png")); + png(&a, 255); + png(&b, 255); let mut s = effectcraft_host::session(); let r = s.execute_checked("file.import", json!({"paths": [&a], "sequence": true})).unwrap(); let item = ItemId(r["items"][0].as_u64().unwrap()); @@ -181,11 +185,12 @@ fn image_sequences_reload_by_themselves_only_with_all_footage() { // Non-Sequence Footage (the default, as in After Effects): sequences are never stamped. assert_eq!(s.prefs.import.auto_reload_footage, "nonSequence"); assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); - save(&a, &flat(BLUE, true)); + png(&a, 0); assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); // All Footage: every frame is watched (first seen now, so only remembered). s.prefs.import.auto_reload_footage = "all".into(); assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); - save(&b, &flat(GREEN, true)); + std::fs::write(&b, b"not yet a png").unwrap(); + png(&b, 128); assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap()["files"], json!(1)); } diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 8d49f087..d26b1a1d 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -251,7 +251,10 @@ fn h264_frames_at_reduced_sizes() { assert_eq!((sized.width, sized.height), (w, h)); let db = psnr(&sized, &effectcraft_raster::resample(&full, w, h)); eprintln!("h264 frame {n} at {w}×{h}: {db:.2} dB against the resampled full frame"); - assert!(db > 55.0, "frame {n} at {w}×{h}: {db:.2} dB"); + // Converting at half size averages before the YUV→RGB clip, so saturated pixels differ + // slightly; how much depends on the encoder that made the fixture (50 dB with x264 from + // ffmpeg 6.1, above 55 with newer ones), all well past visible. + assert!(db > 45.0, "frame {n} at {w}×{h}: {db:.2} dB"); assert!(Arc::ptr_eq(&sized, &pool.frame_at_size(ItemId(1), &f, t, w, h).expect("frame")), "cached"); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6365381f..4bbac2f7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -297,6 +297,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "查找缺失的效果"), ("file.findMissing", "Find Missing Fonts", "查找缺失的字体"), ("file.findMissing", "Find Missing Footage", "查找缺失的素材"), + ("file.relinkFootage", "Relink Missing Footage...", "重新链接缺失的素材..."), ("file.watchFolder", "Watch Folder...", "监视文件夹..."), ("", "Scripts", "脚本"), ("file.installScript", "Install Script File...", "安装脚本文件..."), @@ -920,6 +921,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "尋找遺失的效果"), ("file.findMissing", "Find Missing Fonts", "尋找遺失的字體"), ("file.findMissing", "Find Missing Footage", "尋找遺失的素材"), + ("file.relinkFootage", "Relink Missing Footage...", "重新連結遺失的素材..."), ("file.watchFolder", "Watch Folder...", "監視檔案夾..."), ("", "Scripts", "指令碼"), ("file.installScript", "Install Script File...", "安裝指令碼檔案..."), @@ -1540,6 +1542,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "見つからないエフェクトを検索"), ("file.findMissing", "Find Missing Fonts", "見つからないフォントを検索"), ("file.findMissing", "Find Missing Footage", "見つからないフッテージを検索"), + ("file.relinkFootage", "Relink Missing Footage...", "見つからないフッテージを再リンク..."), ("file.watchFolder", "Watch Folder...", "監視フォルダー..."), ("", "Scripts", "スクリプト"), ("file.installScript", "Install Script File...", "スクリプトファイルをインストール..."), @@ -2163,6 +2166,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "Знайти відсутні ефекти"), ("file.findMissing", "Find Missing Fonts", "Знайти відсутні шрифти"), ("file.findMissing", "Find Missing Footage", "Знайти відсутні матеріали"), + ("file.relinkFootage", "Relink Missing Footage...", "Перепідключити відсутні матеріали..."), ("file.watchFolder", "Watch Folder...", "Спостерігати за текою..."), ("", "Scripts", "Скрипти"), ("file.installScript", "Install Script File...", "Встановити файл скрипту..."), @@ -2787,6 +2791,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "Buscar efectos que faltan"), ("file.findMissing", "Find Missing Fonts", "Buscar fuentes que faltan"), ("file.findMissing", "Find Missing Footage", "Buscar material de archivo que falta"), + ("file.relinkFootage", "Relink Missing Footage...", "Volver a vincular material de archivo que falta..."), ("file.watchFolder", "Watch Folder...", "Observar carpeta..."), ("", "Scripts", "Scripts"), ("file.installScript", "Install Script File...", "Instalar archivo de script..."), @@ -3409,6 +3414,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "Localizar efeitos ausentes"), ("file.findMissing", "Find Missing Fonts", "Localizar fontes ausentes"), ("file.findMissing", "Find Missing Footage", "Localizar mídias ausentes"), + ("file.relinkFootage", "Relink Missing Footage...", "Revincular mídias ausentes..."), ("file.watchFolder", "Watch Folder...", "Pasta monitorada..."), ("", "Scripts", "Scripts"), ("file.installScript", "Install Script File...", "Instalar arquivo de script..."), diff --git a/docs/agents.md b/docs/agents.md index 2b3012bd..a0f4e7d0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -104,8 +104,8 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | | `get_layer {layer, comp?, time?, depth?, flat?}` | A layer's property tree (`depth` limits how many group levels expand). Every node has a `path`. | | `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | -| `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. | -| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?, comp?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. | +| `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | +| `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | | `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | | `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | @@ -130,7 +130,7 @@ stretched. Use MCP `execute_command` for engine commands `prop.set`, `prop.addKey`, `keys.set` and `keys.select` with `timeBase: "layer" | "comp"`; omitted means `layer`. The convenience tools -`set_property` and `add_keyframe` retain their layer-time interface. The input base applies to +`set_property` and `add_keyframe` take the same `timeBase` (layer time when omitted). The input base applies to both `keys.set.time` and `newTime`, and to every `keys.select.keys[].time`. Omitted `prop.set` or `prop.addKey` times use the target comp's CTI. Supplied write times snap to its nearest comp frame before conversion to stored layer time, including stretched or @@ -189,8 +189,8 @@ the Progress panel, `jobs.list`); headless sessions run `footage.check` when the Things that tripped up a long agent-driven session (a multi-scene 3D piece built entirely over MCP). - **Layer time vs comp time.** `add_keyframe` and `set_property` with `time` take layer time; - `get_property` and `render_frame` take comp time. Engine keyframe commands default to layer - time; use `execute_command` with `timeBase: "comp"` to supply comp seconds on offset or + `get_property` and `render_frame` take comp time. Those tools and the engine keyframe commands + default to layer time; pass `timeBase: "comp"` to supply comp seconds on offset or stretched layers. `run_script`'s `setValueAtTime` and `keyTime` also use comp time. Without an explicit engine time base, subtract the layer's start time before keying an unstretched layer ([#257](https://github.com/storytold/effectcraft/issues/257)).