Files
vfxreview/lib/render-pipeline/validation.ts
T
twotalesanimation 41ee826700
Deploy / deploy (push) Successful in 6m11s
fix(pipeline): keep an urgent export urgent through a validation override
Overriding a failed validation queued the preview at priority 50
regardless. It took its rank from the export's last PREVIEW_ONLY job —
but validation runs before the preview stage, so a VALIDATION_FAILED
export has never had one, and the missing row fell through to a
hardcoded normal priority. An urgent shot lost its urgency at exactly
the moment someone was standing over it waiting.

Every preview now derives its rank from the export's AE_RENDER priority
rather than from a previous preview. That fixes the missing-row case and
also removes a slow drift: a preview's priority is already boosted, so
re-deriving from it moved an export a step up the queue on every retry.

createPreviewJob takes the render priority and the attempt number, so the
first build, a retry, a regenerate and an override all rank identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:19:19 +02:00

401 lines
14 KiB
TypeScript

import { Prisma, ValidationStatus } from "@prisma/client";
import { z } from "zod";
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
import { applyTransition } from "./transitions";
import { getConfigBoolean, getConfigNumber, getConfigValue, getPreviewConfig } from "./config";
import { buildPreviewManifest, createPreviewJob, inheritPreviewMedia, renderPriorityFor } from "./preview";
/**
* Validation engine results (RenderPipeline2 §8). The worker runs the checks —
* it has the files — and reports every result in one call. The server decides
* what they mean: it is the only thing that moves an Export's state.
*/
export const validationCheckSchema = z.object({
checkName: z.string().min(1).max(64),
status: z.enum(["PASS", "FAIL", "WARN", "SKIPPED"]),
expected: z.string().max(500).optional().nullable(),
actual: z.string().max(500).optional().nullable(),
message: z.string().max(2000).optional().nullable(),
details: z.unknown().optional(),
});
export const validationReportSchema = z.object({
machineId: z.string().min(1),
checks: z.array(validationCheckSchema).min(1).max(200),
/** Sequence-level digest reused by the delivery builder for copy verification. */
checksum: z.string().max(200).optional(),
checksumAlgo: z.string().max(32).optional(),
exrFileCount: z.number().int().nonnegative().optional(),
exrTotalBytes: z.number().nonnegative().optional(),
validationSeconds: z.number().nonnegative().optional(),
});
export type ValidationReport = z.infer<typeof validationReportSchema>;
/**
* Per-check severity overrides, e.g. `{"exr_type":"warn"}` (§17.3 risk row).
* A false FAIL blocks an artist, so a check can be demoted without a redeploy.
* `timecode_plate_match` is deliberately overridable too — but demoting it
* should be a considered, temporary act: it is the check the engine exists for.
*/
async function getSeverityOverrides(): Promise<Record<string, ValidationStatus>> {
const raw = await getConfigValue("validation.overrides");
if (!raw?.trim()) return {};
try {
const parsed = JSON.parse(raw) as Record<string, string>;
const out: Record<string, ValidationStatus> = {};
for (const [check, level] of Object.entries(parsed)) {
const up = String(level).toUpperCase();
if (up === "PASS" || up === "FAIL" || up === "WARN" || up === "SKIPPED") {
out[check] = up as ValidationStatus;
}
}
return out;
} catch {
console.error("[render-pipeline] validation.overrides is not valid JSON — ignoring");
return {};
}
}
function summarise(checks: { checkName: string; status: ValidationStatus }[]): string {
const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIPPED: 0 };
for (const c of checks) counts[c.status] += 1;
return `${counts.PASS} passed, ${counts.FAIL} failed, ${counts.WARN} warned, ${counts.SKIPPED} skipped`;
}
/**
* E12 — the worker reports every check from one validation run.
*
* Any FAIL stops the pipeline (§8.2): Export → VALIDATION_FAILED with the exact
* reasons queryable as rows. Otherwise the preview stage is queued, exactly as
* a successful render used to do directly.
*/
export async function reportValidation(jobId: string, body: ValidationReport) {
const overrides = await getSeverityOverrides();
const previewEnabled = (await getPreviewConfig()).enabled;
const effective = body.checks.map((c) => ({
...c,
status: (overrides[c.checkName] ?? c.status) as ValidationStatus,
overridden: overrides[c.checkName] != null && overrides[c.checkName] !== c.status,
}));
return db.$transaction(async (tx) => {
const job = await tx.renderJob.findUnique({
where: { id: jobId },
include: { export: true },
});
if (!job) throw new PipelineError(404, "Render job not found");
if (job.machineId !== body.machineId) {
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
}
const exportRow = job.export;
if (!exportRow) throw new PipelineError(422, "Job has no export to validate");
// Replayed report (worker retried the call after a network cut): the rows
// for this attempt already exist, so don't duplicate them or re-transition.
const existing = await tx.validationResult.count({
where: { exportId: exportRow.id, renderJobId: job.id },
});
if (existing > 0) {
return {
export: { id: exportRow.id, status: exportRow.status },
results: existing,
failed: exportRow.status === "VALIDATION_FAILED",
alreadyApplied: true,
};
}
if (exportRow.status !== "VALIDATING") {
throw new PipelineError(
409,
`Export is ${exportRow.status} — validation results are only accepted while VALIDATING`
);
}
// The render job was held open through validation (see completeRender) so a
// worker dying here is still reaped; the work is finished now.
await tx.renderJob.update({
where: { id: job.id },
data: { status: "COMPLETED", progress: 1, finishedAt: new Date() },
});
await tx.validationResult.createMany({
data: effective.map((c) => ({
exportId: exportRow.id,
renderJobId: job.id,
checkName: c.checkName,
status: c.status,
expected: c.expected ?? null,
actual: c.actual ?? null,
message: c.overridden
? `${c.message ?? "check reported " + c.status} [severity overridden by config]`
: (c.message ?? null),
details: (c.details ?? undefined) as Prisma.InputJsonValue | undefined,
})),
});
const failures = effective.filter((c) => c.status === "FAIL");
const stats = {
...(body.exrFileCount != null ? { exrFileCount: body.exrFileCount } : {}),
...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}),
...(body.checksum ? { checksum: body.checksum } : {}),
};
const summary = summarise(effective);
if (failures.length > 0) {
const reasons = failures
.slice(0, 5)
.map((f) => `${f.checkName}: ${f.message ?? `expected ${f.expected}, got ${f.actual}`}`)
.join("; ");
const updated = await applyTransition(
tx,
exportRow,
"VALIDATION_FAILED",
{
type: "WORKER",
id: body.machineId,
note: `Validation failed (${summary}) — ${reasons}${failures.length > 5 ? ` (+${failures.length - 5} more)` : ""}`,
},
stats
);
return {
export: { id: updated.id, status: updated.status },
results: effective.length,
failed: true,
failures: failures.map((f) => f.checkName),
alreadyApplied: false,
};
}
// Passed: hand off to the preview stage, the same handoff completeRender
// used to make directly before validation existed.
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}); ${inherited.note}` },
{ ...stats, ...inherited.data }
);
return {
export: { id: updated.id, status: updated.status },
results: effective.length,
failed: false,
previewJob: null,
alreadyApplied: false,
};
}
const manifest = await buildPreviewManifest(exportRow);
const previewJob = await createPreviewJob(tx, exportRow, manifest, job.maxAttempts, job.priority);
const updated = await applyTransition(
tx,
exportRow,
"GENERATING_PREVIEW",
{ type: "WORKER", id: body.machineId, note: `Validation passed (${summary}) — queued preview build` },
stats
);
return {
export: { id: updated.id, status: updated.status },
results: effective.length,
failed: false,
previewJob: { id: previewJob.id, type: previewJob.type },
alreadyApplied: false,
};
});
}
/** Whether the validation stage runs at all (kill switch for a bad engine release). */
export async function isValidationEnabled(): Promise<boolean> {
return getConfigBoolean("validation.enabled");
}
export const overrideValidationSchema = z.object({
/**
* Required, and deliberately so: an override is a human overruling a check
* that exists to stop bad frames shipping, and "why" is the only part of
* 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(),
});
/**
* Manual override of a failed validation run.
*
* Some checks fail on renders that are genuinely fine — a plate the engine
* cannot reach, an expectation that does not apply to this shot. Rather than
* leaving those exports stranded, an override resumes the pipeline *at the
* point validation would have handed off*: the preview is built and the export
* lands in READY_FOR_QC. It never skips QC, so a human still looks at the
* frames before anything can be delivered.
*
* The failed ValidationResult rows are left exactly as they are — history is
* not rewritten, and the QC queue keeps surfacing them as warnings so whoever
* QCs the shot knows what was waved through.
*/
export async function overrideValidation(
exportId: string,
input: { reason: string; userId: string; userName?: string | null; skipPreview?: boolean }
) {
const previewEnabled = (await getPreviewConfig()).enabled;
const maxAttempts = await getConfigNumber("render.maxAttempts");
return db.$transaction(async (tx) => {
const exportRow = await tx.export.findUnique({ where: { id: exportId } });
if (!exportRow) throw new PipelineError(404, "Export not found");
if (exportRow.status !== "VALIDATION_FAILED") {
throw new PipelineError(
409,
`Export is ${exportRow.status} — only a VALIDATION_FAILED export can be overridden`
);
}
const failed = await tx.validationResult.findMany({
where: { exportId, status: "FAIL" },
orderBy: { createdAt: "desc" },
select: { checkName: true, renderJobId: true },
});
// Only the most recent run's failures are what is being waved through.
const latestRun = failed[0]?.renderJobId;
const waved = [...new Set(failed.filter((f) => f.renderJobId === latestRun).map((f) => f.checkName))];
const who = input.userName ?? "unknown user";
const note =
`Validation overridden by ${who}: ${input.reason.trim()}` +
(waved.length ? ` — waved: ${waved.join(", ")}` : "");
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,
previewJob: null,
};
}
const manifest = await buildPreviewManifest(exportRow);
const last = await tx.renderJob.findFirst({
where: { exportId, type: "PREVIEW_ONLY" },
orderBy: { attempt: "desc" },
select: { attempt: true },
});
// An export that failed validation has never had a preview job — validation
// runs before the preview stage — so the rank comes from its render, which
// is what keeps an urgent export urgent through an override. Someone is
// usually standing over an urgent shot when they wave a check through.
const previewJob = await createPreviewJob(
tx,
exportRow,
manifest,
maxAttempts,
await renderPriorityFor(tx, exportId),
(last?.attempt ?? 0) + 1
);
const updated = await applyTransition(tx, exportRow, "GENERATING_PREVIEW", {
type: "USER",
id: input.userId,
note: `${note} — queued preview build`,
});
return {
export: { id: updated.id, status: updated.status },
overridden: waved,
previewJob: { id: previewJob.id, attempt: previewJob.attempt },
};
});
}
export interface ValidationBrowserFilter {
projectId?: string;
episode?: string;
checkName?: string;
status?: ValidationStatus;
page?: number;
limit?: number;
}
/**
* Cross-export browser (§13) — the point is spotting a systemic fault
* ("every shot this week failed timecode_plate_match"), not one bad render.
*/
export async function listValidationResults(filter: ValidationBrowserFilter) {
const page = Math.max(1, filter.page ?? 1);
const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
const where: Prisma.ValidationResultWhereInput = {
...(filter.checkName ? { checkName: filter.checkName } : {}),
...(filter.status ? { status: filter.status } : { status: { in: ["FAIL", "WARN"] } }),
...(filter.projectId || filter.episode
? {
export: {
...(filter.projectId ? { projectId: filter.projectId } : {}),
...(filter.episode ? { shot: { episode: filter.episode } } : {}),
},
}
: {}),
};
const [total, rows] = await Promise.all([
db.validationResult.count({ where }),
db.validationResult.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
include: {
export: {
select: {
id: true,
versionString: true,
status: true,
shot: { select: { shotCode: true, episode: true } },
},
},
},
}),
]);
return {
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
results: rows.map((r) => ({
id: r.id,
checkName: r.checkName,
status: r.status,
expected: r.expected,
actual: r.actual,
message: r.message,
details: r.details,
createdAt: r.createdAt,
exportId: r.exportId,
versionString: r.export.versionString,
exportStatus: r.export.status,
shotCode: r.export.shot.shotCode,
episode: r.export.shot.episode,
})),
};
}