Checks fail on renders that are genuinely fine — a plate the engine cannot reach, an expectation that does not apply to this shot — and a VALIDATION_FAILED export was a dead end, blocking delivery on a shot a human can see is correct. Override validation on the Export detail page resumes the pipeline where a pass would have handed off: preview, then READY_FOR_QC. It never skips QC and never delivers directly, so the frames are still looked at — the override waives the automated check, not the human one. The failed ValidationResult rows are left untouched rather than rewritten, so the QC queue keeps listing them as warnings and whoever QCs the shot sees what was waved through. The reason is required and recorded with the checks it covered on the export's timeline, and the validation panel keeps an "overridden" badge long after the event scrolls away. Restricted to admin/producer/supervisor: waving a failed check through is a supervisor's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,12 @@ An Export reaches `READY_FOR_DELIVERY` only by passing QC — from the AE panel
|
||||
or the web. That is the single gate; there is no way to package an export that
|
||||
has not been QC'd.
|
||||
|
||||
A render that failed automated validation can still get there: **Override
|
||||
validation** on the Export detail page (admin/producer/supervisor, reason
|
||||
required) resumes the pipeline at the preview stage, so the shot still goes
|
||||
through QC like any other. The failed checks stay on record and keep appearing
|
||||
as warnings in the QC queue, so whoever QCs it knows what was waved through.
|
||||
|
||||
Two rules worth knowing:
|
||||
|
||||
- **Newest version per shot only.** If a shot has two exports sitting ready, the
|
||||
|
||||
@@ -1085,6 +1085,7 @@ The offset term matters — a shot may render a sub-range of a long plate (obser
|
||||
|
||||
- Any FAIL → whole run FAIL → Export = VALIDATION_FAILED, pipeline stops, exact reasons in `ValidationResult` rows (E12 example above shows a missing-frame report).
|
||||
- WARN never stops the pipeline but is shown in QC and delivery UIs.
|
||||
- **Manual override.** A check can fail on a render that is genuinely fine — an unreachable plate, an expectation that does not apply to this shot — so a VALIDATION_FAILED export is not a dead end. An admin/producer/supervisor can override it from the Export detail page with a **required reason**, and the pipeline resumes exactly where a pass would have handed off: preview, then READY_FOR_QC. **An override never skips QC and never delivers directly** — a human still looks at the frames. The failed `ValidationResult` rows are left untouched, so the QC queue keeps listing them as warnings, and the reason plus the checks waved through are recorded on the Export's timeline.
|
||||
- Checksum: xxHash64 per file, sequence digest = xxHash64 of sorted per-file digests; stored on `Export.checksum` and reused by the delivery builder for copy verification (MD5 available for the client-facing manifest per 18.1-Q13).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, RotateCcw, XCircle } from "lucide-react";
|
||||
import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -105,6 +106,9 @@ const CHECK_STYLES: Record<string, string> = {
|
||||
export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [overrideOpen, setOverrideOpen] = useState(false);
|
||||
const [overrideReason, setOverrideReason] = useState("");
|
||||
const [overriding, setOverriding] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["pipeline-export", exportId],
|
||||
@@ -125,6 +129,31 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
}
|
||||
|
||||
async function overrideValidation() {
|
||||
setOverriding(true);
|
||||
try {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/override-validation`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: overrideReason }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Override failed", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Validation overridden",
|
||||
description: `${(body.overridden ?? []).join(", ") || "No failed checks recorded"} — continuing to preview and QC`,
|
||||
});
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
} finally {
|
||||
setOverriding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
@@ -178,6 +207,11 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{e.status === "VALIDATION_FAILED" && !overrideOpen && (
|
||||
<Button variant="outline" size="sm" onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldAlert className="h-4 w-4 mr-2 text-amber-400" /> Override validation
|
||||
</Button>
|
||||
)}
|
||||
{ACTIVE_STATUSES.includes(e.status) && (
|
||||
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
|
||||
@@ -186,6 +220,48 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Override — a human overruling the checks, so the reason is required
|
||||
and kept on the export's timeline. */}
|
||||
{overrideOpen && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-3">
|
||||
<div className="text-sm text-amber-200">
|
||||
Override the failed validation for {e.shot.shotCode} {e.versionString}?
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
The render continues to preview and then QC — it is not delivered directly, and the failed
|
||||
checks stay on record and still show up in the QC queue. Say why, so the next person
|
||||
reading this export knows what was waved through.
|
||||
</p>
|
||||
<textarea
|
||||
value={overrideReason}
|
||||
onChange={(ev) => setOverrideReason(ev.target.value)}
|
||||
rows={2}
|
||||
autoFocus
|
||||
placeholder="e.g. plate unreachable from the render node; timecode checked by hand against the EDL"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={overrideValidation}
|
||||
disabled={overrideReason.trim().length < 3 || overriding}
|
||||
>
|
||||
{overriding ? "Overriding…" : "Override and continue"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manifest */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Manifest</div>
|
||||
@@ -227,6 +303,13 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
|
||||
Validation
|
||||
{/* A waved-through failure must stay visible long after the event
|
||||
scrolls down the timeline. */}
|
||||
{e.events.some((ev) => ev.note?.startsWith("Validation overridden")) && (
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-400">
|
||||
overridden
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const latestRun = e.validations[e.validations.length - 1].renderJobId;
|
||||
const rows = e.validations.filter((v) => v.renderJobId === latestRun);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { overrideValidation, overrideValidationSchema } from "@/lib/render-pipeline/validation";
|
||||
|
||||
// ── POST /api/render/exports/{exportId}/override-validation ──────────────────
|
||||
//
|
||||
// A human overruling a failed validation run. Resumes the pipeline at the
|
||||
// preview stage, so the export still goes through QC; the failed checks stay on
|
||||
// record and keep showing up as warnings in the QC queue.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ exportId: string }> }
|
||||
) {
|
||||
try {
|
||||
// Waving a failed check through is a supervisor's call, not any user's.
|
||||
const user = await requirePipelineAdmin();
|
||||
const { exportId } = await params;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = overrideValidationSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A reason is required", details: parsed.error.issues }, { status: 422 });
|
||||
}
|
||||
|
||||
const result = await overrideValidation(exportId, {
|
||||
reason: parsed.data.reason,
|
||||
userId: user.id,
|
||||
userName: user.name ?? user.email ?? null,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ export const EXPORT_TRANSITIONS: Record<ExportStatus, ExportStatus[]> = {
|
||||
// QUEUED/RENDER_FAILED: the worker died *during* validation, so the render is
|
||||
// retried — nothing was learned about the EXRs either way (reaper).
|
||||
VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW", "READY_FOR_QC", "QUEUED", "RENDER_FAILED", "CANCELLED"],
|
||||
VALIDATION_FAILED: ["QUEUED", "SUPERSEDED"],
|
||||
// GENERATING_PREVIEW / READY_FOR_QC: a human overruled the checks (§8.2).
|
||||
// The override resumes where a pass would have handed off — it never skips
|
||||
// QC, so the frames are still looked at before anything ships.
|
||||
VALIDATION_FAILED: ["QUEUED", "SUPERSEDED", "GENERATING_PREVIEW", "READY_FOR_QC"],
|
||||
GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"],
|
||||
PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"],
|
||||
// GENERATING_PREVIEW: a slate/burn-in mistake is caught *after* a successful
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { PipelineError } from "./errors";
|
||||
import { applyTransition } from "./transitions";
|
||||
import { getConfigBoolean, getConfigValue, getPreviewConfig } from "./config";
|
||||
import { getConfigBoolean, getConfigNumber, getConfigValue, getPreviewConfig } from "./config";
|
||||
import { buildPreviewManifest, createPreviewJob } from "./preview";
|
||||
|
||||
/**
|
||||
@@ -214,6 +214,103 @@ 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),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
) {
|
||||
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(", ")}` : "");
|
||||
|
||||
if (!previewEnabled) {
|
||||
const updated = await applyTransition(tx, exportRow, "READY_FOR_QC", {
|
||||
type: "USER",
|
||||
id: input.userId,
|
||||
note: `${note} (preview stage disabled)`,
|
||||
});
|
||||
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" },
|
||||
});
|
||||
const previewJob = await tx.renderJob.create({
|
||||
data: {
|
||||
type: "PREVIEW_ONLY",
|
||||
exportId,
|
||||
attempt: (last?.attempt ?? 0) + 1,
|
||||
maxAttempts,
|
||||
priority: last?.priority ?? 50,
|
||||
manifest: manifest as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user