diff --git a/RenderPipeline2.md b/RenderPipeline2.md index da7b2c8..481544b 100644 --- a/RenderPipeline2.md +++ b/RenderPipeline2.md @@ -268,6 +268,9 @@ model Export { shot Shot @relation(fields: [shotId], references: [id]) projectId String taskId String? // comp task the preview Version attaches to + // (E1 creates one named after the preview file + // when the shot has none, so the preview lands + // somewhere the app shows it) versionId String? // preview Version created at READY_FOR_QC versionNumber Int // 4 versionString String // "v004" diff --git a/lib/render-pipeline/exports.test.ts b/lib/render-pipeline/exports.test.ts new file mode 100644 index 0000000..0ecc890 --- /dev/null +++ b/lib/render-pipeline/exports.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { renderOutputBase } from "./exports"; +import { nextTaskTitle } from "./preview"; + +describe("renderOutputBase", () => { + it("follows the panel's house convention", () => { + expect(renderOutputBase("UNG_102_019_010", "v001")).toBe("UNG_102_019_010_cmp_TT_v001"); + }); + + // createExport names the comp task it creates after this basename, and + // buildPreviewManifest names the MOV/MP4 off the same call — so a shot queued + // with no task ends up with a task titled exactly like its preview file. The + // title already carries the version, which makes finalizePreview's rename of + // the task to the delivered version a no-op on that first export. + it("gives a freshly created comp task a title finalizePreview leaves alone", () => { + const title = renderOutputBase("UNG_102_019_010", "v003"); + expect(nextTaskTitle(title, "v003")).toBe(title); + }); +}); diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts index 27c17a3..3062bb9 100644 --- a/lib/render-pipeline/exports.ts +++ b/lib/render-pipeline/exports.ts @@ -4,6 +4,7 @@ import { PipelineError } from "./errors"; import { describePlateProvenance, renderManifestSchema, type RenderManifest } from "./manifest"; import { applyTransition, type TransitionActor } from "./transitions"; import { getConfigNumber, getConfigValue } from "./config"; +import { recalcShotStatus } from "@/lib/shot-status"; /** Statuses in which a shot has a live export in flight (§6.4 E1 409 rule). */ const ACTIVE_STATUSES: ExportStatus[] = [ @@ -48,7 +49,7 @@ function nextExrOutputBase(current: string | null, shotCode: string, versionStri * shot-code based and deliberately excludes the source clip name that * `Shot.exrOutput` carries from the EDL import. */ -function renderOutputBase(shotCode: string, versionString: string): string { +export function renderOutputBase(shotCode: string, versionString: string): string { return `${shotCode}_cmp_TT_${versionString}`; } @@ -89,7 +90,6 @@ export async function createExport(input: CreateExportInput) { where: { id: manifest.shotId }, include: { project: { select: { id: true, code: true, showId: true, projectType: true } }, - tasks: { where: { type: "COMP" }, orderBy: { sortOrder: "asc" }, take: 1, select: { id: true } }, }, }); if (!shot) throw new PipelineError(404, `Shot not found: ${manifest.shotId}`); @@ -157,6 +157,43 @@ export async function createExport(input: CreateExportInput) { const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber); const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString); + // Every Version the pipeline registers hangs off a Task, so a shot with + // no comp task renders fine and then has its preview land nowhere the + // app shows it — the export completes and the artist sees nothing. + // Give the export the task it needs, named after the preview file it + // will produce (the same name the manual batch upload picks, and the + // one finalizePreview's rename keeps in step from here on). + // + // The lookup is repeated inside the transaction because `shot` was read + // before it opened, and because the P2002 retry above re-runs this + // block: a rolled-back attempt takes its task with it. + const existingTask = await tx.task.findFirst({ + where: { shotId: shot.id, type: "COMP" }, + orderBy: { sortOrder: "asc" }, + select: { id: true }, + }); + let taskId = existingTask?.id ?? null; + let taskCreated = false; + if (!taskId) { + const lastTask = await tx.task.findFirst({ + where: { shotId: shot.id }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + const task = await tx.task.create({ + data: { + title: renderOutputBase(shot.shotCode, versionString), + type: "COMP", + shotId: shot.id, + projectId: shot.project.id, + createdById: submittedBy?.id ?? null, + sortOrder: (lastTask?.sortOrder ?? -1) + 1, + }, + }); + taskId = task.id; + taskCreated = true; + } + // {root}/{showId}/[{episode} for episodic]/{shotCode}/{version} const showSegment = shot.project.showId || shot.project.code; const episodeSegment = @@ -187,7 +224,7 @@ export async function createExport(input: CreateExportInput) { data: { shotId: shot.id, projectId: shot.project.id, - taskId: shot.tasks[0]?.id ?? null, + taskId, versionNumber, versionString, status: "QUEUED", @@ -248,10 +285,17 @@ export async function createExport(input: CreateExportInput) { note: `Queue Export ${versionString}${input.force ? " (force)" : ""}` + `${input.skipPreview ? " (skip preview)" : ""} — ` + - `${provenance.ok ? "" : "WARNING: "}${provenance.note}`, + `${provenance.ok ? "" : "WARNING: "}${provenance.note}` + + (taskCreated + ? ` — shot had no comp task, created "${renderOutputBase(shot.shotCode, versionString)}" for the review version` + : ""), }, }); + if (taskCreated) { + await recalcShotStatus(shot.id, tx); + } + return { export: { id: created.id, @@ -265,6 +309,7 @@ export async function createExport(input: CreateExportInput) { submissionNote, }, renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority }, + task: { id: taskId, created: taskCreated }, shot: { id: shot.id, shotVersion: versionString, exrOutput: exrBase }, superseded: toSupersede.map((s) => s.id), }; diff --git a/lib/render-pipeline/preview.ts b/lib/render-pipeline/preview.ts index 288699c..d8a64ae 100644 --- a/lib/render-pipeline/preview.ts +++ b/lib/render-pipeline/preview.ts @@ -5,6 +5,7 @@ import { applyTransition } from "./transitions"; import { getConfigNumber, getPreviewConfig } from "./config"; import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates"; import { clientShotCode } from "@/lib/shot-code"; +import { renderOutputBase } from "./exports"; /** Slate date format used by the panel's getDateString (YYYY/MM/DD). */ function slateDate(d = new Date()): string { @@ -90,7 +91,10 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise // above all its own show LUT. Falling back to another show's would render a // preview that is quietly the wrong colour. const templates = await resolveProjectTemplates(shot.project); - const base = `${shot.shotCode}_cmp_TT_${exportRow.versionString}`; + // Shared with createExport, which names the shot's comp task after this + // basename when it has to create one: the task and the file it holds must + // not drift apart. + const base = renderOutputBase(shot.shotCode, exportRow.versionString); // Client code, when set, replaces shotCode in everything the client sees — // slate identity and (via ctx.slate.versionName) the overlay burn-in — but // never in internal paths: movOutput/mp4Output below stay on `base`. Just