Files
vfxreview/RenderWorker/scripts/vfxr_build_preview.jsx
T
twotalesanimation 67d8639861
Deploy / deploy (push) Successful in 6m17s
fix(pipeline): trim picture-lock handles at the footage comp, not the template's SHOT layer
Manipulating startTime/outPoint on previewComp's SHOT layer touched a
comp duplicated from the artist-authored template, whose slate/shot
timing relationship the build script doesn't own -- and it dropped the
slate frame in production. The trim now happens one level down, on the
private single-layer footage precomp this script fully controls;
previewComp's own sizing logic is untouched from before this feature
existed, since it already generalizes correctly to any shotComp
duration.

Also threads the picture-lock skip reason through to the export's
timeline, so a shot missing sequence timecodes or a matching picture
lock file is visible without digging into worker-machine logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:22:49 +02:00

747 lines
28 KiB
React

/*
vfxr_build_preview.jsx — VFXReview render pipeline, preview build stage.
Runs headlessly: AfterFX.com -noui -r <wrapper>.jsx
where the wrapper sets var VFXR_CONTEXT_PATH = "...json"; then
$.evalFile()s this script.
It does NOT render. It opens the studio slate/overlay template, rebuilds
the shot around the *rendered* EXR sequence (OCIO → show LUT → overlay),
duplicates the export template into a preview comp with the slate filled
in, queues the MOV and MP4 output modules, and saves a throwaway AEP.
The worker then runs `aerender -project <that aep>` with no -comp, which
renders the whole queue in one launch.
Build logic mirrors the VFXReviewConnector panel functions
buildShotFromCode / updateOverlayForComp / buildPreviewForShot so the
farm and the artist produce identical slates and burn-ins.
Everything is reported back through the result JSON — never a dialog.
*/
(function vfxrBuildPreview() {
var ctx = null;
var result = {
ok: false,
error: null,
warnings: [],
tempAep: null,
previewComp: null,
queued: [],
frameCount: null,
width: null,
height: null,
piclockRefBuilt: false,
piclockSkippedReason: null
};
var resultPath = null;
// ── tiny helpers (ES3 / ExtendScript safe) ───────────────────────────────
function readTextFile(path) {
var f = new File(path);
var text;
if (!f.exists) {
return null;
}
f.encoding = "UTF-8";
if (!f.open("r")) {
return null;
}
text = f.read();
f.close();
return text;
}
function writeTextFile(path, text) {
var f = new File(path);
try {
f.encoding = "UTF-8";
if (!f.open("w")) {
return false;
}
f.write(text);
f.close();
return true;
} catch (writeError) {
return false;
}
}
function jsonEscape(value) {
var text = String(value);
var out = "";
var i;
var ch;
var code;
for (i = 0; i < text.length; i += 1) {
ch = text.charAt(i);
code = text.charCodeAt(i);
if (ch === "\"") {
out += "\\\"";
} else if (ch === "\\") {
out += "\\\\";
} else if (ch === "\n") {
out += "\\n";
} else if (ch === "\r") {
out += "\\r";
} else if (ch === "\t") {
out += "\\t";
} else if (code < 32) {
out += "\\u" + ("000" + code.toString(16)).slice(-4);
} else {
out += ch;
}
}
return out;
}
function toJSON(value) {
var parts = [];
var i;
var key;
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "number") {
return isFinite(value) ? String(value) : "null";
}
if (typeof value === "boolean") {
return String(value);
}
if (typeof value === "string") {
return "\"" + jsonEscape(value) + "\"";
}
if (value instanceof Array) {
for (i = 0; i < value.length; i += 1) {
parts.push(toJSON(value[i]));
}
return "[" + parts.join(",") + "]";
}
for (key in value) {
if (value.hasOwnProperty(key) && value[key] !== undefined) {
parts.push("\"" + jsonEscape(key) + "\":" + toJSON(value[key]));
}
}
return "{" + parts.join(",") + "}";
}
function warn(message) {
result.warnings.push(String(message));
}
function findComp(name) {
var i;
var item;
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof CompItem && item.name === name) {
return item;
}
}
return null;
}
function findFolder(name) {
var i;
var item;
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FolderItem && item.name === name) {
return item;
}
}
return null;
}
// Mirrors the panel's parseTimecodeToSeconds exactly.
function parseTimecodeToSeconds(tc, fps) {
var parts = String(tc).split(/[:;]/);
if (parts.length < 4) {
return 0;
}
return parseInt(parts[0], 10) * 3600 + parseInt(parts[1], 10) * 60 +
parseInt(parts[2], 10) + parseInt(parts[3], 10) / (fps || 24);
}
// Locates the picture-lock reference footage the same way the panel's
// pullPictureLock does: the _PICLOCKS project folder first, then a disk
// glob under ctx.piclockDir. Returns the FootageItem, or null (with a
// warn()) if it cannot be found — never fatal to the preview build.
function findPiclockFootage(ctx) {
var piclocksProjectFolder = findFolder("_PICLOCKS");
var i;
var item;
var piclocksDir;
var files;
var importOptions;
if (piclocksProjectFolder) {
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FootageItem &&
item.parentFolder === piclocksProjectFolder &&
item.name.indexOf(ctx.piclockGlobPrefix + "_") === 0) {
return item;
}
}
}
if (!ctx.piclockDir) {
warn("Picture lock: no footage root configured for this project — skipping reference render");
return null;
}
piclocksDir = new Folder(ctx.piclockDir);
if (!piclocksDir.exists) {
warn("Picture lock: folder not found: " + piclocksDir.fsName);
return null;
}
files = piclocksDir.getFiles(ctx.piclockGlobPrefix + "_*");
if (!files || files.length < 1) {
warn("Picture lock: no file matching " + ctx.piclockGlobPrefix + "_* found in " + piclocksDir.fsName);
return null;
}
for (i = 0; i < files.length; i += 1) {
if (files[i] instanceof File) {
try {
importOptions = new ImportOptions(files[i]);
return app.project.importFile(importOptions);
} catch (importError) {
warn("Picture lock: import failed: " + importError.message);
return null;
}
}
}
return null;
}
// Mirrors the panel's addOCIOToLayer: effect added but disabled, so the
// artist/farm chain matches exactly.
function addOCIOToLayer(layer) {
var ocio;
var outputProp;
try {
ocio = layer.property("Effects").addProperty("OCIO Color Space Transform");
ocio.enabled = false;
} catch (ocioError) {
warn("OCIO effect unavailable: " + ocioError.toString());
return;
}
try {
outputProp = ocio.property("Output Color Space");
} catch (byNameError) {
outputProp = null;
}
if (!outputProp) {
try {
outputProp = ocio.property(2);
} catch (byIndexError) {
outputProp = null;
}
}
if (outputProp) {
try {
outputProp.setValue(93);
} catch (setError) {
warn("Could not set OCIO output colour space: " + setError.toString());
}
}
}
function setEssentialProperty(layer, propName, value) {
if (value === null || value === undefined || value === "") {
return false;
}
try {
layer.property("Essential Properties").property(propName).setValue(value);
return true;
} catch (setError) {
return false;
}
}
function setEssentialPropertyByIndex(layer, index, value) {
try {
layer.property("Essential Properties").property(index).setValue(value);
return true;
} catch (setError) {
return false;
}
}
function listEssentialPropertyNames(layer) {
var names = [];
var group;
var i;
try {
group = layer.property("Essential Properties");
for (i = 1; i <= group.numProperties; i += 1) {
try {
names.push(i + ":" + group.property(i).name);
} catch (oneError) {
}
}
} catch (groupError) {
}
return names;
}
// Slate identity fields (version name, shot name). Current templates expose
// these under a stable name, older ones only positionally, and the two do
// not always line up — so write both. Setting the same property twice is
// harmless; the panel uses the same name-with-index-fallback pattern.
function setSlateIdentity(layer, propName, index, value, label) {
var byName;
var byIndex;
if (value === null || value === undefined || value === "") {
return; // leave the template's own default
}
byName = setEssentialProperty(layer, propName, value);
byIndex = setEssentialPropertyByIndex(layer, index, value);
if (!byName && !byIndex) {
warn(label + ": neither Essential Property \"" + propName + "\" nor index " +
index + " could be set. Available: " +
listEssentialPropertyNames(layer).join(", "));
}
}
// Per-submission fields (VFX Scope / Submission Note). The property names
// come from config; if one does not exist, report the template's actual
// property names so it can be corrected without another build.
function setSlateField(layer, propName, value, label) {
if (!propName) {
return;
}
if (value === null || value === undefined || value === "") {
return; // nothing to write; leave the template's own default
}
if (!setEssentialProperty(layer, propName, value)) {
warn(label + ": Essential Property \"" + propName + "\" not found or not settable. " +
"Available: " + listEssentialPropertyNames(layer).join(", "));
}
}
// The production name, burned into both the slate and the overlay. The
// slate's other fields are title case ("Date", "Scene") while the overlay's
// are upper ("DATE", "SHOT NAME"), so both spellings are attempted rather
// than assuming which template uses which — and a template that exposes
// neither reports its actual property names instead of failing silently.
function setShowField(layer, value, label) {
var candidates = ["SHOW", "Show"];
var i;
if (value === null || value === undefined || value === "") {
return;
}
for (i = 0; i < candidates.length; i += 1) {
if (setEssentialProperty(layer, candidates[i], value)) {
return;
}
}
warn(label + ": no Essential Property named " + candidates.join(" or ") +
". Available: " + listEssentialPropertyNames(layer).join(", "));
}
function setCompStartFrame(comp, frameNumber) {
try {
comp.displayStartFrame = frameNumber;
return;
} catch (frameError) {
}
try {
comp.displayStartTime = frameNumber / comp.frameRate;
} catch (timeError) {
}
}
function applyOutputModuleTemplate(outputModule, templateName) {
var templates;
var i;
try {
templates = outputModule.templates;
for (i = 0; i < templates.length; i += 1) {
if (templates[i] === templateName) {
outputModule.applyTemplate(templateName);
return true;
}
}
} catch (templateError) {
}
return false;
}
function importExrSequence(dirPath) {
var folder = new Folder(dirPath);
var files;
var importOptions;
var footage;
if (!folder.exists) {
throw new Error("Render output folder not found: " + dirPath);
}
files = folder.getFiles("*.exr");
if (!files || files.length < 1) {
throw new Error("No EXR files found in " + dirPath);
}
files.sort();
importOptions = new ImportOptions(files[0]);
importOptions.sequence = true;
footage = app.project.importFile(importOptions);
try {
footage.mainSource.conformFrameRate = ctx.fps || 24;
} catch (conformError) {
warn("Could not conform frame rate: " + conformError.toString());
}
return footage;
}
function queueOutput(comp, templateName, outputPath, label) {
var item;
var outputModule;
if (!templateName || !outputPath) {
warn("Skipping " + label + ": no template or output path configured");
return false;
}
try {
item = app.project.renderQueue.items.add(comp);
outputModule = item.outputModule(1);
if (!applyOutputModuleTemplate(outputModule, templateName)) {
item.remove();
throw new Error("Output module template not found: \"" + templateName + "\"");
}
outputModule.file = new File(outputPath);
result.queued.push({ label: label, template: templateName, output: outputPath });
return true;
} catch (queueError) {
try {
if (item) {
item.remove();
}
} catch (removeError) {
}
throw queueError;
}
}
// ── main ─────────────────────────────────────────────────────────────────
try {
if (typeof VFXR_CONTEXT_PATH === "undefined" || !VFXR_CONTEXT_PATH) {
throw new Error("VFXR_CONTEXT_PATH was not set by the wrapper script");
}
var contextText = readTextFile(VFXR_CONTEXT_PATH);
if (!contextText) {
throw new Error("Could not read job context: " + VFXR_CONTEXT_PATH);
}
ctx = eval("(" + contextText + ")");
resultPath = ctx.resultPath;
try {
app.beginSuppressDialogs();
} catch (suppressError) {
}
// 1. Open the studio template (slate, overlay, show LUT, export template)
var templateFile = new File(ctx.templateAep);
if (!templateFile.exists) {
throw new Error("Template project not found: " + ctx.templateAep);
}
app.open(templateFile);
var templateComp = findComp(ctx.templateComp);
if (!templateComp) {
throw new Error("Export template comp \"" + ctx.templateComp + "\" not found in template project");
}
// 2. Import the rendered EXR sequence
var footage = importExrSequence(ctx.outputDirLocal);
var footageFolder = findFolder("_FOOTAGE_4K");
if (footageFolder) {
footage.parentFolder = footageFolder;
}
footage.name = ctx.shotCode + "_" + ctx.versionString;
var width = footage.width;
var height = footage.height;
var duration = footage.duration;
var fps = ctx.fps || 24;
result.width = width;
result.height = height;
result.frameCount = Math.round(duration * fps);
// Trim decision (§9, picture lock): the render's frame range is
// editorial ± a fixed symmetric handle count by studio convention (the
// same constant pullPictureLock hardcodes), so no picture-lock file is
// needed for this — only ctx.handleFrames. Applied at the footage/shot
// comp level (below) rather than by juggling layer timing inside
// previewComp — previewComp is duplicated from an artist-authored
// template whose slate/shot timing relationship this script does not
// own, so it is left completely untouched, exactly as it always
// rendered correctly before this feature existed.
var handleSeconds = (ctx.handleFrames || 0) / fps;
var contentDuration = duration;
var headSkipSeconds = 0;
var trimmedToPictureLock = false;
if (ctx.noPictureLock) {
result.piclockSkippedReason = "No Picture Lock was set for this export";
} else if (handleSeconds <= 0) {
result.piclockSkippedReason = "No handle frame count configured";
} else if (2 * handleSeconds >= duration) {
warn("Picture lock: handle count (" + ctx.handleFrames + "fr) covers the whole render (" +
result.frameCount + "fr) — trim skipped");
result.piclockSkippedReason = "handle count exceeds the render duration";
} else {
headSkipSeconds = handleSeconds;
contentDuration = duration - (2 * handleSeconds);
trimmedToPictureLock = true;
}
var contentStartFrame = (ctx.frameStart !== null && ctx.frameStart !== undefined)
? ctx.frameStart + Math.round(headSkipSeconds * fps)
: null;
// 3. Footage precomp (mirrors buildShotFromCode)
var precompFolder = findFolder("_PRECOMPS");
var footageComp = app.project.items.addComp(
ctx.shotCode + "_FOOTAGE", width, height, 1, contentDuration, fps);
if (precompFolder) {
footageComp.parentFolder = precompFolder;
}
if (contentStartFrame !== null) {
setCompStartFrame(footageComp, contentStartFrame);
}
var footageLayer = footageComp.layers.add(footage);
if (headSkipSeconds > 0) {
footageLayer.startTime -= headSkipSeconds;
}
addOCIOToLayer(footageLayer);
// 4. Shot comp: footage + show LUT + overlay
var shotComp = app.project.items.addComp(ctx.shotCode, width, height, 1, contentDuration, fps);
if (contentStartFrame !== null) {
setCompStartFrame(shotComp, contentStartFrame);
}
shotComp.layers.add(footageComp);
if (ctx.lutComp) {
var showLut = findComp(ctx.lutComp);
if (showLut) {
var lutLayer = shotComp.layers.add(showLut);
lutLayer.collapseTransformation = true;
} else {
warn("Show LUT comp \"" + ctx.lutComp + "\" not found — rendering without it");
}
}
if (ctx.overlayComp) {
var overlayComp = findComp(ctx.overlayComp);
if (overlayComp) {
var overlayLayer = shotComp.layers.add(overlayComp);
overlayLayer.moveToBeginning();
overlayLayer.enabled = true;
setEssentialProperty(overlayLayer, "DATE", ctx.slate.date);
setEssentialProperty(overlayLayer, "SHOT NAME", ctx.slate.versionName);
setShowField(overlayLayer, ctx.slate.show, "Overlay SHOW");
} else {
warn("Overlay comp \"" + ctx.overlayComp + "\" not found — rendering without burn-ins");
}
}
// 5. Preview comp from the export template (mirrors buildPreviewForShot)
var previewsFolder = findFolder("_PREVIEWS");
var previewComp = templateComp.duplicate();
previewComp.name = ctx.shotCode + "_PREVIEW";
if (previewsFolder) {
previewComp.parentFolder = previewsFolder;
}
previewComp.layer("SHOT").replaceSource(shotComp, false);
try {
previewComp.layer("THUMBNAIL").replaceSource(shotComp, false);
} catch (thumbError) {
warn("THUMBNAIL layer not updated: " + thumbError.toString());
}
var slateLayer = previewComp.layer("NETFLIX_SLATE");
if (slateLayer) {
// Identity fields match the panel's indices (3 = version name,
// 10 = shot name) and are also written by name; the rest are
// addressed by name only.
// VersionName carries the full "{shotCode}_cmp_TT_{version}";
// ShotName is the bare shot code, with no version suffix.
setSlateIdentity(slateLayer, "VersionName", 3, ctx.slate.versionName, "Version Name");
setSlateIdentity(slateLayer, "ShotName", 10, ctx.slate.shotCode, "Shot Name");
setEssentialProperty(slateLayer, "Date", ctx.slate.date);
setEssentialProperty(slateLayer, "Desc", ctx.slate.description);
setEssentialProperty(slateLayer, "Episode", ctx.slate.episode);
setEssentialProperty(slateLayer, "Scene", ctx.slate.scene);
setEssentialProperty(slateLayer, "Frames", result.frameCount);
setShowField(slateLayer, ctx.slate.show, "Slate SHOW");
setSlateField(slateLayer, ctx.slateScopeProp, ctx.slate.vfxScope, "VFX Scope");
// The slate's Notes field carries this submission's note; when the
// artist left it blank, fall back to the shot's own notes so the
// slate looks the same as an artist-built preview.
setSlateField(slateLayer, ctx.slateSubmissionProp,
(ctx.slate.submissionNote !== null &&
ctx.slate.submissionNote !== undefined &&
ctx.slate.submissionNote !== "")
? ctx.slate.submissionNote
: ctx.slate.notes,
"Submission Note");
} else {
warn("NETFLIX_SLATE layer not found in template — slate fields not filled");
}
// previewComp sizes itself to whatever shotComp.duration actually is —
// unchanged from before this feature existed. shotComp is already the
// trimmed (or full, if skipped above) duration, so this needs no
// special-casing here.
var shotLayer = previewComp.layer("SHOT");
var targetOutPoint = shotLayer.inPoint + shotComp.duration;
if (targetOutPoint > previewComp.duration) {
previewComp.duration = targetOutPoint;
}
shotLayer.outPoint = targetOutPoint;
previewComp.duration = targetOutPoint;
result.previewComp = previewComp.name;
result.trimmedToPictureLock = trimmedToPictureLock;
app.project.renderQueue.showWindow(false);
while (app.project.renderQueue.numItems > 0) {
app.project.renderQueue.item(1).remove();
}
// 5b. Picture-lock reference comp: the raw plate only — no OCIO, no
// show LUT, no overlay — spanning the FULL padded duration (handles
// included), so a supervisor can check back against it if a delivered
// edit is ever suspected to be off. Independent of the trim above:
// this one needs the actual picture-lock file and sequence timecodes,
// and is skipped gracefully (never fatal) if either is missing.
if (ctx.noPictureLock) {
if (!result.piclockSkippedReason) {
result.piclockSkippedReason = "No Picture Lock was set for this export";
}
} else if (!ctx.seqTimecodeStart || !ctx.seqTimecodeEnd) {
warn("Picture lock: no sequence timecodes for this shot — reference render skipped");
result.piclockSkippedReason = "no sequence timecodes for this shot";
} else {
// A diagnostic aid, not a deliverable: any failure building or
// queuing it must warn and move on, never take down the mov/mp4
// that already succeeded (queueOutput() throws on template errors).
try {
var piclockFootage = findPiclockFootage(ctx);
if (piclockFootage) {
var piclockStartTC = 0;
try {
if (piclockFootage.mainSource && typeof piclockFootage.mainSource.startTimecode === "number") {
piclockStartTC = piclockFootage.mainSource.startTimecode;
}
} catch (tcReadError) {
}
var tcIn = parseTimecodeToSeconds(ctx.seqTimecodeStart, fps);
var tcOut = parseTimecodeToSeconds(ctx.seqTimecodeEnd, fps);
var refIn = tcIn - piclockStartTC - handleSeconds;
var refOut = tcOut - piclockStartTC + handleSeconds;
if (refIn < 0) {
refIn = 0;
}
if (refOut > piclockFootage.duration) {
refOut = piclockFootage.duration;
}
var piclockRefComp = app.project.items.addComp(
ctx.shotCode + "_PICLOCK_REF", width, height, 1, refOut - refIn, fps);
if (previewsFolder) {
piclockRefComp.parentFolder = previewsFolder;
}
var piclockLayer = piclockRefComp.layers.add(piclockFootage);
var piclockScale = Math.max(
piclockRefComp.width / piclockFootage.width,
piclockRefComp.height / piclockFootage.height
) * 100;
piclockLayer.property("Scale").setValue([piclockScale, piclockScale]);
piclockLayer.startTime = -refIn;
piclockLayer.inPoint = 0;
piclockLayer.outPoint = refOut - refIn;
if (queueOutput(piclockRefComp, ctx.mp4Template, ctx.piclockRefOutput, "piclockRef")) {
result.piclockRefBuilt = true;
} else {
result.piclockSkippedReason = "no mp4 template or output path configured";
}
} else if (!result.piclockSkippedReason) {
result.piclockSkippedReason = "picture-lock file not found";
}
} catch (piclockError) {
warn("Picture lock: reference render skipped — " +
(piclockError && piclockError.toString ? piclockError.toString() : String(piclockError)));
result.piclockSkippedReason = "error building the reference render";
result.piclockRefBuilt = false;
}
}
// 6. Queue both outputs against the same preview comp. aerender will
// render the whole queue in one launch.
queueOutput(previewComp, ctx.movTemplate, ctx.movOutput, "mov");
queueOutput(previewComp, ctx.mp4Template, ctx.mp4Output, "mp4");
if (result.queued.length < 1) {
throw new Error("Nothing was queued — check output module templates");
}
// 7. Save the throwaway project the worker will hand to aerender
var tempFile = new File(ctx.tempAep);
var tempParent = tempFile.parent;
if (tempParent && !tempParent.exists) {
tempParent.create();
}
app.project.save(tempFile);
result.tempAep = ctx.tempAep;
result.ok = true;
} catch (buildError) {
result.ok = false;
result.error = buildError && buildError.toString ? buildError.toString() : String(buildError);
try {
if (buildError && buildError.line) {
result.error += " (line " + buildError.line + ")";
}
} catch (lineError) {
}
}
try {
app.endSuppressDialogs(false);
} catch (endSuppressError) {
}
if (!resultPath && ctx && ctx.resultPath) {
resultPath = ctx.resultPath;
}
if (resultPath) {
writeTextFile(resultPath, toJSON(result));
}
// -noui leaves the app running otherwise; the worker waits on exit.
try {
app.quit();
} catch (quitError) {
}
}());