From 93137cdcd2a1c8a953a8ce408f8af6916ae079c1 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:53:49 +0200 Subject: [PATCH] fix(connector): anchor built comps on the plate's own EXR timecode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build Shot produced comps starting at 00:00:00:00 because the panel never actually read the EXR timecode. getFootageStartFrames ranked a fragile "New Comp from Selection" probe first: it toggles the Project panel via the Window menu to get focus, and where that does not land the menu item is greyed out, executeCommand silently does nothing, and the probe returns null. A shot with no timecodeStart then fell through the API rank to zero. The worker already reads EXR headers properly in ExrHeader.cs, and its validation ranks the plate's own timeCode above the API value, which stores the editorial in-point and sits a handle-length ahead of the plate by design. Shots were therefore being built from rank 3 and validated against rank 1. That is a timecode desync waiting to reach a delivery, not just a cosmetic start frame. readExrTimecode ports the worker's reader to ExtendScript and becomes rank 1: one short header read, walking `name\0 type\0 int32 size data` for timeCode and falling back to timeCodeString where a writer omitted the packed attribute. decodePackedTimecode mirrors Timecode.FromPacked, decoding the SMPTE 12M BCD a byte at a time so the arithmetic never touches the sign bit. The menu probe is kept as rank 2 and now runs only when the plate carries no timecode at all, so the Project-panel toggle is off the normal path. setCompStartFrame was the second failure. Assigning displayStartFrame on a version of AE that does not have the property is ignored silently rather than throwing, so the early return skipped the displayStartTime fallback entirely. It now reads the value back, falls through when it did not take, and returns whether anything stuck; buildShotFromCode logs when nothing does. A start frame that cannot be set is now visible in the diagnostics report instead of a comp quietly sitting at zero. A plate whose timecode is present but zero still falls through to the API rank, keeping the existing frames > 0 convention so the safety net survives. If a genuine 00:00:00:00 plate should win outright that is a deliberate follow-up. The parser is covered against synthetic headers, including the 0x18585712 example documented in ExrHeader.cs, drop-frame, truncated and non-EXR input. What is not yet exercised in a live After Effects session is mainSource.file resolving to the sequence's first frame and displayStartFrame taking on a given AE build, so build one shot and check its start against the plate before relying on this. Picture-lock automation was reverted for a timecode desync of the same shape. PANEL_VERSION goes to 2.6.0 per the documented rule, so Pipeline → Machines does not show two workstations on one version behaving differently. The startSource comment in the manifest schema enumerated only the two older values; the queue-time provenance check keys on /\bAPI\b/ and so already reads "EXR timecode …" as trustworthy rather than suspect. The same silent-failure setCompStartFrame still exists in the farm-side vfxr_build_preview.jsx, which is fed the right value from the manifest but can drop it the same way. Left for its own change. Co-Authored-By: Claude Opus 5 --- VFXReviewConnector.jsx | 244 ++++++++++++++++++++++++++++++-- VFXReviewConnector.md | 29 +++- lib/render-pipeline/manifest.ts | 8 +- 3 files changed, 265 insertions(+), 16 deletions(-) diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index 40ba73d..a740d4e 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -32,7 +32,7 @@ var SETTINGS_KEY = "cfg"; // Bump on every change deployed to a workstation. The panel reports this to // the server on load, so Pipeline → Machines shows which version each machine // is actually running and flags the ones left behind. -var PANEL_VERSION = "2.5.0"; +var PANEL_VERSION = "2.6.0"; (function VFXReviewConnector(thisObj) { var uiState = { @@ -958,17 +958,29 @@ var PANEL_VERSION = "2.5.0"; } } + // Assigning displayStartFrame on a version of AE that does not have the + // property is ignored silently rather than throwing, which is one of the + // ways a comp ends up back at 00:00:00:00. Read the value back and fall + // through to displayStartTime when it did not take, and tell the caller + // whether anything stuck. function setCompStartFrame(comp, frameNumber) { + var wanted = Math.round(frameNumber); + try { comp.displayStartFrame = frameNumber; - return; + if (Math.round(comp.displayStartFrame) === wanted) { + return true; + } } catch (frameError) { } try { comp.displayStartTime = frameNumber / comp.frameRate; + return Math.round(comp.displayStartTime * comp.frameRate) === wanted; } catch (timeError) { } + + return false; } function deselectAllProjectItems() { @@ -1051,13 +1063,220 @@ var PANEL_VERSION = "2.5.0"; return frames; } - // The plate's own start timecode: AE metadata when we can read it, - // otherwise the API's source clip timecode less the handle frames — the - // same convention the Set Start Timecode button uses. + // The first file of an imported sequence: the frame whose header carries the + // plate's start timecode. importEXRSequence sorts before importing, so this + // is the sequence's first frame and not an arbitrary one. + function getFootageFirstFile(footageItem) { + try { + if (footageItem && footageItem.mainSource && footageItem.mainSource.file) { + return footageItem.mainSource.file; + } + } catch (fileError) { + } + + return null; + } + + function readUInt32LE(buf, offset) { + return (buf.charCodeAt(offset) & 0xFF) + + (buf.charCodeAt(offset + 1) & 0xFF) * 256 + + (buf.charCodeAt(offset + 2) & 0xFF) * 65536 + + (buf.charCodeAt(offset + 3) & 0xFF) * 16777216; + } + + function readInt32LE(buf, offset) { + var value = readUInt32LE(buf, offset); + + return value >= 2147483648 ? value - 4294967296 : value; + } + + // A null-terminated header string. Returns null when the terminator is not + // found within a sane distance, which means the bytes are not a header we + // understand and parsing should stop rather than guess. + function readExrString(buf, offset) { + var end = offset; + var limit = Math.min(buf.length, offset + 256); + + while (end < limit && buf.charCodeAt(end) !== 0) { + end += 1; + } + + if (end >= limit) { + return null; + } + + return buf.substring(offset, end); + } + + function pad2(value) { + return (value < 10 ? "0" : "") + value; + } + + function makeTimecode(hours, minutes, seconds, frames, dropFrame) { + if (hours > 23 || minutes > 59 || seconds > 59) { + return null; + } + + return { + hours: hours, + minutes: minutes, + seconds: seconds, + frames: frames, + dropFrame: dropFrame, + text: pad2(hours) + ":" + pad2(minutes) + ":" + pad2(seconds) + + (dropFrame ? ";" : ":") + pad2(frames) + }; + } + + // SMPTE 12M as stored in the EXR timeCode attribute: BCD-packed, which is + // why the raw value reads back as its own hex digits (0x18585712 is + // 18:58:57:12). Every field sits inside a single byte, so the nibbles can be + // picked off per byte and never touch the sign bit. + function decodePackedTimecode(buf, offset) { + var b0 = buf.charCodeAt(offset) & 0xFF; // frames, drop-frame flag + var b1 = buf.charCodeAt(offset + 1) & 0xFF; // seconds + var b2 = buf.charCodeAt(offset + 2) & 0xFF; // minutes + var b3 = buf.charCodeAt(offset + 3) & 0xFF; // hours + + return makeTimecode( + (b3 & 0x0F) + ((b3 >> 4) & 0x03) * 10, + (b2 & 0x0F) + ((b2 >> 4) & 0x07) * 10, + (b1 & 0x0F) + ((b1 >> 4) & 0x07) * 10, + (b0 & 0x0F) + ((b0 >> 4) & 0x03) * 10, + ((b0 >> 6) & 0x01) === 1); + } + + function parseTimecodeText(text) { + var parts = String(text).split(/[:;]/); + + if (parts.length < 4) { + return null; + } + + return makeTimecode( + parseInt(parts[0], 10) || 0, + parseInt(parts[1], 10) || 0, + parseInt(parts[2], 10) || 0, + parseInt(parts[3], 10) || 0, + String(text).indexOf(";") !== -1); + } + + // Reads the plate's timecode straight out of the OpenEXR header, the way the + // worker's ExrHeader.cs does, so the panel anchors comps on exactly the + // value validation later checks them against. Header layout is + // magic(4) version(4) then attributes as `name\0 type\0 int32 size data`, + // terminated by an empty name — one short read, no pixel decode. + // + // Returns null when the file is unreadable or carries no timecode at all. + function readExrTimecode(file) { + var EXR_HEADER_BYTES = 65536; + var EXR_MAGIC = 20000630; + var buf = null; + var opened = false; + var pos = 8; + var packed = null; + var text = null; + var name; + var type; + var size; + + if (!file || !file.exists) { + return null; + } + + try { + file.encoding = "BINARY"; + opened = file.open("r"); + if (opened) { + buf = file.read(EXR_HEADER_BYTES); + } + } catch (readError) { + buf = null; + } + + try { + if (opened) { + file.close(); + } + } catch (closeError) { + } + + if (!buf || buf.length < 16 || readUInt32LE(buf, 0) !== EXR_MAGIC) { + return null; + } + + while (pos < buf.length) { + name = readExrString(buf, pos); + if (name === null) { + break; + } + pos += name.length + 1; + if (name.length === 0) { + break; // an empty name ends the header + } + + type = readExrString(buf, pos); + if (type === null) { + break; + } + pos += type.length + 1; + + if (pos + 4 > buf.length) { + break; + } + size = readInt32LE(buf, pos); + pos += 4; + if (size < 0 || pos + size > buf.length) { + break; + } + + if (name === "timeCode" && size >= 8) { + packed = decodePackedTimecode(buf, pos); + } else if (name === "timeCodeString" && size >= 11) { + text = parseTimecodeText(buf.substr(pos, size).replace(/\0+$/, "")); + } + + pos += size; + } + + // The packed attribute is the authoritative one; timeCodeString is a + // convenience copy that some writers emit and others omit. + return packed || text; + } + + // Non-drop frame count, mirroring Timecode.ToFrames in the worker. The show + // rate is the authority — a plate's own rate is whatever happens to be on + // disk, and plates are conformed to the show rate on import. + function timecodeToFrames(tc, fps) { + var rate = Math.round(resolveFps(fps)); + + return ((tc.hours * 60 + tc.minutes) * 60 + tc.seconds) * rate + tc.frames; + } + + // The plate's own start timecode, ranked the way validation ranks it + // (RenderPipeline2 §8.1a): the EXR header's embedded timecode first, then + // AE's own read of the footage metadata, and only then the API's source clip + // timecode less the handle frames — the convention the Set Start Timecode + // button uses. The API value is the editorial in-point and sits a + // handle-length ahead of the plate by design, so it is the last resort + // rather than the first. function getFootageStartFrames(footageItem, apiShot, fps) { - var probed = probeFootageStartFrames(footageItem, fps); + var exrTc = readExrTimecode(getFootageFirstFile(footageItem)); + var probed; var frames; + if (exrTc) { + frames = timecodeToFrames(exrTc, fps); + if (frames > 0) { + return { + frames: frames, + source: "EXR timecode " + exrTc.text + + (exrTc.dropFrame ? " (drop-frame flag set on the plate)" : "") + }; + } + } + + probed = probeFootageStartFrames(footageItem, fps); if (probed !== null && probed > 0) { return { frames: probed, source: "footage metadata" }; } @@ -1069,7 +1288,10 @@ var PANEL_VERSION = "2.5.0"; } } - return { frames: 0, source: probed === 0 ? "footage starts at 00:00:00:00" : "no timecode found" }; + return { + frames: 0, + source: (exrTc || probed === 0) ? "the plate starts at 00:00:00:00" : "no timecode found" + }; } function findComp(name) { @@ -1914,7 +2136,9 @@ var PANEL_VERSION = "2.5.0"; diagLog("start frame " + startInfo.frames + " from " + startInfo.source); footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, fps); - setCompStartFrame(footageComp, startInfo.frames); + if (!setCompStartFrame(footageComp, startInfo.frames)) { + diagLog("could not set the start frame on " + footageComp.name + " - it will read 00:00:00:00"); + } if (precompFolder) { footageComp.parentFolder = precompFolder; } @@ -1925,7 +2149,9 @@ var PANEL_VERSION = "2.5.0"; } shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, fps); - setCompStartFrame(shotComp, startInfo.frames); + if (!setCompStartFrame(shotComp, startInfo.frames)) { + diagLog("could not set the start frame on " + shotComp.name + " - it will read 00:00:00:00"); + } if (episodeProjectFolder) { shotComp.parentFolder = episodeProjectFolder; } else { diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index 61714db..5bc7c4d 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -201,16 +201,37 @@ Constructs a full shot composite in the open AE project from the episode and sho **Process:** 1. Calls the shot lookup API to retrieve metadata (description, notes, episode, scene, etc.). 2. If a comp with the shot code already exists, updates its overlay instead of rebuilding. -3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence at 24 fps. +3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence, conformed to the shot's frame rate. 4. Creates a `{shotCode}_FOOTAGE` precomp containing all imported sequences, each with an OCIO Color Space Transform effect added (disabled by default). 5. Creates the main `{shotCode}` comp and places the footage precomp inside it. -6. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top. -7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp. -8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders. +6. Anchors both comps on the plate's start timecode — see [Start timecode](#start-timecode). +7. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top. +8. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp. +9. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders. If any step fails, the status line names the stage and a copyable report opens — see [Diagnostics and logging](#diagnostics-and-logging). +#### Start timecode + +Neither comp starts at `00:00:00:00`. Both are anchored on the plate's own start +timecode, taken from the first source that has one: + +| Rank | Source | Notes | +|---|---|---| +| 1 | The first EXR's header | The `timeCode` attribute, or `timeCodeString` where the packed one is absent. Read straight off disk — no pixel decode, and no dependency on what AE chose to expose. | +| 2 | AE's footage metadata | What "New Comp from Selection" would have inherited. | +| 3 | API `timecodeStart` less `handleFrames` | The convention **Set Start Timecode** uses. Last resort: the API stores the *editorial* in-point, which is a handle-length ahead of the plate by design. | + +This is the same ranking the render worker's validation uses (RenderPipeline2 +§8.1a), so a comp is anchored on exactly the value its EXRs are later checked +against — a shot built from rank 3 and validated against rank 1 is how a +timecode desync gets into a delivery. + +The status line and the diagnostics log both name the rank that was used, e.g. +`start frame 864108 from EXR timecode 10:00:04:12`. A plate carrying no timecode +in any rank leaves the comp at `00:00:00:00` and says so. + --- ## Render Pipeline diff --git a/lib/render-pipeline/manifest.ts b/lib/render-pipeline/manifest.ts index 8e40073..2d4f298 100644 --- a/lib/render-pipeline/manifest.ts +++ b/lib/render-pipeline/manifest.ts @@ -74,9 +74,11 @@ export const renderManifestSchema = z timingPlate: plateRefSchema.optional(), plates: z.array(plateRefSchema).optional(), /** - * Verbatim from the panel's start-frame resolution: "footage metadata" - * (trustworthy) or "API timecode …" (fallback, suspect). Known at queue - * time, so a suspect export is flagged before it burns render hours. + * Verbatim from the panel's start-frame resolution, in the order it + * ranks them: "EXR timecode …" (the plate's own header, best), + * "footage metadata" (AE's read of it, trustworthy) or + * "API timecode …" (fallback, suspect). Known at queue time, so a + * suspect export is flagged before it burns render hours. */ startSource: z.string().optional(), startFrames: z.number().int().optional(),