Files
vfxreview/RenderWorker/scripts/vfxr_build_preview.jsx
T
twotalesanimation 783e82eaf5 feat(render-pipeline): sync task title to preview version, fill slate ShotName
finalizePreview now retitles the export's task so its version suffix matches the
preview that was just uploaded (UNG_102_019_010_cmp_TT_v004 -> _v005), which is
the rename the manual batch upload already does on rename-and-upload. The new
nextTaskTitle helper mirrors nextExrOutputBase so the task title, EXR base and
preview filename all derive the suffix the same way. It uses the export's
versionString rather than versionNumber, since the DB number is bumped to stay
monotonic per task while the filename follows the shot's shotVersion.

The rename only fires when a Version was actually created, runs in the same
transaction, and is recorded in the ExportEvent note; the finalize response now
returns the task id, title and whether it changed. Task status is deliberately
untouched (pipeline renders must not resurface in the review flow, S10.0).

The headless preview build was addressing the slate's identity fields by index
only, so ShotName was never filled on the farm. setSlateIdentity writes both the
named property and the index -- the name-with-index-fallback pattern the panel
already uses for VersionName -- and warns with the template's actual property
names when neither resolves, instead of failing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:30:47 +02:00

551 lines
19 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
};
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 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(", "));
}
}
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);
// 3. Footage precomp (mirrors buildShotFromCode)
var precompFolder = findFolder("_PRECOMPS");
var footageComp = app.project.items.addComp(
ctx.shotCode + "_FOOTAGE", width, height, 1, duration, fps);
if (precompFolder) {
footageComp.parentFolder = precompFolder;
}
if (ctx.frameStart !== null && ctx.frameStart !== undefined) {
setCompStartFrame(footageComp, ctx.frameStart);
}
addOCIOToLayer(footageComp.layers.add(footage));
// 4. Shot comp: footage + show LUT + overlay
var shotComp = app.project.items.addComp(ctx.shotCode, width, height, 1, duration, fps);
if (ctx.frameStart !== null && ctx.frameStart !== undefined) {
setCompStartFrame(shotComp, ctx.frameStart);
}
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);
} 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);
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");
}
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;
// 6. Queue both outputs against the same preview comp. aerender will
// render the whole queue in one launch.
app.project.renderQueue.showWindow(false);
while (app.project.renderQueue.numItems > 0) {
app.project.renderQueue.item(1).remove();
}
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) {
}
}());