From a3859aa61bccceef169bc79e68493b2036579464 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:45:33 +0200 Subject: [PATCH] feat(connector): build a QC review session for a whole project Build QC Review Session (QC section) makes a QC_{shot}_{version} comp for every shot in the project's QC queue. The export sits over its earlier versions and plates, and only the export and the main plate are switched on. Each scene also gets a continuous edit, cut to the edit with the handles trimmed, and a contact sheet. QC Pass/Fail now act on the shot comp in the viewer, the selected shot in a scene comp, or the shot under the playhead in a scene edit, and label the result green/red. The QC queue now also returns seqTimecodeStart, handleHead and handleTail. Compare Versions now lines versions up by EXR timecode, then by file frame number. It used to use Export.frameStart, which is timecode-anchored, not 1001-based. Also includes Keep AEP (panel 2.9.0): Queue Export saves a reduced .aep copy in each export's version folder. Panel 2.10.0. Not yet run in After Effects. Co-Authored-By: Claude Opus 5.5 --- EXT_API_REFERENCE.md | 1 + VFXReviewConnector.jsx | 960 +++++++++++++++++++++++++++++++++++++- VFXReviewConnector.md | 79 +++- lib/render-pipeline/qc.ts | 7 +- 4 files changed, 1028 insertions(+), 19 deletions(-) diff --git a/EXT_API_REFERENCE.md b/EXT_API_REFERENCE.md index 397b1ac..090a990 100644 --- a/EXT_API_REFERENCE.md +++ b/EXT_API_REFERENCE.md @@ -542,4 +542,5 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`: | Retry Export | `POST /api/ext/exports/{id}/retry` (E14) | | Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` | | Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) | +| Build QC Review Session | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5): uses `scene`, `episode`, `seqTimecodeStart`, `handleHead`, `handleTail` | | QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) | diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index b8b7094..25e3228 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -23,7 +23,10 @@ var CONFIG = { // output module template. House convention is 1001, and it is deliberately // independent of the comp's start frame — comps are anchored to the plate's // timecode, so their frame numbers run into the millions. - sequenceStart: 1001 + sequenceStart: 1001, + // Queue Export saves a reduced copy of the project next to each export's + // EXRs. Off skips it (and the project reopen it costs). + saveExportAep: true }; var SETTINGS_GROUP = "VFXReviewConnector"; @@ -32,7 +35,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.8.0"; +var PANEL_VERSION = "2.10.0"; (function VFXReviewConnector(thisObj) { var uiState = { @@ -45,6 +48,7 @@ var PANEL_VERSION = "2.8.0"; pipelineStatusText: null, urgentCheckbox: null, skipPreviewCheckbox: null, + saveAepCheckbox: null, handlesCheckbox: null, handleFramesField: null, submissionNoteField: null, @@ -4246,6 +4250,144 @@ var PANEL_VERSION = "2.8.0"; return postJSON(CONFIG.baseUrl + "/api/ext/exports", body); } + // ── Export AEP snapshots ────────────────────────────────────────────────── + // + // Each export keeps a copy of the project it was queued from, so the exact + // comp behind a render can be reopened later. The copy is reduced to the + // one comp and what it uses. Footage stays where it is: plates and EXRs are + // referenced by path, never copied, so the file is a few MB at most. + // + // It sits in the export's version folder as {render base}.aep. The worker + // only touches *.exr files there, so it is never deleted or counted. + + // The version folder on this machine. Usually the worker has not created it + // yet, so this falls back to the shot folder and then to the panel's own + // export layout. + function exportSnapshotFolder(exportInfo, shot) { + var serverDir = normalizeSlashes(exportInfo.outputDir || "").replace(/\/+$/, ""); + var resolved; + var parentDir; + + if (serverDir) { + resolved = resolveServerPath(serverDir); + if (resolved.path) { + return resolved.path; + } + parentDir = serverDir.substring(0, serverDir.lastIndexOf("/")); + if (parentDir) { + resolved = resolveServerPath(parentDir); + if (resolved.path) { + return resolved.path + "/" + exportInfo.versionString; + } + } + } + + if (!trimText(CONFIG.exportRoot)) { + return null; + } + return getShotExportDir({ + shotCode: shot.shotCode, + episode: shot.episode, + shotVersion: exportInfo.versionString + }); + } + + function exportSnapshotFile(exportInfo, shot) { + var folder = exportSnapshotFolder(exportInfo, shot); + var base = String(exportInfo.outputPattern || "").replace(/\.\[#+\]\.[A-Za-z0-9]+$/, ""); + + if (!folder || !ensureFolder(folder)) { + return null; + } + if (!base || base === exportInfo.outputPattern) { + base = shot.shotCode + "_" + exportInfo.versionString; + } + return new File(folder + "/" + base + ".aep"); + } + + function sameFile(a, b) { + return !!(a && b && normalizeSlashes(a.fsName).toLowerCase() === normalizeSlashes(b.fsName).toLowerCase()); + } + + // Saves a reduced copy of the project for each queued export, then reopens + // the original. The project must already be saved: the original file is + // never written here. AE can only reduce the open project, so each copy is + // made by Save As, Reduce Project and Save, which leaves the copy open. + // That is why the original is reopened at the end, which also clears undo. + // + // snapshots: [{ compId, compName, shot, exportInfo }]. Returns + // { saved: [paths], failed: ["v004: reason"] }. + function saveExportSnapshots(snapshots) { + var original = app.project.file; + var result = { saved: [], failed: [] }; + var i; + var job; + var target; + var comp; + + function backToOriginal() { + if (sameFile(app.project.file, original)) { + return; + } + app.project.close(CloseOptions.DO_NOT_SAVE_CHANGES); + app.open(original); + } + + if (!snapshots.length || !original) { + return result; + } + + try { + for (i = 0; i < snapshots.length; i += 1) { + job = snapshots[i]; + target = exportSnapshotFile(job.exportInfo, job.shot); + if (!target) { + result.failed.push(job.exportInfo.versionString + ": no folder for the AEP (check Export Root)"); + continue; + } + + try { + backToOriginal(); + updateStatus(snapshots.length, "Saving AEP for " + job.shot.shotCode + " " + + job.exportInfo.versionString + "..."); + app.project.save(target); + if (!sameFile(app.project.file, target)) { + throw new Error("Save As did not take"); + } + + comp = null; + try { + comp = app.project.itemByID(job.compId); + } catch (idError) { + comp = null; + } + if (!(comp instanceof CompItem) || comp.name !== job.compName) { + comp = findComp(job.compName); + } + if (!comp) { + throw new Error("comp " + job.compName + " not found in the copy"); + } + + app.project.reduceProject([comp]); + app.project.save(); + result.saved.push(normalizeSlashes(target.fsName)); + diagLog("export AEP saved: " + target.fsName); + } catch (snapshotError) { + result.failed.push(job.exportInfo.versionString + ": " + describeError(snapshotError)); + diagLog("export AEP failed for " + job.compName + ": " + describeError(snapshotError)); + } + } + } finally { + backToOriginal(); + } + + comp = findComp(snapshots[0].compName); + if (comp) { + comp.openInViewer(); + } + return result; + } + function queueExport() { var comps = getTargetComps(); var result = makeBatchResult(comps.length); @@ -4259,6 +4401,9 @@ var PANEL_VERSION = "2.8.0"; var errorText; var lastQueued = null; var lastShotCode = null; + var snapshots = []; + var snapshotResult = null; + var snapshotText = ""; if (comps.length < 1) { updateStatus(0, "Select or open a comp first."); @@ -4331,6 +4476,12 @@ var PANEL_VERSION = "2.8.0"; result.queued += 1; lastQueued = response.data.export; lastShotCode = response.data.export.shotCode || lookupCode; + snapshots.push({ + compId: comp.id, + compName: comp.name, + shot: data.shot, + exportInfo: response.data.export + }); } else { errorText = (response.data && response.data.error) ? response.data.error : ("HTTP " + response.status); @@ -4338,12 +4489,27 @@ var PANEL_VERSION = "2.8.0"; } } + // After every submit, because each copy reopens the project and the + // comps above stop being valid. + if (snapshots.length && CONFIG.saveExportAep !== false) { + try { + snapshotResult = saveExportSnapshots(snapshots); + } catch (snapshotError) { + snapshotResult = { saved: [], failed: [describeError(snapshotError)] }; + } + if (snapshotResult.failed.length) { + snapshotText = " AEP not saved: " + snapshotResult.failed.join("; ") + "."; + } else if (snapshotResult.saved.length) { + snapshotText = " AEP saved."; + } + } + if (lastQueued) { updateStatus(comps.length, "Queued " + lastQueued.versionString + (urgent ? " (urgent)" : "") + ((uiState.skipPreviewCheckbox && uiState.skipPreviewCheckbox.value) ? " (no preview)" : "") + " on render farm. " + - result.queued + " queued, " + result.skipped + " skipped."); + result.queued + " queued, " + result.skipped + " skipped." + snapshotText); setPipelineStatus(formatExportStatus(lastShotCode, lastQueued)); startExportPolling(lastShotCode); } else { @@ -4518,8 +4684,9 @@ var PANEL_VERSION = "2.8.0"; } // The export's own outputPattern prefix, so a version folder that also holds - // the preview MOV/MP4 still resolves to the EXR sequence. - function importQCSequence(entry) { + // the preview MOV/MP4 still resolves to the EXR sequence. quiet builds the + // comp without opening it, for the QC session's batch. + function importQCSequence(entry, quiet) { // The server addresses this folder from its own render root, which this // machine may not resolve — resolveServerPath tries the mapping, the // literal path, then Export Root (§ Server paths → this machine). @@ -4534,8 +4701,13 @@ var PANEL_VERSION = "2.8.0"; var existing = findComp(compName); if (existing) { - existing.openInViewer(); - setQCStatus(compName + " already open"); + if (!parseQCTag(existing.comment)) { + existing.comment = qcTag(entry); + } + if (!quiet) { + existing.openInViewer(); + setQCStatus(compName + " already open"); + } return true; } @@ -4584,9 +4756,13 @@ var PANEL_VERSION = "2.8.0"; comp = app.project.items.addComp(compName, footage.width, footage.height, 1, footage.duration, entry.fps || 24); setCompStartFrame(comp, entry.frameStart || 0); + // QC Pass/Fail read the export from here when this comp is open. + comp.comment = qcTag(entry); // Named by version so Compare Versions can stack earlier ones under it. comp.layers.add(footage).name = entry.versionString; - comp.openInViewer(); + if (!quiet) { + comp.openInViewer(); + } } catch (compError) { setQCStatus("Could not build QC comp: " + describeError(compError)); return false; @@ -4848,11 +5024,7 @@ var PANEL_VERSION = "2.8.0"; layer = comp.layers.add(footage); layer.name = label; layer.moveToEnd(); - // Comp time 0 is entry.frameStart; line each version up by its - // own first frame number, so a render with handles still matches. - if (chosen[i].first !== null && entry.frameStart !== null && entry.frameStart !== undefined) { - layer.startTime = (chosen[i].first - entry.frameStart) / fps; - } + alignQCLayer(layer, comp, entry.versionString, fps); added.push(label); diagLog("QC compare: " + label + " — " + chosen[i].files.length + " frames from " + chosen[i].folder.fsName + " (first frame " + chosen[i].first + ")"); @@ -4867,28 +5039,753 @@ var PANEL_VERSION = "2.8.0"; } } + // ── QC: review session ──────────────────────────────────────────────────── + // + // One button lays out a whole project's QC pass. Every shot waiting in the + // queue gets its QC_{shot}_{version} comp: the export on top, its earlier + // versions under it, then the shot's plates. Only the export and the main + // plate are switched on. Each scene also gets a continuous edit of those + // comps, cut to the edit with the handles trimmed, and a contact sheet. + // + // Each shot comp carries its export id in its comment. QC Pass/Fail act on + // the shot comp in the viewer, the shot layer selected in a scene comp, or + // the shot under the playhead in a scene edit, without finding it in the list. + + var QC_TAG = "VFXR-QC"; + var QC_EDIT_TAG = "VFXR-QC-EDIT"; + var QC_CONTACT_TAG = "VFXR-QC-CONTACT"; + // Indexes into AE's label set: Green and Red in the default colours. + var QC_LABEL_PASS = 9; + var QC_LABEL_FAIL = 1; + + function qcTag(entry) { + return QC_TAG + " exportId=" + entry.exportId + " shot=" + entry.shotCode + + " version=" + entry.versionString; + } + + function parseQCTag(text) { + var match = /VFXR-QC exportId=(\S+) shot=(\S+) version=(\S+)/.exec(String(text || "")); + + return match ? { exportId: match[1], shotCode: match[2], versionString: match[3] } : null; + } + + function qcFirstFrameNumber(footage) { + var file = getFootageFirstFile(footage); + + return file ? parseFrameNumberFromPath(file.fsName) : null; + } + + // Frames from reference's first frame to footage's first frame. EXR + // timecode is tried first, because a shipped export's timecode was checked + // against its plate. File frame numbers come next, and 0 is the last resort. + // Either answer is dropped when it is over limit. A shot's elements can + // carry unrelated camera timecodes (BG01 vs FG01). Plates may also be + // numbered from their timecode while renders start at 1001. + function qcFrameOffset(footage, reference, fps, limit) { + var a = readExrTimecode(getFootageFirstFile(footage)); + var b = readExrTimecode(getFootageFirstFile(reference)); + var offset; + + if (a && b) { + offset = timecodeToFrames(a, fps) - timecodeToFrames(b, fps); + if (Math.abs(offset) <= limit) { + return offset; + } + } + a = qcFirstFrameNumber(footage); + b = qcFirstFrameNumber(reference); + if (a !== null && b !== null && Math.abs(a - b) <= limit) { + return a - b; + } + return 0; + } + + // Lines a layer up in time with the export layer of a QC comp. + function alignQCLayer(layer, comp, exportLayerName, fps) { + var exportLayer = findLayerByName(comp, exportLayerName); + + if (!exportLayer || exportLayer.index === layer.index) { + return; + } + layer.startTime = exportLayer.startTime + + qcFrameOffset(layer.source, exportLayer.source, fps, Math.round(comp.duration * fps)) / fps; + } + + // Path -> FootageItem for everything already in the project, so a plate + // the artist's project already holds is reused rather than imported again. + function buildQCFootageIndex() { + var index = {}; + var i; + var item; + var file; + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (!(item instanceof FootageItem)) { + continue; + } + file = getFootageFirstFile(item); + if (file) { + index[normalizeSlashes(file.fsName).toLowerCase()] = item; + } + } + return index; + } + + // { footage, imported } for the sequence starting at firstFile. + function importQCFootage(firstFile, fps, index) { + var key = normalizeSlashes(firstFile.fsName).toLowerCase(); + var importOptions; + var footage; + + if (index[key]) { + return { footage: index[key], imported: false }; + } + importOptions = new ImportOptions(firstFile); + importOptions.sequence = true; + footage = app.project.importFile(importOptions); + footage.mainSource.conformFrameRate = fps; + index[key] = footage; + return { footage: footage, imported: true }; + } + + // The scene comes from the shot record, which does not always match the + // scene in the shot code (UNG_109_022_010 is in scene 013). The code is + // used only when the record has no scene. + function qcSceneOf(entry) { + var scene = trimText(entry.scene); + var match; + + if (scene) { + return scene; + } + match = /_(\d{3})_\d{3}$/.exec(String(entry.shotCode)); + return match ? match[1] : "000"; + } + + function qcEpisodeCode(entry) { + return isEpisodicProject() + ? deriveEpisodeCode(trimText(entry.episode) || deriveEpisodeFromShotCode(entry.shotCode)) + : ""; + } + + function qcGroupName(entry) { + var prefix = isEpisodicProject() ? qcEpisodeCode(entry) : getShowId(); + + return (prefix ? prefix + "_" : "") + "SC" + qcSceneOf(entry); + } + + // One entry per shot, the highest version, when a shot has several + // versions queued. Older queued versions are still found on disk. + function latestQCEntries(entries) { + var byShot = {}; + var list = []; + var i; + var entry; + var best; + + for (i = 0; i < entries.length; i += 1) { + entry = entries[i]; + best = byShot[entry.shotCode]; + if (!best) { + byShot[entry.shotCode] = entry; + list.push(entry.shotCode); + } else if ((qcVersionNumber(entry.versionString) || 0) > (qcVersionNumber(best.versionString) || 0)) { + byShot[entry.shotCode] = entry; + } + } + for (i = 0; i < list.length; i += 1) { + list[i] = byShot[list[i]]; + } + return list; + } + + // Edit order: the sequence timecode when every shot in the scene has one, + // otherwise shot code order. + function sortQCGroup(entries) { + var timed = true; + var i; + + for (i = 0; i < entries.length; i += 1) { + if (!trimText(entries[i].seqTimecodeStart)) { + timed = false; + } + } + entries.sort(function (a, b) { + var diff = 0; + + if (timed) { + diff = parseTimecodeToFrames(a.seqTimecodeStart, a.fps || 24) - + parseTimecodeToFrames(b.seqTimecodeStart, b.fps || 24); + } + if (diff) { + return diff; + } + return a.shotCode < b.shotCode ? -1 : (a.shotCode > b.shotCode ? 1 : 0); + }); + } + + function groupQCEntries(entries) { + var byName = {}; + var groups = []; + var i; + var name; + + for (i = 0; i < entries.length; i += 1) { + name = qcGroupName(entries[i]); + if (!byName[name]) { + byName[name] = { name: name, entries: [] }; + groups.push(byName[name]); + } + byName[name].entries.push(entries[i]); + } + groups.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); }); + for (i = 0; i < groups.length; i += 1) { + sortQCGroup(groups[i].entries); + } + return groups; + } + + function chooseQCSessionGroups(groups) { + var dialog = new Window("dialog", "Build QC Session: " + CURRENT_PROJECT.code); + var list; + var earlier; + var buttons; + var chosen = []; + var i; + + dialog.orientation = "column"; + dialog.alignChildren = ["fill", "top"]; + dialog.add("statictext", undefined, + "A QC comp per shot, plus an edit and a contact sheet per scene. Ctrl-click to leave a scene out.", + { multiline: true }).preferredSize = [380, 34]; + list = dialog.add("listbox", undefined, [], { multiselect: true }); + list.preferredSize = [380, Math.min(320, 24 + groups.length * 20)]; + for (i = 0; i < groups.length; i += 1) { + list.add("item", groups[i].name + " " + groups[i].entries.length + " shot" + + (groups[i].entries.length > 1 ? "s" : "")).selected = true; + } + earlier = dialog.add("checkbox", undefined, "Also import earlier versions (switched off)"); + earlier.value = true; + buttons = dialog.add("group"); + buttons.alignment = ["right", "top"]; + buttons.add("button", undefined, "Build", { name: "ok" }); + buttons.add("button", undefined, "Cancel", { name: "cancel" }); + + if (dialog.show() !== 1) { + return null; + } + for (i = 0; list.selection && i < list.selection.length; i += 1) { + chosen.push(groups[list.selection[i].index]); + } + chosen.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); }); + return { groups: chosen, includeEarlier: earlier.value }; + } + + // The part of the export that is in the edit, in shot comp frames: the + // export without the handles it was rendered with. Exports from before + // handles were recorded play in full. + function qcCutRange(entry, exportLayer, fps) { + var total = Math.round(exportLayer.source.duration * fps); + var head = Math.max(0, parseInt(entry.handleHead, 10) || 0); + var tail = Math.max(0, parseInt(entry.handleTail, 10) || 0); + + if (head + tail >= total) { + head = 0; + tail = 0; + } + return { + inFrame: Math.round(exportLayer.startTime * fps) + head, + frames: total - head - tail, + trimmed: head + tail > 0 + }; + } + + function buildQCSessionShot(entry, includeEarlier, bins, index) { + var fps = entry.fps || 24; + var compName = "QC_" + entry.shotCode + "_" + entry.versionString; + var result = { entry: entry, fps: fps, comp: null, exportLayer: null, notes: [], earlier: 0, plates: 0 }; + var comp; + var exportLayer; + var resolved; + var found = []; + var plateFolders; + var plateLayers = []; + var mainPlateName = null; + var mainPlateDuration = -1; + var files; + var name; + var label; + var got; + var layer; + var i; + + if (!importQCSequence(entry, true)) { + result.notes.push(DIAG.lastFailure ? DIAG.lastFailure.detail : "the export could not be imported"); + return result; + } + comp = findComp(compName); + exportLayer = comp ? findLayerByName(comp, entry.versionString) : null; + if (!exportLayer) { + result.notes.push("no " + entry.versionString + " layer in " + compName); + return result; + } + comp.parentFolder = bins.shots; + exportLayer.source.parentFolder = bins.footage; + + // Earlier versions, newest first, under the export. + if (includeEarlier) { + resolved = resolveServerPath(entry.outputDir); + if (resolved.path && qcVersionNumber(entry.versionString) !== null) { + found = findPreviousQCVersions(entry, resolved.path); + } + for (i = 0; i < found.length; i += 1) { + label = qcVersionLabel(found[i]); + if (findLayerByName(comp, label)) { + continue; + } + found[i].files.sort(); + got = importQCFootage(found[i].files[0], fps, index); + if (got.imported) { + got.footage.parentFolder = bins.footage; + } + layer = comp.layers.add(got.footage); + layer.name = label; + layer.moveToEnd(); + alignQCLayer(layer, comp, entry.versionString, fps); + result.earlier += 1; + } + } + + // Plates, found as Build Shot finds them. The longest one is the main + // plate, as in Build Shot. + plateFolders = findSequenceFolders(entry.shotCode, qcEpisodeCode(entry)); + for (i = 0; i < plateFolders.length; i += 1) { + files = plateFolders[i].getFiles("*.exr"); + if (!files || files.length < 1) { + continue; + } + files.sort(); + name = decodeURI(plateFolders[i].name); + layer = findLayerByName(comp, name); + if (!layer) { + got = importQCFootage(files[0], fps, index); + if (got.imported) { + got.footage.name = name; + got.footage.parentFolder = bins.footage; + } + layer = comp.layers.add(got.footage); + layer.name = name; + alignQCLayer(layer, comp, entry.versionString, fps); + } + plateLayers.push(name); + if (layer.source.duration > mainPlateDuration) { + mainPlateDuration = layer.source.duration; + mainPlateName = name; + } + } + if (!plateLayers.length) { + result.notes.push("no plates found for " + entry.shotCode + " under " + + getFootageGroupFolder(qcEpisodeCode(entry)).fsName); + } + // Plates go to the bottom, also when an earlier run already added them. + for (i = 0; i < plateLayers.length; i += 1) { + findLayerByName(comp, plateLayers[i]).moveToEnd(); + } + + for (i = 1; i <= comp.numLayers; i += 1) { + layer = comp.layer(i); + layer.enabled = layer.name === entry.versionString || layer.name === mainPlateName; + } + + result.comp = comp; + result.exportLayer = findLayerByName(comp, entry.versionString); + result.plates = plateLayers.length; + result.cut = qcCutRange(entry, result.exportLayer, fps); + return result; + } + + // Scene comps are generated. A rebuild empties and refills them. + function resetQCComp(name, width, height, duration, fps, folder) { + var comp = findComp(name); + + if (comp) { + while (comp.numLayers > 0) { + comp.layer(1).locked = false; + comp.layer(1).remove(); + } + comp.width = width; + comp.height = height; + comp.frameRate = fps; + comp.duration = duration; + } else { + comp = app.project.items.addComp(name, width, height, 1, duration, fps); + } + comp.parentFolder = folder; + return comp; + } + + function fitQCLayer(layer, width, height, centre) { + var scale = Math.min(width / layer.source.width, height / layer.source.height) * 100; + var transform = layer.property("ADBE Transform Group"); + + transform.property("ADBE Scale").setValue([scale, scale]); + transform.property("ADBE Position").setValue(centre); + } + + // The scene's shots butted together in edit order, each cut to its edit + // range. A layer marker names each shot. + function buildQCSceneEdit(group, shots, folder) { + var first = shots[0].comp; + var total = 0; + var cursor = 0; + var comp; + var layer; + var shot; + var i; + + for (i = 0; i < shots.length; i += 1) { + total += shots[i].cut.frames / shots[i].fps; + } + comp = resetQCComp("QC_EDIT_" + group.name, first.width, first.height, + Math.max(total, 1 / first.frameRate), first.frameRate, folder); + comp.comment = QC_EDIT_TAG + " " + group.name; + + for (i = 0; i < shots.length; i += 1) { + shot = shots[i]; + layer = comp.layers.add(shot.comp); + layer.moveToEnd(); + fitQCLayer(layer, comp.width, comp.height, [comp.width / 2, comp.height / 2]); + layer.startTime = cursor - shot.cut.inFrame / shot.fps; + layer.inPoint = cursor; + layer.outPoint = cursor + shot.cut.frames / shot.fps; + layer.property("ADBE Marker").setValueAtTime(cursor, + new MarkerValue(shot.entry.shotCode + " " + shot.entry.versionString)); + cursor += shot.cut.frames / shot.fps; + } + return comp; + } + + // Every shot of the scene side by side, each starting at its cut in, with a + // label under it. The label carries the shot's tag too, so selecting either + // the picture or its label picks the shot for QC Pass. + function buildQCContactSheet(group, shots, folder) { + var first = shots[0].comp; + var cols = Math.ceil(Math.sqrt(shots.length)); + var rows = Math.ceil(shots.length / cols); + var cellW = Math.floor(first.width / cols); + var cellH = Math.round(cellW * first.height / first.width); + var labelH = Math.max(36, Math.round(cellH * 0.14)); + var longest = 1 / first.frameRate; + var comp; + var shot; + var layer; + var text; + var textProp; + var textDoc; + var x; + var top; + var i; + + for (i = 0; i < shots.length; i += 1) { + longest = Math.max(longest, shots[i].cut.frames / shots[i].fps); + } + comp = resetQCComp("QC_CONTACT_" + group.name, cols * cellW, rows * (cellH + labelH), + longest, first.frameRate, folder); + comp.comment = QC_CONTACT_TAG + " " + group.name; + + for (i = 0; i < shots.length; i += 1) { + shot = shots[i]; + x = (i % cols) * cellW + cellW / 2; + top = Math.floor(i / cols) * (cellH + labelH); + + layer = comp.layers.add(shot.comp); + fitQCLayer(layer, cellW, cellH, [x, top + cellH / 2]); + layer.startTime = -shot.cut.inFrame / shot.fps; + layer.inPoint = 0; + layer.outPoint = shot.cut.frames / shot.fps; + + text = comp.layers.addText(shot.entry.shotCode + " " + shot.entry.versionString); + textProp = text.property("ADBE Text Properties").property("ADBE Text Document"); + textDoc = textProp.value; + textDoc.fontSize = Math.round(labelH * 0.5); + textDoc.applyFill = true; + textDoc.fillColor = [1, 1, 1]; + textDoc.applyStroke = false; + textDoc.justification = ParagraphJustification.CENTER_JUSTIFY; + textProp.setValue(textDoc); + text.property("ADBE Transform Group").property("ADBE Position") + .setValue([x, top + cellH + labelH * 0.68]); + text.name = "label " + shot.entry.shotCode; + text.comment = qcTag(shot.entry); + } + return comp; + } + + // ScriptUI doesn't repaint a docked panel during a long loop unless asked. + function qcProgress(message) { + setQCStatus(message); + try { + uiState.qcStatusText.window.update(); + } catch (updateError) { + } + } + + function buildQCSession() { + var groups; + var choice; + var index; + var sessionBin; + var footageBin; + var groupBin; + var shotsBin; + var shots; + var info; + var firstEdit = null; + var problems = []; + var total = 0; + var done = 0; + var built = 0; + var untrimmed = 0; + var scenes = 0; + var entry; + var g; + var i; + + if (!app.project) { + setQCStatus("Open a project first"); + return; + } + if (!CURRENT_PROJECT) { + setQCStatus("Pick a project first"); + return; + } + + // A failed load leaves the old entries behind, so start from none. + qcState.entries = []; + loadQCQueue(); + if (!qcState.entries.length) { + return; + } + groups = groupQCEntries(latestQCEntries(qcState.entries)); + choice = chooseQCSessionGroups(groups); + if (!choice || !choice.groups.length) { + setQCStatus("QC session cancelled"); + return; + } + for (g = 0; g < choice.groups.length; g += 1) { + total += choice.groups[g].entries.length; + } + + app.beginUndoGroup("VFXReview Connector - Build QC Session"); + try { + index = buildQCFootageIndex(); + sessionBin = findOrCreateFolder("QC_SESSION_" + CURRENT_PROJECT.code, app.project.rootFolder); + footageBin = findOrCreateFolder("_footage", sessionBin); + + for (g = 0; g < choice.groups.length; g += 1) { + groupBin = findOrCreateFolder(choice.groups[g].name, sessionBin); + shotsBin = findOrCreateFolder("shots", groupBin); + shots = []; + + for (i = 0; i < choice.groups[g].entries.length; i += 1) { + entry = choice.groups[g].entries[i]; + done += 1; + qcProgress("Building " + entry.shotCode + " " + entry.versionString + + " (" + done + "/" + total + ")..."); + try { + info = buildQCSessionShot(entry, choice.includeEarlier, + { shots: shotsBin, footage: footageBin }, index); + } catch (shotError) { + info = { comp: null, notes: [describeError(shotError)] }; + } + if (info.notes.length) { + problems.push(entry.shotCode + " " + entry.versionString + ": " + info.notes.join("; ")); + } + if (info.comp && info.exportLayer) { + shots.push(info); + built += 1; + if (!info.cut.trimmed) { + untrimmed += 1; + } + diagLog("QC session: " + info.comp.name + " — " + info.earlier + " earlier version(s), " + + info.plates + " plate(s), cut " + info.cut.frames + "f from comp frame " + info.cut.inFrame); + } + } + + if (shots.length) { + qcProgress("Cutting " + choice.groups[g].name + "..."); + try { + info = buildQCSceneEdit(choice.groups[g], shots, groupBin); + firstEdit = firstEdit || info; + buildQCContactSheet(choice.groups[g], shots, groupBin); + scenes += 1; + } catch (sceneError) { + problems.push(choice.groups[g].name + " edit/contact sheet: " + describeError(sceneError)); + } + } + } + } finally { + app.endUndoGroup(); + } + + if (firstEdit) { + firstEdit.openInViewer(); + } + setQCStatus("QC session: " + built + " of " + total + " shot(s), " + scenes + " scene(s)." + + (untrimmed ? " " + untrimmed + " export(s) have no handle record and play in full in the edit." : "") + + (problems.length ? " " + problems.length + " problem(s): see the report." : "")); + if (problems.length) { + for (i = 0; i < problems.length; i += 1) { + diagLog("QC session problem: " + problems[i]); + } + showDiagDialog("QC session built with " + problems.length + " problem(s).", diagReport()); + } + } + + // ── QC: which export a decision is for ──────────────────────────────────── + + // The queue entry for a tag. It is also selected in the list, so the status + // line describes it. If the list has been reloaded since, the tag itself + // carries all a decision needs. + function qcEntryForTag(tag) { + var i; + + if (!tag) { + return null; + } + for (i = 0; i < qcState.entries.length; i += 1) { + if (qcState.entries[i].exportId === tag.exportId) { + if (uiState.qcList) { + uiState.qcList.selection = i; + } + return qcState.entries[i]; + } + } + return tag; + } + + function qcTagOfLayer(layer) { + var tag = null; + + try { + tag = parseQCTag(layer.comment); + if (!tag && layer.source instanceof CompItem) { + tag = parseQCTag(layer.source.comment); + } + } catch (layerError) { + } + return tag; + } + + // { fromComp, entry, problem }. fromComp is false unless the active comp is + // a QC comp, and then the list selection decides. In a scene comp, entry is + // the selected shot layer, or the shot under the playhead in an edit. + function getQCEntryFromActiveComp() { + var comp = app.project ? app.project.activeItem : null; + var comment; + var layers; + var layer; + var i; + + if (!(comp instanceof CompItem)) { + return { fromComp: false }; + } + comment = String(comp.comment || ""); + if (parseQCTag(comment)) { + return { fromComp: true, entry: qcEntryForTag(parseQCTag(comment)) }; + } + if (comment.indexOf(QC_EDIT_TAG) !== 0 && comment.indexOf(QC_CONTACT_TAG) !== 0) { + return { fromComp: false }; + } + + layers = comp.selectedLayers; + if (layers.length === 1) { + return { fromComp: true, entry: qcEntryForTag(qcTagOfLayer(layers[0])), + problem: "The selected layer in " + comp.name + " isn't a shot" }; + } + if (layers.length > 1) { + return { fromComp: true, entry: null, problem: "Select one shot in " + comp.name }; + } + if (comment.indexOf(QC_EDIT_TAG) === 0) { + for (i = 1; i <= comp.numLayers; i += 1) { + layer = comp.layer(i); + if (layer.enabled && comp.time >= layer.inPoint && comp.time < layer.outPoint) { + return { fromComp: true, entry: qcEntryForTag(qcTagOfLayer(layer)), + problem: "No shot under the playhead in " + comp.name }; + } + } + return { fromComp: true, entry: null, problem: "No shot under the playhead in " + comp.name }; + } + return { fromComp: true, entry: null, problem: "Select a shot in " + comp.name }; + } + + // Colours the shot comp, and its layers in the scene comps, by the result. + function markQCResultInProject(exportId, result) { + var label = result === "PASS" ? QC_LABEL_PASS : QC_LABEL_FAIL; + var item; + var comment; + var tag; + var i; + var j; + + app.beginUndoGroup("VFXReview Connector - Mark QC " + result); + try { + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (!(item instanceof CompItem)) { + continue; + } + comment = String(item.comment || ""); + tag = parseQCTag(comment); + if (tag && tag.exportId === exportId) { + item.label = label; + } else if (comment.indexOf(QC_EDIT_TAG) === 0 || comment.indexOf(QC_CONTACT_TAG) === 0) { + for (j = 1; j <= item.numLayers; j += 1) { + tag = qcTagOfLayer(item.layer(j)); + if (tag && tag.exportId === exportId) { + item.layer(j).label = label; + } + } + } + } + } catch (markError) { + diagLog("QC: could not colour comps for " + exportId + ": " + describeError(markError)); + } finally { + app.endUndoGroup(); + } + } + function submitQC(result) { - var entry = getSelectedQCEntry(); + // A QC comp in the viewer decides over the list selection. + var picked = getQCEntryFromActiveComp(); + var entry = picked.fromComp ? picked.entry : getSelectedQCEntry(); var notes = uiState.qcNotesField ? trimText(uiState.qcNotesField.text) : ""; + var source = picked.fromComp ? "From the comp in the viewer:\n" : ""; var body; var response; if (!entry) { - setQCStatus("Select a queue entry first"); + setQCStatus(picked.fromComp ? picked.problem : "Select a queue entry first, or open a shot's QC comp"); return; } // A bare FAIL leaves the artist guessing at what to fix. if (result === "FAIL" && !notes) { - if (!confirm("Fail " + entry.shotCode + " " + entry.versionString + " with no notes?\n\n" + + if (!confirm(source + "Fail " + entry.shotCode + " " + entry.versionString + " with no notes?\n\n" + "The artist only sees the reason you write here.")) { return; } } - if (result === "PASS" && !confirm("QC PASS " + entry.shotCode + " " + entry.versionString + "?\n\n" + + if (result === "PASS" && !confirm(source + "QC PASS " + entry.shotCode + " " + entry.versionString + "?\n\n" + "This marks the version ready for delivery. Nothing is sent to the client.")) { return; } + // Picked from the viewer rather than the list, so a fail is confirmed too. + if (result === "FAIL" && notes && picked.fromComp && + !confirm(source + "QC FAIL " + entry.shotCode + " " + entry.versionString + "?\n\n" + notes)) { + return; + } body = { result: result }; if (notes) { @@ -4905,6 +5802,7 @@ var PANEL_VERSION = "2.8.0"; if (uiState.qcNotesField) { uiState.qcNotesField.text = ""; } + markQCResultInProject(entry.exportId, result); setQCStatus(entry.shotCode + " " + entry.versionString + " — QC " + result + (result === "FAIL" ? " (comp task set to CHANGES)" : " — ready for delivery")); loadQCQueue(); @@ -4983,6 +5881,8 @@ var PANEL_VERSION = "2.8.0"; var loadQCButton; var openQCButton; var compareQCButton; + var qcSessionRow; + var qcSessionButton; var qcPassButton; var qcFailButton; @@ -5079,6 +5979,19 @@ var PANEL_VERSION = "2.8.0"; "For deliverables framed differently from the review — unscaled or\n" + "un-punched-in EXRs — where a preview rebuilt from these frames\n" + "would be the wrong picture."; + uiState.saveAepCheckbox = pipelineRow1.add("checkbox", undefined, "Keep AEP"); + uiState.saveAepCheckbox.value = CONFIG.saveExportAep !== false; + uiState.saveAepCheckbox.helpTip = + "Save a copy of the project with each export, in its version\n" + + "folder as {shot}_cmp_TT_{version}.aep, so the exact comp behind\n" + + "a render can be reopened later.\n\n" + + "The copy is reduced to the exported comp and what it uses.\n" + + "Footage is linked, not copied: no EXRs are stored.\n\n" + + "Your project is reopened afterwards, which clears undo."; + uiState.saveAepCheckbox.onClick = function () { + CONFIG.saveExportAep = uiState.saveAepCheckbox.value; + saveConfig(); + }; var handlesGroup = pipelinePanel.add("group"); handlesGroup.orientation = "row"; @@ -5151,6 +6064,18 @@ var PANEL_VERSION = "2.8.0"; compareQCButton = qcRow1.add("button", undefined, "Compare Versions"); compareQCButton.helpTip = "Import earlier versions of this shot under the selected export in its QC comp, lined up by frame."; + qcSessionRow = qcPanel.add("group"); + qcSessionRow.orientation = "row"; + qcSessionRow.alignChildren = ["fill", "center"]; + qcSessionRow.spacing = 6; + qcSessionButton = qcSessionRow.add("button", undefined, "Build QC Review Session"); + qcSessionButton.helpTip = + "Every shot waiting for QC in this project gets a QC comp: the export over\n" + + "its earlier versions and plates, with only the export and main plate on.\n" + + "Each scene also gets a continuous edit (handles trimmed) and a contact sheet.\n\n" + + "QC Pass / Fail then act on the open shot comp, the selected shot in a\n" + + "scene comp, or the shot under the playhead in a scene edit."; + var qcNotesGroup = qcPanel.add("group"); qcNotesGroup.orientation = "row"; qcNotesGroup.alignChildren = ["fill", "top"]; @@ -5305,6 +6230,7 @@ var PANEL_VERSION = "2.8.0"; openQCButton.onClick = guarded("Open QC Render", openSelectedQCEntry); uiState.qcList.onDoubleClick = guarded("Open QC Render", openSelectedQCEntry); compareQCButton.onClick = guarded("Compare QC Versions", compareQCVersions); + qcSessionButton.onClick = guarded("Build QC Session", buildQCSession); uiState.qcList.onChange = function () { var entry = getSelectedQCEntry(); if (entry) { diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index ed86257..f5e477a 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -245,6 +245,7 @@ shot record, and submits it to the farm. |---|---| | **Urgent** | Priority 20 — claimable outside the render window, ahead of normal jobs | | **No preview** | Renders the EXRs but builds no preview, inheriting the previous export's MOV, review MP4 and thumbnail | +| **Keep AEP** | Saves a reduced copy of the project in the export's version folder (see below) | **No preview** exists for deliverables framed differently from the review — unscaled or un-punched-in EXRs. A preview rebuilt from those frames would be @@ -255,6 +256,37 @@ package still gets a MOV. Leave it unticked for an ordinary submission: the preview is what QC and the client review see. +#### Export AEP (Keep AEP) + +With **Keep AEP** ticked (the default, remembered per machine), every export +keeps a copy of the project it was queued from, so the exact comp behind a +render can be reopened later: + +``` +{version folder}/{shotCode}_cmp_TT_{version}.aep e.g. .../UNG_102_019_010/v005/UNG_102_019_010_cmp_TT_v005.aep +``` + +- The copy is **reduced** to the exported comp and everything it uses (nested + comps, footage, solids). Everything else in the project is dropped. +- Nothing is collected. Plates and EXRs stay where they are and the copy links + to them by path, so it stays a few MB. +- It is saved after the export is submitted, into the same version folder the + farm renders into. The worker only reads and clears `*.exr` files there, so + the `.aep` is never touched. Re-queuing the same version overwrites it, just + as the render overwrites the EXRs. +- AE can only reduce the open project, so the panel does Save As, Reduce Project, + Save, and then **reopens your project**. Your project was saved first and is + never changed, but the reopen clears undo. Untick **Keep AEP** to skip it. +- The copy is the project as it was when you queued. The farm still renders + your working AEP, so a save made before the job starts goes into the render + but not into the copy. +- Reduce Project keeps only comps that are used as layers. An expression that + points at a comp with `comp("Name")`, where that comp isn't nested anywhere, + will break in the copy. + +If the copy can't be saved, the export is still queued and the status line says +`AEP not saved: …` with the reason. + --- ## QC @@ -267,7 +299,8 @@ QC is per-export: a new export always needs new QC. |---|---| | **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. | | **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. | -| **Compare Versions** | Opens the QC comp and stacks earlier versions of the shot under the export, one layer each (`v003`, `v002`…), lined up by frame number. Solo a layer to see that version, or set the top layer to **Difference** to see exactly what changed. With more than one earlier version, a dialog lets you untick the ones you don't need. Versions already in the comp are skipped. | +| **Compare Versions** | Opens the QC comp and stacks earlier versions of the shot under the export, one layer each (`v003`, `v002`…), lined up by EXR timecode (or frame number). Solo a layer to see that version, or set the top layer to **Difference** to see exactly what changed. With more than one earlier version, a dialog lets you untick the ones you don't need. Versions already in the comp are skipped. | +| **Build QC Review Session** | Lays out the whole project's QC pass in one go. Every shot in the queue gets its `QC_{shot}_{version}` comp, and each scene gets an edit and a contact sheet. See *QC review session* below. | | **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. | | **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. | | **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. | @@ -279,6 +312,50 @@ as for Open Render), in both on-disk layouts. That means farm version folders export's are used. If earlier exports were written under a different root (see *More than one on-disk convention*), they won't be found. +### QC review session + +**Build QC Review Session** loads the queue for the selected project. A dialog +lists its scenes. Ctrl-click a scene to leave it out, or untick *Also import +earlier versions*. Everything is built in one undo step, under +`QC_SESSION_{project}` in the Project panel: + +| Item | What it is | +|---|---| +| `{scene}/shots/QC_{shot}_{version}` | One comp per shot, reusing the comp from Open Render if there is one. The export sits on top, earlier versions under it (`v003`, `v002`…), and the shot's plates at the bottom, found as Build Shot finds them. **Only the export and the main plate (the longest) are switched on.** | +| `{scene}/QC_EDIT_{scene}` | The scene's shot comps as one continuous edit. Each shot is cut to the edit, with the handles the render carries trimmed off, and has a layer marker with its name. | +| `{scene}/QC_CONTACT_{scene}` | Every shot of the scene in a grid, each starting at its cut in, with its name under it. | +| `_footage` | The EXR sequences it imported. Plates already in the project are reused, not imported again. | + +A scene is the shot record's scene, which is not always the scene in the shot +code. Episodic shows group by episode too (`UNG_109_SC013`), and standard ones +by show (`DH2_VFX_SC033`). Shots run in sequence-timecode order when every shot +in the scene has one, and in shot-code order otherwise. Only the newest queued +version of a shot gets a comp; older queued versions appear as earlier versions +in it. + +Plates and earlier versions are lined up with the export by EXR timecode. A +timecode more than a shot's length away is ignored, because a shot's elements +can carry unrelated camera timecodes. The file frame number is used next, then +the export's first frame. Exports from before handles were recorded have no +handle count, so they play in full in the edit, and the status line counts them. + +Running the button again adds anything new to the shot comps and rebuilds the +scene edits and contact sheets from scratch. Don't do your own work in those +two comps. + +**Approving from the session.** QC Pass and QC Fail act on the shot in front of +you, without finding it in the list: + +- the `QC_{shot}_{version}` comp in the viewer, or +- the shot layer (or its label) selected in a scene edit or contact sheet, or +- with nothing selected in a scene edit, the shot under the playhead. + +The confirmation names the shot, and a fail with notes is confirmed too when it +came from the viewer. If a scene comp is active and no shot is picked, nothing +is sent. Pick a shot, or switch to another comp to use the list selection. After +a decision, that shot's comp and its layers in the scene comps are labelled +**green** (pass) or **red** (fail). + Passing an export is what makes it eligible for a delivery package on the web app's **Pipeline → Deliveries** page. diff --git a/lib/render-pipeline/qc.ts b/lib/render-pipeline/qc.ts index 5c4ab56..b2f7fa7 100644 --- a/lib/render-pipeline/qc.ts +++ b/lib/render-pipeline/qc.ts @@ -147,7 +147,7 @@ export async function getQCQueue(filter: QCQueueFilter) { orderBy: { statusChangedAt: "asc" }, // oldest first — nothing should sit in QC take: limit, include: { - shot: { select: { id: true, shotCode: true, episode: true, scene: true, project: { select: { code: true, name: true } } } }, + shot: { select: { id: true, shotCode: true, episode: true, scene: true, seqTimecodeStart: true, project: { select: { code: true, name: true } } } }, validations: { where: { status: { in: ["FAIL", "WARN"] } }, select: { checkName: true, status: true, message: true } }, }, }); @@ -167,6 +167,11 @@ export async function getQCQueue(filter: QCQueueFilter) { frameStart: e.frameStart, frameEnd: e.frameEnd, fps: e.fps, + // The panel's QC session cuts each shot into a scene edit: in edit order, + // with the handles the render carries trimmed off. Null on old exports. + seqTimecodeStart: e.shot.seqTimecodeStart, + handleHead: e.handleHead, + handleTail: e.handleTail, deliveryMovPath: e.deliveryMovPath, thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null, previewUrl: e.previewMovKey ? `/api/files/${e.previewMovKey}` : null,