M13.1: branching history, puppet pin recording, WebAssembly plug-in API, ScriptUI model and hosts, File ▸ Scripts, deterministic render test

- engine: History keeps undone lines as branches; edit.history.list/goto; History panel tree; MCP history tool
- puppet.recordPin / puppet.recordOptions; viewer Cmd-drag records; Record Options form
- effects::plugin (EffectPlugin trait, register_plugin, API v1); effectcraft-plugin (wasmi, sandboxed, fuel-limited); effect.plugins.load/list
- ScriptUI: engine scriptui model + layout + scriptui.* commands; script crate scriptui.js and script-host threads (dialogs block show())
- File ▸ Scripts: install script / ScriptUI panel, sample scripts, window.scriptPanel
- host test: bit-identical renders across runs and thread counts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:07:20 -04:00
1 parent ebcb8c95ce
commit 89c9b1b6ee
48 files changed
+4440 -134

No files matched your search

@@ -0,0 +1,41 @@
// Create Null at Selected Layers — an EffectCraft sample script (original work, MIT OR
// Apache-2.0).
//
// Adds a null at the centroid of the selected layers' positions (at the current time) and
// parents the layers that have no parent to it (set PARENT below to false to skip that).
// One undo step.
(function createNullAtCentroid() {
var PARENT = true;
var comp = app.project.activeItem;
if (!(comp instanceof CompItem)) {
alert("Create Null: open a composition first.");
return;
}
var layers = comp.selectedLayers;
if (layers.length === 0) {
alert("Create Null: select one or more layers.");
return;
}
var t = comp.time;
var sum = [0, 0, 0];
for (var i = 0; i < layers.length; i++) {
var p = layers[i].transform.position.valueAtTime(t, false);
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p.length > 2 ? p[2] : 0;
}
var c = [sum[0] / layers.length, sum[1] / layers.length, sum[2] / layers.length];
app.beginUndoGroup("Create Null at Selected Layers");
var nul = comp.layers.addNull();
nul.name = "Centroid";
nul.transform.position.setValue(c);
if (PARENT) {
for (var k = 0; k < layers.length; k++) {
if (!layers[k].parent) layers[k].setParentWithJump(nul);
}
}
app.endUndoGroup();
writeLn("Null at [" + c[0].toFixed(1) + ", " + c[1].toFixed(1) + "]");
})();
+71
View File
@@ -0,0 +1,71 @@
// Rename Layers — an EffectCraft sample script (original work, MIT OR Apache-2.0).
//
// Renames the selected layers of the active composition (all layers when none are selected):
// find & replace, then an optional prefix, suffix and running number. One undo step.
(function renameLayers() {
var comp = app.project.activeItem;
if (!(comp instanceof CompItem)) {
alert("Rename Layers: open a composition first.");
return;
}
var layers = comp.selectedLayers;
if (layers.length === 0) {
layers = [];
for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i));
}
var dlg = new Window("dialog", "Rename Layers");
dlg.orientation = "column";
dlg.alignChildren = ["fill", "top"];
var find = dlg.add("panel", undefined, "Find and Replace");
find.alignChildren = ["fill", "top"];
var row1 = find.add("group");
row1.add("statictext", undefined, "Find:");
var findText = row1.add("edittext", undefined, "");
findText.characters = 20;
var row2 = find.add("group");
row2.add("statictext", undefined, "Replace:");
var replaceText = row2.add("edittext", undefined, "");
replaceText.characters = 20;
var extra = dlg.add("panel", undefined, "Add");
extra.alignChildren = ["fill", "top"];
var row3 = extra.add("group");
row3.add("statictext", undefined, "Prefix:");
var prefix = row3.add("edittext", undefined, "");
prefix.characters = 12;
row3.add("statictext", undefined, "Suffix:");
var suffix = row3.add("edittext", undefined, "");
suffix.characters = 12;
var number = extra.add("checkbox", undefined, "Number the layers");
var row4 = extra.add("group");
row4.add("statictext", undefined, "Start at:");
var start = row4.add("edittext", undefined, "1");
start.characters = 5;
start.enabled = false;
number.onClick = function () {
start.enabled = number.value;
};
var buttons = dlg.add("group");
buttons.alignment = "right";
buttons.add("button", undefined, "Cancel", { name: "cancel" });
buttons.add("button", undefined, "OK", { name: "ok" });
if (dlg.show() !== 1) return;
app.beginUndoGroup("Rename Layers");
var n = parseInt(start.text, 10);
if (isNaN(n)) n = 1;
for (var k = 0; k < layers.length; k++) {
var name = layers[k].name;
if (findText.text !== "") name = name.split(findText.text).join(replaceText.text);
name = prefix.text + name + suffix.text;
if (number.value) name += " " + (n + k);
layers[k].name = name;
}
app.endUndoGroup();
writeLn("Renamed " + layers.length + " layer(s).");
})();
@@ -0,0 +1,52 @@
// Render Queue Batch — an EffectCraft sample script (original work, MIT OR Apache-2.0).
//
// Adds the compositions selected in the Project panel (every composition when none are
// selected) to the Render Queue, each with an output file named after the comp in a folder you
// choose, and optionally starts rendering.
(function renderQueueBatch() {
var comps = [];
var sel = app.project.selection;
for (var i = 0; i < sel.length; i++) if (sel[i] instanceof CompItem) comps.push(sel[i]);
if (comps.length === 0) {
for (var j = 1; j <= app.project.numItems; j++) {
var it = app.project.item(j);
if (it instanceof CompItem) comps.push(it);
}
}
if (comps.length === 0) {
alert("Render Queue Batch: the project has no compositions.");
return;
}
var dlg = new Window("dialog", "Render Queue Batch");
dlg.alignChildren = ["fill", "top"];
dlg.add("statictext", undefined, comps.length + " composition(s) will be queued.");
var row = dlg.add("group");
row.add("statictext", undefined, "Output folder:");
var folder = row.add("edittext", undefined, Folder.desktop ? Folder.desktop.fsName : "");
folder.characters = 30;
var fmtRow = dlg.add("group");
fmtRow.add("statictext", undefined, "Format:");
var format = fmtRow.add("dropdownlist", undefined, ["H.264 (.mp4)", "PNG Sequence"]);
format.selection = 0;
var go = dlg.add("checkbox", undefined, "Start rendering");
var buttons = dlg.add("group");
buttons.alignment = "right";
buttons.add("button", undefined, "Cancel", { name: "cancel" });
buttons.add("button", undefined, "Queue", { name: "ok" });
if (dlg.show() !== 1) return;
var png = format.selection && format.selection.index === 1;
app.beginUndoGroup("Render Queue Batch");
for (var k = 0; k < comps.length; k++) {
var rq = app.project.renderQueue.items.add(comps[k]);
var name = comps[k].name.replace(/[\\\/:*?"<>|]/g, "_");
var om = rq.outputModule(1);
if (png) om.format = "png";
om.file = new File(folder.text + "/" + name + (png ? "_[#####].png" : ".mp4"));
}
app.endUndoGroup();
writeLn("Queued " + comps.length + " composition(s).");
if (go.value) app.project.renderQueue.render();
})();
@@ -0,0 +1,99 @@
// Layer Tools — an EffectCraft sample ScriptUI panel (original work, MIT OR Apache-2.0).
//
// Installed in the ScriptUI Panels folder it docks like any panel (Window ▸ Layer Tools.jsx);
// run from File ▸ Scripts it opens as a floating palette. Buttons act on the selected layers of
// the active composition.
(function layerTools(thisObj) {
var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Layer Tools", undefined, { resizeable: true });
ui.orientation = "column";
ui.alignChildren = ["fill", "top"];
ui.spacing = 6;
function comp() {
var c = app.project.activeItem;
if (!(c instanceof CompItem)) {
status.text = "Open a composition first.";
return null;
}
return c;
}
function selected(c) {
var l = c.selectedLayers;
if (l.length === 0) status.text = "Select some layers.";
return l;
}
var labelRow = ui.add("group");
labelRow.add("statictext", undefined, "Opacity:");
var opacity = labelRow.add("slider", undefined, 100, 0, 100);
opacity.preferredSize.width = 120;
var opacityValue = labelRow.add("statictext", undefined, "100%");
opacityValue.characters = 5;
opacity.onChanging = function () {
opacityValue.text = Math.round(opacity.value) + "%";
};
opacity.onChange = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
app.beginUndoGroup("Set Opacity");
for (var i = 0; i < layers.length; i++) layers[i].transform.opacity.setValue(Math.round(opacity.value));
app.endUndoGroup();
status.text = "Opacity " + Math.round(opacity.value) + "% on " + layers.length + " layer(s).";
};
var shy = ui.add("checkbox", undefined, "Shy");
shy.onClick = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
app.beginUndoGroup("Shy Layers");
for (var i = 0; i < layers.length; i++) layers[i].shy = shy.value;
app.endUndoGroup();
};
var nameRow = ui.add("group");
nameRow.alignChildren = ["fill", "center"];
var newName = nameRow.add("edittext", undefined, "Layer");
newName.characters = 14;
var renameBtn = nameRow.add("button", undefined, "Rename");
renameBtn.onClick = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
app.beginUndoGroup("Rename Layers");
for (var i = 0; i < layers.length; i++) layers[i].name = layers.length > 1 ? newName.text + " " + (i + 1) : newName.text;
app.endUndoGroup();
status.text = "Renamed " + layers.length + " layer(s).";
};
var nullBtn = ui.add("button", undefined, "Null at Centroid");
nullBtn.onClick = function () {
var c = comp();
if (!c) return;
var layers = selected(c);
if (layers.length === 0) return;
var x = 0, y = 0;
for (var i = 0; i < layers.length; i++) {
var p = layers[i].transform.position.value;
x += p[0];
y += p[1];
}
app.beginUndoGroup("Null at Centroid");
var n = c.layers.addNull();
n.transform.position.setValue([x / layers.length, y / layers.length]);
app.endUndoGroup();
status.text = "Added " + n.name + ".";
};
var status = ui.add("statictext", undefined, "Select layers, then pick a tool.");
status.characters = 30;
if (ui instanceof Window) {
ui.center();
ui.show();
} else {
ui.layout.layout(true);
}
})(this);
@@ -0,0 +1,37 @@
// Sort Layers by In Point — an EffectCraft sample script (original work, MIT OR Apache-2.0).
//
// Reorders the selected layers of the active composition (all layers when none are selected)
// so the layer that starts first is on top. Layers with the same in point keep their order.
// One undo step.
(function sortLayersByInPoint() {
var comp = app.project.activeItem;
if (!(comp instanceof CompItem)) {
alert("Sort Layers by In Point: open a composition first.");
return;
}
var layers = comp.selectedLayers;
if (layers.length === 0) {
layers = [];
for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i));
}
if (layers.length < 2) return;
// The slots the layers occupy now (stack indices, top first).
var slots = layers.map(function (l) { return l.index; }).sort(function (a, b) { return a - b; });
var order = layers
.map(function (l, k) { return { layer: l, inPoint: l.inPoint, k: k }; })
.sort(function (a, b) { return a.inPoint - b.inPoint || a.k - b.k; });
app.beginUndoGroup("Sort Layers by In Point");
// Place each layer, earliest first, in the next slot from the top.
for (var j = 0; j < order.length; j++) {
var target = slots[j];
var layer = order[j].layer;
if (layer.index === target) continue;
var anchor = comp.layer(target);
layer.moveBefore(anchor);
}
app.endUndoGroup();
writeLn("Sorted " + layers.length + " layer(s) by in point.");
})();