From 6678721fb189ed41b667bab2f172892655fe1f8d Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:09:01 +0200 Subject: [PATCH] fix(pipeline): create the comp task an export needs when a shot has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shot with no COMP task rendered and previewed fine and then showed nothing in the app. createExport set Export.taskId from the shot's first COMP task or null, and finalizePreview registered the review Version with that same null taskId — so the MP4 was uploaded and stored, but the Version hung off no task and none of the review flows had anywhere to draw it. From the artist's side the export completed and produced nothing. E1 now resolves the shot's comp task inside the version-allocation transaction and creates one when there isn't any, titled after the preview file the export will produce: {shotCode}_cmp_TT_{version}, the same convention the manual batch upload's create-task path already uses. renderOutputBase() is exported and buildPreviewManifest takes its `base` from it, so the task title and the MOV/MP4 name come from one function rather than two copies of the same template. The title therefore already carries the version, which makes finalizePreview's rename-to-the- delivered-version a no-op on that first export rather than an immediate rename of a task created moments earlier. The lookup and the create sit inside the transaction so the P2002 version-collision retry rolls a losing attempt's task back with everything else, instead of leaving a duplicate behind. recalcShotStatus runs only when a task was actually created: a shot with no tasks derives WAITING and now has one. Approval-driven statuses still take precedence, so an already-approved shot stays COMPLETE. Both the queue-time ExportEvent note and the E1 response record what happened, so a task appearing under a shot on its own is traceable to the export that made it. Exports already in flight with a null taskId keep their orphaned versions; backfilling those is a separate migration. Co-Authored-By: Claude Opus 5 --- RenderPipeline2.md | 3 ++ lib/render-pipeline/exports.test.ts | 19 +++++++++++ lib/render-pipeline/exports.ts | 53 ++++++++++++++++++++++++++--- lib/render-pipeline/preview.ts | 6 +++- 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 lib/render-pipeline/exports.test.ts 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