Files
vfxreview/lib/render-pipeline/manifest.ts
T
twotalesanimation 37f73a2bd7
Deploy / deploy (push) Successful in 6m13s
fix(validation): separate output file numbering from comp frame numbers
A render carries frame numbers in two unrelated systems. The comp is
anchored to its plate's timecode, so its timeline runs 1247792-1247896;
the output module names files from 1001. Validation compared the file
numbers against the comp range and failed every correctly-rendered shot
by ~1.2 million frames — first_frame, last_frame and missing_frames all
reporting a defect that was not there.

The manifest now carries outputFrameStart (the output module's starting
number, house convention 1001) alongside the comp range. Count and
contiguity are measured on the output numbering; first/last compare
against outputFrameStart and are SKIPPED on older manifests, where the
count and gap checks still hold on their own.

The same conflation broke the API timecode fallback: it anchored on the
comp's frameStart while the render's first frame is 1001, producing a
+1246791 frame "offset". Shot.timecodeStart is the edit in-point and the
plate carries handleFrames of head handle, so the reference is the API
value less handles, anchored on the render's own first frame.

Verified on the shot that reported it: UNG_107_051_010 v004 now passes
count, contiguity and a plate-anchored timecode match. Regression tests
cover both coordinate systems, including that a genuine +8 handle offset
still fails.

Panel 2.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:26:39 +02:00

125 lines
4.8 KiB
TypeScript

import { z } from "zod";
/**
* The Render Manifest (RenderPipeline2 §6.2).
* Uploaded by the AE panel on Queue Export, snapshotted verbatim into
* RenderJob.manifest, handed to the worker on claim.
*
* `outputDir` / `outputPattern` may be the literal string "auto" — the server
* generates them from the shot's exrOutput convention and the NEW version
* number it decides (§6.4 E1).
*/
/**
* A plate element imported into the comp, resolved by the panel from the comp's
* own footage layers — not re-derived from a path convention (§6.2).
*/
export const plateRefSchema = z.object({
element: z.string().optional(),
/** First frame of the sequence on disk; the timecode reference file. */
path: z.string().min(1),
frameStart: z.number().int().optional(),
frameEnd: z.number().int().optional(),
});
export type PlateRef = z.infer<typeof plateRefSchema>;
export const renderManifestSchema = z
.object({
manifestVersion: z.number().int().min(1).default(1),
projectCode: z.string().min(1),
shotCode: z.string().regex(/^[A-Za-z0-9_-]+$/, "shotCode contains invalid characters"),
shotId: z.string().min(1),
aepPath: z.string().min(1),
compName: z.string().min(1),
rendererType: z.string().default("aerender"),
aeVersionHint: z.string().optional(),
outputDir: z.string().min(1),
outputPattern: z.string().min(1),
outputModuleTemplate: z.string().optional(),
renderSettingsTemplate: z.string().optional(),
/** Comp timeline range handed to aerender. */
frameStart: z.number().int(),
frameEnd: z.number().int(),
/**
* The number the first *output file* carries — the output module's starting
* number (house convention 1001). Deliberately separate from frameStart: a
* comp anchored to its plate's timecode starts in the millions, so file
* numbering and comp frames are different coordinate systems.
*/
outputFrameStart: z.number().int().optional(),
fps: z.number().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
expected: z
.object({
colorspace: z.string().optional(),
bitDepth: z.string().optional(),
exrCompression: z.string().optional(),
alpha: z.boolean().optional(),
timecodeStart: z.string().optional(),
})
.optional(),
/**
* Plate provenance for the timecode check (§8.1a). Optional so
* manifestVersion 1 panels keep working — validation then degrades to a
* worker-derived plate, and finally to the API value as a WARN.
*/
source: z
.object({
/**
* The element the comp's start time was anchored to (the panel's
* primaryFootage). A shot's elements carry unrelated camera timecodes,
* so the timing reference must be named rather than guessed.
*/
timingPlate: plateRefSchema.optional(),
plates: z.array(plateRefSchema).optional(),
/**
* Verbatim from the panel's start-frame resolution: "footage metadata"
* (trustworthy) or "API timecode …" (fallback, suspect). Known at queue
* time, so a suspect export is flagged before it burns render hours.
*/
startSource: z.string().optional(),
startFrames: z.number().int().optional(),
handleFrames: z.number().int().optional(),
})
.optional(),
preview: z
.object({
template: z.string().optional(),
burnins: z.boolean().optional(),
})
.optional(),
})
.refine((m) => m.frameEnd >= m.frameStart, {
message: "frameEnd must be >= frameStart",
path: ["frameEnd"],
});
export type RenderManifest = z.infer<typeof renderManifestSchema>;
/**
* Queue-time read of the manifest's plate provenance (§8.1a). A comp whose
* start time came from the API rather than the plate's own metadata is very
* likely to fail the timecode check — worth recording before the render burns
* machine hours, not after.
*/
export function describePlateProvenance(manifest: RenderManifest): { ok: boolean; note: string } {
const src = manifest.source;
if (!src?.timingPlate?.path) {
return {
ok: false,
note: "no timing plate in manifest — timecode will be checked against a worker-derived plate",
};
}
// Only warn on a start source we positively know to be the API fallback. An
// absent startSource means the comp predates this session (an AEP opened from
// disk), which is normal — the worker still does the authoritative check.
if (src.startSource && /\bAPI\b/i.test(src.startSource)) {
return {
ok: false,
note: `comp start came from "${src.startSource}", not the plate's own metadata — timecode check likely to fail`,
};
}
return { ok: true, note: `timing plate ${src.timingPlate.element ?? src.timingPlate.path}` };
}