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 <[email protected]>
This commit is contained in:
redonion23andClaude Opus 5.5 committed 2026-10-13 05:59:10 +02:00
1 parent 470ea7a3af
commit 004984d853
3 files changed
+84 -5

No files matched your search

+8
View File
@@ -121,6 +121,11 @@ fn item(s: &Session, a: &J) -> R {
});
let used_in: Vec<u64> = 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");
+39 -5
View File
@@ -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;
+37
View File
@@ -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<String> =
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()));