From 004984d85376c28c1ce97bf55f39d769a4a09a10 Mon Sep 17 00:00:00 2001 From: redonion23 <199118131+redonion23@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:59:10 +0200 Subject: [PATCH] Scripting: footage proxies, replaceWithSequence and mainSource.conformFrameRate (#813) FootageItem had no replaceWithSequence, setProxy, setProxyWithSequence or setProxyToNone, useProxy and proxySource were fixed stubs, and mainSource was a new object on every read with no conformFrameRate. They now run the existing commands (file.replaceFootage, file.setProxy, file.useProxy, file.setProxyNone, file.interpretFootage); the item info reports the proxy and the conform/native frame rates, and mainSource is one live FootageSource per FootageItem. replaceSource is not covered: it needs an engine command that changes a layer's source. Co-Authored-By: Claude Opus 5.5 --- crates/script/src/model.rs | 8 +++++++ crates/script/src/prelude.js | 44 ++++++++++++++++++++++++++++++++---- crates/script/src/tests.rs | 37 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf..18e6ff58 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -121,6 +121,11 @@ fn item(s: &Session, a: &J) -> R { }); let used_in: Vec = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect(); o["usedIn"] = json!(used_in); + // The item's proxy (footage and comps): its file and whether Use Proxy is on (#813). + o["proxy"] = match &it.proxy { + Some(p) => json!({"file": p.footage.path, "isStill": p.footage.kind == effectcraft_project::FootageKind::Still, "enabled": p.enabled}), + None => J::Null, + }; match &it.kind { ItemKind::Folder => { o["kind"] = json!("folder"); @@ -171,6 +176,9 @@ fn item(s: &Session, a: &J) -> R { o["footageMissing"] = json!(fo.missing); o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still); o["loop"] = json!(fo.loop_count); + // Interpret Footage ▸ Conform to frame rate: 0 when the file's own rate is used (#813). + o["conformFrameRate"] = json!(if fo.native_rate.is_some() { fo.frame_rate.as_f64() } else { 0.0 }); + o["nativeFrameRate"] = json!(fo.native_rate.unwrap_or(fo.frame_rate).as_f64()); } ItemKind::Solid(so) => { o["kind"] = json!("solid"); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab..2b4d4ded 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1490,8 +1490,14 @@ AVItem.prototype = Object.create(Item.prototype); def("hasVideo", function () { return this.__info().hasVideo; }); def("hasAudio", function () { return this.__info().hasAudio; }); def("footageMissing", function () { return !!this.__info().footageMissing; }); - def("useProxy", function () { return false; }); - def("proxySource", function () { return null; }); + def("useProxy", function () { var p = this.__info().proxy; return !!(p && p.enabled); }, function (v) { + if (!this.__info().proxy) throw __err("\"" + this.name + "\" has no proxy"); + __call("file.useProxy", { item: this.__id, on: !!v }); + }); + def("proxySource", function () { + var p = this.__info().proxy; + return p ? { isStill: !!p.isStill, file: p.file ? new File(p.file) : null, hasAlpha: false, toString: function () { return "[object FileSource]"; } } : null; + }); def("isMediaReplacementCompatible", function () { return false; }); def("time", function () { return 0; }); })(); @@ -1623,14 +1629,42 @@ FootageItem.prototype.constructor = FootageItem; Object.defineProperty(FootageItem.prototype, "file", { get: function () { var f = this.__info().file; return f ? new File(f) : null; }, }); +// The item's FootageSource: one live object per FootageItem, as in After Effects (#813). +function FootageSource(item) { this.__item = item; } +FootageSource.prototype = { + constructor: FootageSource, + __info: function () { return this.__item.__info(); }, + get isStill() { return !!this.__info().isStill; }, + get color() { return this.__info().color; }, + get file() { var i = this.__info(); return i.kind !== "solid" && i.file ? new File(i.file) : null; }, + get missingFootagePath() { var i = this.__info(); return i.footageMissing ? i.file : ""; }, + get loop() { return this.__info().loop; }, + get hasAlpha() { return false; }, + get nativeFrameRate() { return this.__info().nativeFrameRate || 0; }, + get displayFrameRate() { return this.__info().frameRate; }, + // Interpret Footage ▸ Conform to frame rate; 0 uses the file's own rate. + get conformFrameRate() { return this.__info().conformFrameRate || 0; }, + set conformFrameRate(v) { + v = __num(v, "conformFrameRate"); + if (v < 0) throw __err("conformFrameRate must be 0 or a frame rate"); + __call("file.interpretFootage", { items: [this.__item.__id], frameRate: v === 0 ? "file" : v }); + }, + toString: function () { return this.__info().kind === "solid" ? "[object SolidSource]" : "[object FileSource]"; }, +}; Object.defineProperty(FootageItem.prototype, "mainSource", { get: function () { - var i = this.__info(); - if (i.kind === "solid") return { isStill: true, color: i.color, hasAlpha: false, toString: function () { return "[object SolidSource]"; } }; - return { isStill: !!i.isStill, file: i.file ? new File(i.file) : null, missingFootagePath: i.footageMissing ? i.file : "", loop: i.loop, hasAlpha: false, toString: function () { return "[object FileSource]"; } }; + if (!this.__mainSource) Object.defineProperty(this, "__mainSource", { value: new FootageSource(this) }); + return this.__mainSource; }, }); FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { + __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); +}; +// Proxies (footage and comps). A numbered still brings its sequence, so the two setters agree. +AVItem.prototype.setProxy = function (f) { __call("file.setProxy", { item: this.__id, path: __str(f) }); }; +AVItem.prototype.setProxyWithSequence = function (f) { __call("file.setProxy", { item: this.__id, path: __str(f) }); }; +AVItem.prototype.setProxyToNone = function () { __call("file.setProxyNone", { item: this.__id }); }; FootageItem.prototype.replaceWithSolid = function (color, name, w, h) { __call("file.replaceWithSolid", { item: this.__id, color: [color[0], color[1], color[2]], width: w, height: h }); if (name !== undefined) this.name = name; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820..a5f90684 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -731,6 +731,43 @@ fn import_options_sequence_picks_one_still_or_the_run() { let _ = std::fs::remove_dir_all(&dir); } +/// #813: FootageItem proxies, replaceWithSequence and mainSource.conformFrameRate. +#[test] +fn footage_proxies_sequences_and_conform_rate() { + use effectcraft_engine::project::ItemKind; + let dir = std::env::temp_dir().join(format!("ec-script-proxy-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for n in ["shot_0001.png", "shot_0002.png", "take_0001.png", "take_0002.png", "proxy.png"] { + std::fs::write(dir.join(n), b"").unwrap(); + } + let p = |n: &str| dir.join(n).to_string_lossy().replace('\\', "/"); + let mut s = session(); + s.importer = Some(Arc::new(Stills)); + let o = ok( + &mut s, + &format!( + "var io = new ImportOptions(new File('{}')); io.sequence = true; var item = app.project.importFile(io);\ + var same = item.mainSource === item.mainSource;\ + item.mainSource.conformFrameRate = 12; var c12 = item.mainSource.conformFrameRate, fr = item.frameRate;\ + item.mainSource.conformFrameRate = 0; var c0 = item.mainSource.conformFrameRate;\ + var before = [item.useProxy, item.proxySource];\ + item.setProxy(new File('{}')); var set = [item.useProxy, item.proxySource.file.name];\ + item.useProxy = false; var off = item.useProxy;\ + item.setProxyToNone(); var none = item.proxySource;\ + item.replaceWithSequence(new File('{}'), false);\ + [same, c12, fr, c0, before[0], before[1], set[0], set[1], off, none]", + p("shot_0001.png"), + p("proxy.png"), + p("take_0001.png") + ), + ); + assert_eq!(o.result, json!([true, 12, 12, 0, false, null, true, "proxy.png", false, null])); + let paths: Vec = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); + assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn binary_file_io_preserves_all_byte_values() { let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id()));