4642ecb11f
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>
526 lines
20 KiB
TypeScript
526 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;
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
skipPreview: input.skipPreview === true,
|
|
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)" : ""}` +
|
|
`${input.skipPreview ? " (skip preview)" : ""} — ` +
|
|
`${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,
|
|
})),
|
|
};
|
|
}
|