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(),