/* vfxr_build_preview.jsx — VFXReview render pipeline, preview build stage. Runs headlessly: AfterFX.com -noui -r .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 ` 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) { } }());