feb1c7b3e9
Completes RenderPipeline2 phases 3-5. Additive throughout: with validation.enabled=false the pipeline behaves exactly as before. Validation (§8) runs on the worker straight after a render, while it still holds the job and the files are local. Everything is read from the EXR header — one 64 KB read per sampled frame, no pixel decode — so the engine needs no oiiotool. The render job deliberately stays open through the stage, so a worker that dies there is reaped and retried instead of stranding the export in VALIDATING. Timecode is the primary check and is anchored on the plate, never the API: measured on the live show, Shot.timecodeStart is the editorial in-point and sits one handle-length (8f) ahead of the plate, so checking against it would pass the defective exports and fail the correct ones. Reference precedence is manifest timing plate -> plate found on disk -> API value, and the API rank is WARN-only. The panel now resolves plate provenance by walking the comp's own footage layers, so the worker never guesses which element carries the comp's timing. Verified against real studio renders via the new --validate mode: it catches both defect classes seen in shipped work — the +8 handle offset and frame-derived timecode (00:00:41:17 = frame 1001 at 24fps). QC (§10) is one service behind two front doors, API-key for the panel and session for the web. PASS means ready to upload and nothing else; FAIL sets the comp task to CHANGES. The panel gains a QC section that imports the real EXR sequence at full res rather than the H.264. Delivery (§11) rides the existing queue as a DELIVERY_BUILD job: hard links with a copy fallback, verification by re-hashing delivered bytes against the validation-time digest, manifest.json and the CSV report. fsutil hardlink list is unavailable over SMB, so link identity is never queried — reading the bytes back covers links and copies alike. Also fixes ApiClient.ProgressAsync throwing on transport failure, which would have aborted work in flight during a brief server outage; progress reporting is best-effort by contract. Removes the stale RenderWorker/ rule from the root .gitignore. The worker's source is tracked like the rest of the repo and its own .gitignore already covers bin/, obj/ and publish/; the rule silently excluded new files while the existing 20 stayed tracked. Not yet applied: migration 20260813000000 was hand-written because the dev database was unavailable — run prisma migrate deploy and verify before relying on it. None of this has run against a live server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
import { ExportStatus, Prisma } from "@prisma/client";
|
|
import { db } from "@/lib/db";
|
|
import { PipelineError } from "./errors";
|
|
import { describePlateProvenance, renderManifestSchema, type RenderManifest } from "./manifest";
|
|
import { applyTransition, type TransitionActor } from "./transitions";
|
|
import { getConfigNumber, getConfigValue } from "./config";
|
|
|
|
/** Statuses in which a shot has a live export in flight (§6.4 E1 409 rule). */
|
|
const ACTIVE_STATUSES: ExportStatus[] = [
|
|
"QUEUED",
|
|
"RENDERING",
|
|
"VALIDATING",
|
|
"GENERATING_PREVIEW",
|
|
];
|
|
|
|
/** Non-terminal statuses auto-superseded when a newer Export is queued (T1/T17). */
|
|
const SUPERSEDABLE_STATUSES: ExportStatus[] = [
|
|
"QUEUED",
|
|
"RENDER_FAILED",
|
|
"VALIDATION_FAILED",
|
|
"PREVIEW_FAILED",
|
|
"READY_FOR_QC",
|
|
"QC_FAILED",
|
|
"READY_FOR_DELIVERY",
|
|
];
|
|
|
|
const OPEN_JOB_STATUSES = ["QUEUED", "CLAIMED", "RUNNING"] as const;
|
|
|
|
export function parseVersionString(v: string | null | undefined): number {
|
|
const m = /^v(\d+)$/i.exec(v?.trim() ?? "");
|
|
return m ? parseInt(m[1], 10) : 0;
|
|
}
|
|
|
|
export function formatVersionString(n: number): string {
|
|
return `v${String(n).padStart(3, "0")}`;
|
|
}
|
|
|
|
/** `UNG_..._cmp_TT_v003` + 4 → `UNG_..._cmp_TT_v004`; falls back to house convention. */
|
|
function nextExrOutputBase(current: string | null, shotCode: string, versionString: string): string {
|
|
if (current && /_v\d+$/i.test(current)) return current.replace(/_v\d+$/i, `_${versionString}`);
|
|
if (current) return `${current}_${versionString}`;
|
|
return renderOutputBase(shotCode, versionString);
|
|
}
|
|
|
|
/**
|
|
* Rendered EXR naming for the pipeline — matches the panel's
|
|
* "Queue EXR (Review)" convention (`{shotCode}_cmp_TT_{version}`), which is
|
|
* 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 {
|
|
return `${shotCode}_cmp_TT_${versionString}`;
|
|
}
|
|
|
|
export interface CreateExportInput {
|
|
manifest: unknown;
|
|
submittedByEmail?: string | null;
|
|
priority?: number;
|
|
force?: boolean;
|
|
/**
|
|
* Per-submission slate fields. `undefined` inherits the previous export's
|
|
* value for this shot; `null` or "" explicitly clears it.
|
|
*/
|
|
vfxScope?: string | null;
|
|
submissionNote?: string | null;
|
|
}
|
|
|
|
/**
|
|
* E1 — Queue Export (§6.4). Transactionally: decides the new version number
|
|
* (server-side, race-free), updates Shot.shotVersion/exrOutput exactly as the
|
|
* panel's legacy PATCH did, supersedes older non-terminal exports, creates
|
|
* Export(QUEUED) + RenderJob attempt 1 with the manifest snapshot.
|
|
*/
|
|
export async function createExport(input: CreateExportInput) {
|
|
const parsed = renderManifestSchema.safeParse(input.manifest);
|
|
if (!parsed.success) {
|
|
throw new PipelineError(422, "Manifest validation error", parsed.error.issues);
|
|
}
|
|
const manifest = parsed.data;
|
|
|
|
const shot = await db.shot.findUnique({
|
|
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}`);
|
|
|
|
// Cross-checks (§6.2): manifest vs DB truth
|
|
if (shot.shotCode !== manifest.shotCode) {
|
|
throw new PipelineError(422, `Manifest shotCode "${manifest.shotCode}" does not match shot ${shot.id} ("${shot.shotCode}")`);
|
|
}
|
|
if (shot.project.code !== manifest.projectCode) {
|
|
throw new PipelineError(422, `Manifest projectCode "${manifest.projectCode}" does not match shot's project ("${shot.project.code}")`);
|
|
}
|
|
// A 0/0 shot range means "never imported", not a real single-frame shot at 0
|
|
const shotRangeKnown =
|
|
shot.frameStart != null && shot.frameEnd != null && !(shot.frameStart === 0 && shot.frameEnd === 0);
|
|
if (
|
|
!input.force &&
|
|
shotRangeKnown &&
|
|
(shot.frameStart !== manifest.frameStart || shot.frameEnd !== manifest.frameEnd)
|
|
) {
|
|
throw new PipelineError(422, `Manifest frame range ${manifest.frameStart}-${manifest.frameEnd} does not match shot record ${shot.frameStart}-${shot.frameEnd} (pass force to override)`);
|
|
}
|
|
|
|
const activeExport = await db.export.findFirst({
|
|
where: { shotId: shot.id, status: { in: ACTIVE_STATUSES } },
|
|
select: { id: true, status: true, versionString: true },
|
|
});
|
|
if (activeExport && !input.force) {
|
|
throw new PipelineError(
|
|
409,
|
|
`An active export (${activeExport.versionString}, ${activeExport.status}) already exists for this shot — pass force to supersede it`,
|
|
{ activeExportId: activeExport.id }
|
|
);
|
|
}
|
|
|
|
const submittedBy = input.submittedByEmail
|
|
? await db.user.findUnique({ where: { email: input.submittedByEmail }, select: { id: true, name: true } })
|
|
: null;
|
|
|
|
const [maxAttempts, outputRoot] = await Promise.all([
|
|
getConfigNumber("render.maxAttempts"),
|
|
getConfigValue("render.outputRoot"),
|
|
]);
|
|
|
|
// Retry loop: @@unique([shotId, versionNumber]) guards the version-increment
|
|
// race between two concurrent E1 calls; loser recomputes and retries.
|
|
for (let tryNo = 0; tryNo < 3; tryNo++) {
|
|
try {
|
|
return await db.$transaction(async (tx) => {
|
|
const latest = await tx.export.aggregate({
|
|
where: { shotId: shot.id },
|
|
_max: { versionNumber: true },
|
|
});
|
|
const freshShot = await tx.shot.findUniqueOrThrow({
|
|
where: { id: shot.id },
|
|
select: { shotVersion: true, exrOutput: true, slateVfxScope: true, slateSubmissionNote: true },
|
|
});
|
|
// vfxScope: caller override → shot's slate field
|
|
const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (freshShot.slateVfxScope ?? null);
|
|
// submissionNote: caller override (per-export) → shot's slate default
|
|
const submissionNote =
|
|
input.submissionNote !== undefined ? input.submissionNote : (freshShot.slateSubmissionNote ?? null);
|
|
// versionNumber is an internal sequence for DB uniqueness only; output files
|
|
// use the shot's current (manually-set) shotVersion.
|
|
const versionNumber = (latest._max.versionNumber ?? 0) + 1;
|
|
const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber);
|
|
const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString);
|
|
|
|
// {root}/{showId}/[{episode} for episodic]/{shotCode}/{version}
|
|
const showSegment = shot.project.showId || shot.project.code;
|
|
const episodeSegment =
|
|
shot.project.projectType === "EPISODIC" && shot.episode?.trim() ? shot.episode.trim() : null;
|
|
const outputDir =
|
|
manifest.outputDir === "auto"
|
|
? [outputRoot, showSegment, episodeSegment, shot.shotCode, versionString]
|
|
.filter(Boolean)
|
|
.join("/")
|
|
: manifest.outputDir;
|
|
const outputPattern =
|
|
manifest.outputPattern === "auto"
|
|
? `${renderOutputBase(shot.shotCode, versionString)}.[#####].exr`
|
|
: manifest.outputPattern;
|
|
|
|
const resolvedManifest: RenderManifest = { ...manifest, outputDir, outputPattern };
|
|
|
|
// Supersede older non-terminal exports (T1/T17); cancel their open jobs.
|
|
const toSupersede = await tx.export.findMany({
|
|
where: {
|
|
shotId: shot.id,
|
|
status: { in: input.force ? [...SUPERSEDABLE_STATUSES, ...ACTIVE_STATUSES] : SUPERSEDABLE_STATUSES },
|
|
},
|
|
select: { id: true, status: true },
|
|
});
|
|
|
|
const created = await tx.export.create({
|
|
data: {
|
|
shotId: shot.id,
|
|
projectId: shot.project.id,
|
|
taskId: shot.tasks[0]?.id ?? null,
|
|
versionNumber,
|
|
versionString,
|
|
status: "QUEUED",
|
|
aepPath: manifest.aepPath,
|
|
compName: manifest.compName,
|
|
rendererType: manifest.rendererType,
|
|
outputDir,
|
|
outputPattern,
|
|
frameStart: manifest.frameStart,
|
|
frameEnd: manifest.frameEnd,
|
|
fps: manifest.fps,
|
|
width: manifest.width,
|
|
height: manifest.height,
|
|
colorspace: manifest.expected?.colorspace ?? null,
|
|
vfxScope,
|
|
submissionNote,
|
|
submittedById: submittedBy?.id ?? null,
|
|
submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null,
|
|
},
|
|
});
|
|
|
|
for (const old of toSupersede) {
|
|
await tx.renderJob.updateMany({
|
|
where: { exportId: old.id, status: { in: [...OPEN_JOB_STATUSES] } },
|
|
data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: `Superseded by export ${created.id} (${versionString})` },
|
|
});
|
|
await applyTransition(
|
|
tx,
|
|
old,
|
|
"SUPERSEDED",
|
|
{ type: "SYSTEM", note: `Superseded by ${versionString}` },
|
|
{ supersededById: created.id }
|
|
);
|
|
}
|
|
|
|
const renderJob = await tx.renderJob.create({
|
|
data: {
|
|
type: "AE_RENDER",
|
|
exportId: created.id,
|
|
attempt: 1,
|
|
maxAttempts,
|
|
priority: input.priority ?? 50,
|
|
manifest: resolvedManifest as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
// Plate provenance is recorded at queue time so a suspect export is
|
|
// visible in the queue before it renders (§8.1a).
|
|
const provenance = describePlateProvenance(resolvedManifest);
|
|
await tx.exportEvent.create({
|
|
data: {
|
|
exportId: created.id,
|
|
fromStatus: null,
|
|
toStatus: "QUEUED",
|
|
actorType: "USER",
|
|
actorId: submittedBy?.id ?? null,
|
|
note:
|
|
`Queue Export ${versionString}${input.force ? " (force)" : ""} — ` +
|
|
`${provenance.ok ? "" : "WARNING: "}${provenance.note}`,
|
|
},
|
|
});
|
|
|
|
return {
|
|
export: {
|
|
id: created.id,
|
|
shotCode: shot.shotCode,
|
|
versionNumber,
|
|
versionString,
|
|
status: created.status,
|
|
outputDir,
|
|
outputPattern,
|
|
vfxScope,
|
|
submissionNote,
|
|
},
|
|
renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority },
|
|
shot: { id: shot.id, shotVersion: versionString, exrOutput: exrBase },
|
|
superseded: toSupersede.map((s) => s.id),
|
|
};
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" && tryNo < 2) {
|
|
continue; // concurrent E1 won the version — recompute
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
throw new PipelineError(409, "Could not allocate a version number after retries");
|
|
}
|
|
|
|
function serializeExport<T extends { exrTotalBytes: bigint | null }>(e: T) {
|
|
return { ...e, exrTotalBytes: e.exrTotalBytes == null ? null : Number(e.exrTotalBytes) };
|
|
}
|
|
|
|
/** E2 — latest export + status for the AE panel header (§6.4). */
|
|
export async function getLatestExport(shotCode: string, projectCode?: string | null) {
|
|
const shot = await db.shot.findFirst({
|
|
where: { shotCode, ...(projectCode ? { project: { code: projectCode } } : {}) },
|
|
select: { id: true },
|
|
});
|
|
if (!shot) throw new PipelineError(404, "Shot not found");
|
|
|
|
const exp = await db.export.findFirst({
|
|
where: { shotId: shot.id },
|
|
orderBy: { versionNumber: "desc" },
|
|
include: {
|
|
renderJobs: {
|
|
orderBy: { attempt: "desc" },
|
|
take: 1,
|
|
include: { machine: { select: { name: true } } },
|
|
},
|
|
},
|
|
});
|
|
if (!exp) return { export: null };
|
|
|
|
const job = exp.renderJobs[0] ?? null;
|
|
return {
|
|
export: {
|
|
id: exp.id,
|
|
versionNumber: exp.versionNumber,
|
|
versionString: exp.versionString,
|
|
status: exp.status,
|
|
statusChangedAt: exp.statusChangedAt,
|
|
outputDir: exp.outputDir,
|
|
outputPattern: exp.outputPattern,
|
|
// Panel prefills its slate fields from these
|
|
vfxScope: exp.vfxScope,
|
|
submissionNote: exp.submissionNote,
|
|
renderJob: job
|
|
? {
|
|
id: job.id,
|
|
attempt: job.attempt,
|
|
status: job.status,
|
|
progress: job.progress,
|
|
currentFrame: job.currentFrame,
|
|
totalFrames: job.totalFrames,
|
|
etaSeconds: job.etaSeconds,
|
|
errorMessage: job.errorMessage,
|
|
machineName: job.machine?.name ?? null,
|
|
}
|
|
: null,
|
|
previewUrl: exp.previewMovKey ? `/api/files/${exp.previewMovKey}` : null,
|
|
thumbnailUrl: exp.thumbnailKey ? `/api/files/${exp.thumbnailKey}` : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** E3 — export detail incl. attempts and audit events. */
|
|
export async function getExportDetail(exportId: string) {
|
|
const exp = await db.export.findUnique({
|
|
where: { id: exportId },
|
|
include: {
|
|
shot: { select: { id: true, shotCode: true, episode: true, project: { select: { id: true, name: true, code: true } } } },
|
|
renderJobs: {
|
|
orderBy: { attempt: "asc" },
|
|
include: { machine: { select: { id: true, name: true } } },
|
|
},
|
|
events: { orderBy: { createdAt: "asc" } },
|
|
validations: { orderBy: { createdAt: "asc" } },
|
|
qcReviews: { orderBy: { createdAt: "desc" } },
|
|
deliveryItems: {
|
|
include: {
|
|
package: { select: { id: true, name: true, episode: true, status: true, packageNumber: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!exp) throw new PipelineError(404, "Export not found");
|
|
return {
|
|
export: {
|
|
...serializeExport(exp),
|
|
deliveryItems: exp.deliveryItems.map((i) => ({ ...i, totalBytes: i.totalBytes == null ? null : Number(i.totalBytes) })),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* E14 / E15 — retry a failed export: new RenderJob attempt, back to QUEUED.
|
|
* PREVIEW_FAILED is routed to the preview-only retry (T11) so validated EXRs
|
|
* are never re-rendered just to rebuild a slate.
|
|
*/
|
|
export async function retryExport(exportId: string, actor: TransitionActor) {
|
|
const current = await db.export.findUnique({ where: { id: exportId }, select: { status: true } });
|
|
if (!current) throw new PipelineError(404, "Export not found");
|
|
if (current.status === "PREVIEW_FAILED") {
|
|
const { retryPreview } = await import("./preview");
|
|
return retryPreview(exportId, actor.id ?? undefined);
|
|
}
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const exp = await tx.export.findUnique({
|
|
where: { id: exportId },
|
|
include: { renderJobs: { orderBy: { attempt: "desc" }, take: 1 } },
|
|
});
|
|
if (!exp) throw new PipelineError(404, "Export not found");
|
|
if (!["RENDER_FAILED", "VALIDATION_FAILED"].includes(exp.status)) {
|
|
throw new PipelineError(409, `Export is ${exp.status} — only RENDER_FAILED, VALIDATION_FAILED or PREVIEW_FAILED exports can be retried`);
|
|
}
|
|
const last = exp.renderJobs[0];
|
|
if (!last) throw new PipelineError(500, "Export has no render jobs");
|
|
|
|
const job = await tx.renderJob.create({
|
|
data: {
|
|
type: last.type,
|
|
exportId: exp.id,
|
|
attempt: last.attempt + 1,
|
|
maxAttempts: last.maxAttempts,
|
|
priority: last.priority,
|
|
manifest: last.manifest as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
const updated = await applyTransition(tx, exp, "QUEUED", {
|
|
...actor,
|
|
note: actor.note ?? `Retry (attempt ${job.attempt})`,
|
|
});
|
|
return { export: { id: updated.id, status: updated.status }, renderJob: { id: job.id, attempt: job.attempt } };
|
|
});
|
|
}
|
|
|
|
/** E15 / T18 — cancel a queued or rendering export. Worker learns via heartbeat/progress. */
|
|
export async function cancelExport(exportId: string, actor: TransitionActor) {
|
|
return db.$transaction(async (tx) => {
|
|
const exp = await tx.export.findUnique({ where: { id: exportId }, select: { id: true, status: true } });
|
|
if (!exp) throw new PipelineError(404, "Export not found");
|
|
if (exp.status === "CANCELLED") return { export: exp, alreadyApplied: true };
|
|
if (!["QUEUED", "RENDERING"].includes(exp.status)) {
|
|
throw new PipelineError(409, `Export is ${exp.status} — only QUEUED or RENDERING exports can be cancelled`);
|
|
}
|
|
await tx.renderJob.updateMany({
|
|
where: { exportId: exp.id, status: "QUEUED" },
|
|
data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Cancelled by user" },
|
|
});
|
|
// Claimed/running jobs stay marked CANCELLED but keep finishedAt null until
|
|
// the worker acknowledges (heartbeat CANCEL_JOB command / progress cancelRequested)
|
|
await tx.renderJob.updateMany({
|
|
where: { exportId: exp.id, status: { in: ["CLAIMED", "RUNNING"] } },
|
|
data: { status: "CANCELLED", errorMessage: "Cancelled by user" },
|
|
});
|
|
const updated = await applyTransition(tx, exp, "CANCELLED", actor);
|
|
return { export: { id: updated.id, status: updated.status }, alreadyApplied: false };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Phase 1 stopgap (§17.1): admin "mark as done manually" so the studio can
|
|
* keep using the old render path while the queue is validated. Removed once
|
|
* workers render for real.
|
|
*/
|
|
export async function markExportDoneManually(exportId: string, userId: string, note?: string) {
|
|
return db.$transaction(async (tx) => {
|
|
const exp = await tx.export.findUnique({ where: { id: exportId }, select: { id: true, status: true } });
|
|
if (!exp) throw new PipelineError(404, "Export not found");
|
|
if (!["QUEUED", "RENDERING"].includes(exp.status)) {
|
|
throw new PipelineError(409, `Export is ${exp.status} — only QUEUED or RENDERING exports can be marked done`);
|
|
}
|
|
await tx.renderJob.updateMany({
|
|
where: { exportId: exp.id, status: { in: [...OPEN_JOB_STATUSES] } },
|
|
data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Manually marked done (legacy render path)" },
|
|
});
|
|
const updated = await applyTransition(tx, exp, "READY_FOR_QC", {
|
|
type: "USER",
|
|
id: userId,
|
|
note: note ?? "Manually marked done (rendered outside the pipeline)",
|
|
});
|
|
return { export: { id: updated.id, status: updated.status } };
|
|
});
|
|
}
|
|
|
|
export interface ListExportsFilter {
|
|
projectId?: string;
|
|
episode?: string;
|
|
status?: ExportStatus;
|
|
shotId?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
/** E21 — paginated export list for the web queue/monitoring pages. */
|
|
export async function listExports(filter: ListExportsFilter) {
|
|
const page = Math.max(1, filter.page ?? 1);
|
|
const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
|
|
const where: Prisma.ExportWhereInput = {
|
|
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
|
...(filter.status ? { status: filter.status } : {}),
|
|
...(filter.shotId ? { shotId: filter.shotId } : {}),
|
|
...(filter.episode ? { shot: { episode: filter.episode } } : {}),
|
|
};
|
|
const [total, rows] = await Promise.all([
|
|
db.export.count({ where }),
|
|
db.export.findMany({
|
|
where,
|
|
orderBy: { createdAt: "desc" },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
include: {
|
|
shot: { select: { shotCode: true, episode: true, project: { select: { name: true, code: true } } } },
|
|
renderJobs: {
|
|
orderBy: { attempt: "desc" },
|
|
take: 1,
|
|
include: { machine: { select: { name: true } } },
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
return {
|
|
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
|
exports: rows.map((e) => ({
|
|
id: e.id,
|
|
shotCode: e.shot.shotCode,
|
|
episode: e.shot.episode,
|
|
projectName: e.shot.project.name,
|
|
projectCode: e.shot.project.code,
|
|
versionString: e.versionString,
|
|
status: e.status,
|
|
statusChangedAt: e.statusChangedAt,
|
|
createdAt: e.createdAt,
|
|
outputDir: e.outputDir,
|
|
job: e.renderJobs[0]
|
|
? {
|
|
id: e.renderJobs[0].id,
|
|
attempt: e.renderJobs[0].attempt,
|
|
status: e.renderJobs[0].status,
|
|
progress: e.renderJobs[0].progress,
|
|
currentFrame: e.renderJobs[0].currentFrame,
|
|
totalFrames: e.renderJobs[0].totalFrames,
|
|
etaSeconds: e.renderJobs[0].etaSeconds,
|
|
priority: e.renderJobs[0].priority,
|
|
machineName: e.renderJobs[0].machine?.name ?? null,
|
|
errorMessage: e.renderJobs[0].errorMessage,
|
|
}
|
|
: null,
|
|
})),
|
|
};
|
|
}
|