feat(pipeline): skip the preview build and inherit the previous one
Deploy / deploy (push) Successful in 7m8s
Deploy / deploy (push) Successful in 7m8s
Some exports are deliberately framed differently from their review — unscaled or un-punched-in EXRs — and a preview rebuilt from those frames would be the wrong picture. The previous export's MOV is the correct one. skipPreview renders the EXRs and goes straight to READY_FOR_QC. Available at Queue Export (a "No preview" checkbox in the panel, E1 field) and as an option when overriding a validation failure. The inheritance is as important as the skip: deliveryMovPath, the review MP4, the thumbnail and the Version are carried over from the newest earlier export of that shot which has them. Without it the delivery builder — which takes its MOV from deliveryMovPath — would package EXRs with no MOV at all. Where there is nothing to inherit, the export still reaches QC and the timeline says it has no review media. QC is untouched by any of this: a skipped preview still needs a human pass before the export can be delivered. Panel 2.3.0. Adds migration 20260814000000_export_skip_preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1131,6 +1131,14 @@ Cost note: the AE preview pass roughly doubles machine time per export. This mat
|
||||
|
||||
Finalize (E13) then creates the `Version` row server-side (Section 6.4) in pipeline mode — visible internally, never client-shared, no task/shot status changes.
|
||||
|
||||
### 9.4 Skipping the preview (`Export.skipPreview`)
|
||||
|
||||
Not every export is framed like its review. A deliverable rendered unscaled or without the punch-in shares a shot and a version history with the review, but a preview rebuilt from *its* frames would show the wrong picture — the earlier export's MOV is the correct one.
|
||||
|
||||
`skipPreview` (set at Queue Export, or per-action when overriding a validation failure) renders the EXRs and goes straight to READY_FOR_QC, **inheriting** `deliveryMovPath`, `previewMovKey`, `thumbnailKey` and `versionId` from the newest earlier export of that shot which has them. Inheritance matters as much as the skip: the delivery builder takes its MOV from `deliveryMovPath`, so an export with none would package EXRs alone. QC is unaffected — the export still needs a human pass before it can be delivered.
|
||||
|
||||
With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media.
|
||||
|
||||
---
|
||||
|
||||
## 10. QC Workflow
|
||||
|
||||
+19
-2
@@ -32,7 +32,7 @@ var SETTINGS_KEY = "cfg";
|
||||
// Bump on every change deployed to a workstation. The panel reports this to
|
||||
// the server on load, so Pipeline → Machines shows which version each machine
|
||||
// is actually running and flags the ones left behind.
|
||||
var PANEL_VERSION = "2.2.1";
|
||||
var PANEL_VERSION = "2.3.0";
|
||||
|
||||
(function VFXReviewConnector(thisObj) {
|
||||
var uiState = {
|
||||
@@ -44,6 +44,7 @@ var PANEL_VERSION = "2.2.1";
|
||||
lastActionText: null,
|
||||
pipelineStatusText: null,
|
||||
urgentCheckbox: null,
|
||||
skipPreviewCheckbox: null,
|
||||
submissionNoteField: null,
|
||||
qcList: null,
|
||||
qcNotesField: null,
|
||||
@@ -3851,6 +3852,13 @@ var PANEL_VERSION = "2.2.1";
|
||||
force: force ? true : false
|
||||
};
|
||||
|
||||
// Renders the EXRs and inherits the previous export's review media
|
||||
// rather than rebuilding a preview from frames that are not the review
|
||||
// framing (unscaled / un-punched-in deliverables).
|
||||
if (uiState.skipPreviewCheckbox && uiState.skipPreviewCheckbox.value) {
|
||||
body.skipPreview = true;
|
||||
}
|
||||
|
||||
if (CONFIG.artistEmail) {
|
||||
body.submittedByEmail = CONFIG.artistEmail;
|
||||
}
|
||||
@@ -3942,7 +3950,9 @@ var PANEL_VERSION = "2.2.1";
|
||||
|
||||
if (lastQueued) {
|
||||
updateStatus(comps.length, "Queued " + lastQueued.versionString +
|
||||
(urgent ? " (urgent)" : "") + " on render farm. " +
|
||||
(urgent ? " (urgent)" : "") +
|
||||
((uiState.skipPreviewCheckbox && uiState.skipPreviewCheckbox.value) ? " (no preview)" : "") +
|
||||
" on render farm. " +
|
||||
result.queued + " queued, " + result.skipped + " skipped.");
|
||||
setPipelineStatus(formatExportStatus(lastShotCode, lastQueued));
|
||||
startExportPolling(lastShotCode);
|
||||
@@ -4410,6 +4420,13 @@ var PANEL_VERSION = "2.2.1";
|
||||
pipelineRow1.spacing = 6;
|
||||
queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
|
||||
uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent");
|
||||
uiState.skipPreviewCheckbox = pipelineRow1.add("checkbox", undefined, "No preview");
|
||||
uiState.skipPreviewCheckbox.helpTip =
|
||||
"Render the EXRs but build no preview, reusing the previous export's\n" +
|
||||
"MOV and review media.\n\n" +
|
||||
"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.";
|
||||
|
||||
var slateNoteGroup = pipelinePanel.add("group");
|
||||
slateNoteGroup.orientation = "row";
|
||||
|
||||
@@ -198,6 +198,29 @@ see [Diagnostics and logging](#diagnostics-and-logging).
|
||||
|
||||
---
|
||||
|
||||
## Render Pipeline
|
||||
|
||||
### Queue Export
|
||||
|
||||
Saves the project, builds the render manifest from the selected comp and the
|
||||
shot record, and submits it to the farm.
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| **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 |
|
||||
|
||||
**No preview** exists for deliverables framed differently from the review —
|
||||
unscaled or un-punched-in EXRs. A preview rebuilt from those frames would be
|
||||
the wrong picture, while the earlier export's MOV is already correct, so it
|
||||
carries over instead. The export still goes through QC, and the delivery
|
||||
package still gets a MOV.
|
||||
|
||||
Leave it unticked for an ordinary submission: the preview is what QC and the
|
||||
client review see.
|
||||
|
||||
---
|
||||
|
||||
## QC
|
||||
|
||||
Technical QC on the **rendered EXRs**, not the H.264 preview — the point is
|
||||
|
||||
@@ -108,6 +108,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [overrideOpen, setOverrideOpen] = useState(false);
|
||||
const [overrideReason, setOverrideReason] = useState("");
|
||||
const [overrideSkipPreview, setOverrideSkipPreview] = useState(false);
|
||||
const [overriding, setOverriding] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -135,7 +136,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/override-validation`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: overrideReason }),
|
||||
body: JSON.stringify({ reason: overrideReason, skipPreview: overrideSkipPreview }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -240,6 +241,21 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
placeholder="e.g. plate unreachable from the render node; timecode checked by hand against the EDL"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overrideSkipPreview}
|
||||
onChange={(ev) => setOverrideSkipPreview(ev.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Skip the preview build and reuse the previous export's review media.
|
||||
<span className="block text-zinc-500">
|
||||
For EXRs framed differently from the review — unscaled or un-punched-in — where a
|
||||
preview rebuilt from these frames would be the wrong picture.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -254,6 +270,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
onClick={() => {
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
setOverrideSkipPreview(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createExport } from "@/lib/render-pipeline/exports";
|
||||
|
||||
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
|
||||
//
|
||||
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force? }
|
||||
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force?, skipPreview? }
|
||||
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
|
||||
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function POST(req: NextRequest) {
|
||||
force?: boolean;
|
||||
vfxScope?: string | null;
|
||||
submissionNote?: string | null;
|
||||
skipPreview?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
@@ -38,6 +39,7 @@ export async function POST(req: NextRequest) {
|
||||
// undefined inherits the shot's previous export values
|
||||
vfxScope: body.vfxScope,
|
||||
submissionNote: body.submissionNote,
|
||||
skipPreview: body.skipPreview === true,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (err) {
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function POST(
|
||||
|
||||
const result = await overrideValidation(exportId, {
|
||||
reason: parsed.data.reason,
|
||||
skipPreview: parsed.data.skipPreview,
|
||||
userId: user.id,
|
||||
userName: user.name ?? user.email ?? null,
|
||||
});
|
||||
|
||||
@@ -63,6 +63,13 @@ export interface CreateExportInput {
|
||||
*/
|
||||
vfxScope?: string | null;
|
||||
submissionNote?: string | null;
|
||||
/**
|
||||
* Render the EXRs but build no preview, inheriting the previous export's
|
||||
* review media. For deliverables framed differently from the review — an
|
||||
* unscaled or un-punched-in export — rebuilding the preview from these
|
||||
* frames would produce the wrong MOV.
|
||||
*/
|
||||
skipPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +204,7 @@ export async function createExport(input: CreateExportInput) {
|
||||
colorspace: manifest.expected?.colorspace ?? null,
|
||||
vfxScope,
|
||||
submissionNote,
|
||||
skipPreview: input.skipPreview === true,
|
||||
submittedById: submittedBy?.id ?? null,
|
||||
submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null,
|
||||
},
|
||||
@@ -238,7 +246,8 @@ export async function createExport(input: CreateExportInput) {
|
||||
actorType: "USER",
|
||||
actorId: submittedBy?.id ?? null,
|
||||
note:
|
||||
`Queue Export ${versionString}${input.force ? " (force)" : ""} — ` +
|
||||
`Queue Export ${versionString}${input.force ? " (force)" : ""}` +
|
||||
`${input.skipPreview ? " (skip preview)" : ""} — ` +
|
||||
`${provenance.ok ? "" : "WARNING: "}${provenance.note}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { applyTransition } from "./transitions";
|
||||
import { getConfigBoolean, getConfigNumber } from "./config";
|
||||
import { machineCanClaim } from "./machines";
|
||||
import { reapExpiredLeases } from "./reaper";
|
||||
import { buildPreviewManifest, createPreviewJob } from "./preview";
|
||||
import { buildPreviewManifest, createPreviewJob, inheritPreviewMedia } from "./preview";
|
||||
|
||||
const JOB_TYPES: RenderJobType[] = ["AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD"];
|
||||
|
||||
@@ -313,7 +313,7 @@ export async function completeRender(
|
||||
// Validation disabled: hand off to the preview stage (headless AE rebuild
|
||||
// → delivery MOV + review MP4) exactly as before Phase 3.
|
||||
const previewEnabled = await getConfigBoolean("preview.enabled");
|
||||
if (previewEnabled) {
|
||||
if (previewEnabled && !exportRow.skipPreview) {
|
||||
const manifest = await buildPreviewManifest(exportRow);
|
||||
const created = await createPreviewJob(tx, exportRow, manifest, job.maxAttempts, job.priority);
|
||||
previewJob = { id: created.id, type: created.type };
|
||||
@@ -324,11 +324,14 @@ export async function completeRender(
|
||||
}, stats);
|
||||
exportResult = { id: exp.id, status: exp.status };
|
||||
} else {
|
||||
const inherited = exportRow.skipPreview
|
||||
? await inheritPreviewMedia(tx, exportRow)
|
||||
: { data: {}, note: "preview stage disabled" };
|
||||
const exp = await applyTransition(tx, job.export, "READY_FOR_QC", {
|
||||
type: "WORKER",
|
||||
id: machineId,
|
||||
note: `Render complete in ${body.renderSeconds ?? "?"}s (preview stage disabled)`,
|
||||
}, stats);
|
||||
note: `Render complete in ${body.renderSeconds ?? "?"}s — ${inherited.note}`,
|
||||
}, { ...stats, ...inherited.data });
|
||||
exportResult = { id: exp.id, status: exp.status };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,59 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the previous export's review media onto one that skipped its own
|
||||
* preview build (§9).
|
||||
*
|
||||
* An export whose framing differs from the review framing — unscaled or
|
||||
* un-punched-in EXRs — must not have a preview rebuilt from its own frames:
|
||||
* the earlier, correctly-framed MOV *is* the right preview. Inheriting it also
|
||||
* keeps the delivery package whole, since the builder takes its MOV from
|
||||
* `deliveryMovPath`.
|
||||
*
|
||||
* Returns the fields to apply and a note describing what happened, so the
|
||||
* export's timeline records where its media came from.
|
||||
*/
|
||||
export async function inheritPreviewMedia(
|
||||
tx: Prisma.TransactionClient,
|
||||
exportRow: { id: string; shotId: string }
|
||||
): Promise<{ data: Prisma.ExportUpdateInput; note: string }> {
|
||||
const donor = await tx.export.findFirst({
|
||||
where: {
|
||||
shotId: exportRow.shotId,
|
||||
id: { not: exportRow.id },
|
||||
OR: [{ previewMovKey: { not: null } }, { deliveryMovPath: { not: null } }],
|
||||
},
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: {
|
||||
versionString: true,
|
||||
deliveryMovPath: true,
|
||||
previewMovKey: true,
|
||||
thumbnailKey: true,
|
||||
versionId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!donor) {
|
||||
return {
|
||||
data: {},
|
||||
note: "no preview built and no earlier export to inherit one from — this export has no review media",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
deliveryMovPath: donor.deliveryMovPath,
|
||||
previewMovKey: donor.previewMovKey,
|
||||
thumbnailKey: donor.thumbnailKey,
|
||||
// The Version row stays attached to the export that actually produced the
|
||||
// media; this one only points at the same files.
|
||||
versionId: donor.versionId,
|
||||
},
|
||||
note: `preview skipped — review media inherited from ${donor.versionString}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates the PREVIEW_ONLY job that follows a successful EXR render. */
|
||||
export async function createPreviewJob(
|
||||
tx: Prisma.TransactionClient,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "@/lib/db";
|
||||
import { PipelineError } from "./errors";
|
||||
import { applyTransition } from "./transitions";
|
||||
import { getConfigBoolean, getConfigNumber, getConfigValue, getPreviewConfig } from "./config";
|
||||
import { buildPreviewManifest, createPreviewJob } from "./preview";
|
||||
import { buildPreviewManifest, createPreviewJob, inheritPreviewMedia } from "./preview";
|
||||
|
||||
/**
|
||||
* Validation engine results (RenderPipeline2 §8). The worker runs the checks —
|
||||
@@ -172,13 +172,16 @@ export async function reportValidation(jobId: string, body: ValidationReport) {
|
||||
|
||||
// Passed: hand off to the preview stage, the same handoff completeRender
|
||||
// used to make directly before validation existed.
|
||||
if (!previewEnabled) {
|
||||
if (!previewEnabled || exportRow.skipPreview) {
|
||||
const inherited = exportRow.skipPreview
|
||||
? await inheritPreviewMedia(tx, exportRow)
|
||||
: { data: {}, note: "preview stage disabled" };
|
||||
const updated = await applyTransition(
|
||||
tx,
|
||||
exportRow,
|
||||
"READY_FOR_QC",
|
||||
{ type: "WORKER", id: body.machineId, note: `Validation passed (${summary}); preview stage disabled` },
|
||||
stats
|
||||
{ type: "WORKER", id: body.machineId, note: `Validation passed (${summary}); ${inherited.note}` },
|
||||
{ ...stats, ...inherited.data }
|
||||
);
|
||||
return {
|
||||
export: { id: updated.id, status: updated.status },
|
||||
@@ -221,6 +224,13 @@ export const overrideValidationSchema = z.object({
|
||||
* that decision the next person cannot reconstruct.
|
||||
*/
|
||||
reason: z.string().trim().min(3, "A reason is required").max(2000),
|
||||
/**
|
||||
* Go straight to QC, inheriting the previous export's review media instead
|
||||
* of building one. Correct when this export is framed differently from the
|
||||
* review — unscaled or un-punched-in frames — so a preview rebuilt from it
|
||||
* would be the wrong picture.
|
||||
*/
|
||||
skipPreview: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -239,7 +249,7 @@ export const overrideValidationSchema = z.object({
|
||||
*/
|
||||
export async function overrideValidation(
|
||||
exportId: string,
|
||||
input: { reason: string; userId: string; userName?: string | null }
|
||||
input: { reason: string; userId: string; userName?: string | null; skipPreview?: boolean }
|
||||
) {
|
||||
const previewEnabled = (await getPreviewConfig()).enabled;
|
||||
const maxAttempts = await getConfigNumber("render.maxAttempts");
|
||||
@@ -268,12 +278,18 @@ export async function overrideValidation(
|
||||
`Validation overridden by ${who}: ${input.reason.trim()}` +
|
||||
(waved.length ? ` — waved: ${waved.join(", ")}` : "");
|
||||
|
||||
if (!previewEnabled) {
|
||||
const updated = await applyTransition(tx, exportRow, "READY_FOR_QC", {
|
||||
type: "USER",
|
||||
id: input.userId,
|
||||
note: `${note} (preview stage disabled)`,
|
||||
});
|
||||
const skipPreview = input.skipPreview === true || exportRow.skipPreview;
|
||||
if (!previewEnabled || skipPreview) {
|
||||
const inherited = skipPreview
|
||||
? await inheritPreviewMedia(tx, exportRow)
|
||||
: { data: {}, note: "preview stage disabled" };
|
||||
const updated = await applyTransition(
|
||||
tx,
|
||||
exportRow,
|
||||
"READY_FOR_QC",
|
||||
{ type: "USER", id: input.userId, note: `${note} — ${inherited.note}` },
|
||||
inherited.data
|
||||
);
|
||||
return {
|
||||
export: { id: updated.id, status: updated.status },
|
||||
overridden: waved,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Exports that render EXRs without building a preview, inheriting the previous
|
||||
-- export's review media (unscaled / un-punched-in deliverables).
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "exports" ADD COLUMN "skipPreview" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -877,6 +877,12 @@ model Export {
|
||||
vfxScope String? @db.Text
|
||||
submissionNote String? @db.Text
|
||||
|
||||
/// Render the EXRs but build no preview, inheriting the previous export's
|
||||
/// review media instead. For an export whose framing differs from the review
|
||||
/// framing — unscaled or un-punched-in plates — the earlier MOV is the
|
||||
/// correct preview, and rebuilding it from these EXRs would be wrong.
|
||||
skipPreview Boolean @default(false)
|
||||
|
||||
submittedById String? // resolved User, else null
|
||||
submittedByName String? // free text from panel config as fallback
|
||||
supersededById String? // newer Export that replaced this one
|
||||
|
||||
Reference in New Issue
Block a user