Files
vfxreview/lib/render-pipeline/manifest.ts
T
twotalesanimation 2e9fa45580
Deploy / deploy (push) Successful in 3m31s
feat(review): flag handles in the review player (metadata only)
Renders run 8 frames past the cut at each end. Directors were flagging
glitches in unfixed handles as shot problems, so the player now marks
them — without trimming or burning anything into the EXRs/MOV/MP4.

- Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/
  handleTail; migration backfills pipeline exports and their preview
  versions from the manifest's handleFrames (else 8)
- AE panel: "Includes handles [8] frames each end" -> manifest.review;
  older manifests fall back to source.handleFrames
- finalize copies handles onto the Version; worker 1.6.4 reports the
  head slate length (pictureStartSeconds), else the player infers it
  as MP4 frames - EXR frameCount
- Player: hatched HANDLE / SLATE regions and cut marks on the timeline,
  "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge
  on notes, "Edit range only" toggle (default on in the client portal)
- Staff Handles editor on the review page for manual uploads/corrections

Also fixes two player bugs found while testing: timeToFrame floored
float error (every seek landed a frame early), and J/reverse never ran
(the keyboard effect's cleanup stopped it on the next render).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:07:29 +02:00

139 lines
5.4 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, in the order it
* ranks them: "EXR timecode …" (the plate's own header, best),
* "footage metadata" (AE's read of it, 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(),
/**
* Handles the render carries past the cut, from the panel's "Includes
* handles" setting (0/0 = none). Review metadata only — the player flags
* these frames; nothing is trimmed. Absent (older panels): falls back to
* `source.handleFrames`, which every panel export has sent.
*/
review: z
.object({
handleHead: z.number().int().min(0).max(1000),
handleTail: z.number().int().min(0).max(1000),
})
.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}` };
}