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,