Files
vfxreview/RenderPipeline2.md
T
twotalesanimation ae58dc0366 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

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

77 KiB
Raw Blame History

Render Pipeline 2 — Implementation Specification

Status: Approved for implementation — all design decisions resolved (Section 18.1); Section 18.2 is a checklist of values to collect during implementation Author: Lead Architect Date: 2026-07-31 (rev 3 — final studio decisions folded in) Source of truth for current state: TECHNICAL_ARCHITECTURE_REPORT_CURRENT_STATE.md, EXT_API_REFERENCE.md


1. Purpose and Scope

1.1 Goal

Reduce the artist workflow to:

Artist finishes comp → clicks "Queue Export" → everything else is automatic

The pipeline automates: version increment, rendering (aerender), output validation, preview/thumbnail/metadata generation, QC queueing, and Netflix-style delivery package construction. The only remaining manual steps are: building the comp, visually QC-ing the render, and uploading the finished delivery folder to Content Hub (no Content Hub API exists today).

1.2 What this document is

A complete implementation blueprint. Future AI coding agents implement it phase-by-phase (Section 17) without needing further architectural decisions — every design question has been answered by the studio and recorded in Section 18.1; Section 18.2 lists the concrete values and verifications to collect during implementation.

1.3 What this document is not

  • Not a redesign of VFXReview. Every existing model, route, and workflow stays intact.
  • Not production code. Schema and payloads below are normative in shape, not character-exact.

1.4 New capabilities introduced

Capability Today After
Render execution Artist's AE render queue, manual RenderWorker Windows service driving aerender.exe
Versioning Artist clicks "Increment Version" in panel Automatic on Queue Export
Output validation None Automatic frame/metadata/EXR validation
Preview MOV / thumbnail AE render queue templates, manual Automatic (worker, ffmpeg)
QC Ad hoc Explicit QC queue + Pass/Fail from AE panel
Delivery PowerShell copy scripts from AE panel Build Netflix Delivery button, DB-driven package builder
Delivery history None Permanent DeliveryPackage records

2. Guiding Principles and Reuse Map

2.1 Principles

  1. Extend, never replace. New models reference existing ones by foreign key; no existing column is removed or repurposed.
  2. The database is the queue. No Redis/RabbitMQ/BullMQ. PostgreSQL row-level atomic claims are sufficient at this scale (tens of jobs/day, a handful of workers). This is the single most important simplicity decision in this spec.
  3. The worker owns the filesystem; the server owns the truth. Workers touch EXRs on the shared render root. The web app never reads render output directly — it trusts worker reports persisted in the DB. The app server (Docker) is never required to mount the SAN.
  4. Workers talk only HTTP. Workers never get a DATABASE_URL. All state flows through /api/ext/* using the existing API-key auth. This is what makes "add more render machines later without changing the API" free.
  5. Previews become Versions. The generated MOV preview is registered as a normal Version row on the shot's comp task. The entire existing review stack (player, comments, annotations, client portal) works on pipeline renders with zero changes.
  6. Idempotent, resumable, observable. Every worker→server report is safe to repeat; every job can be retried; every state change is timestamped and attributable.

2.2 Reuse map

Existing asset Reused for
/api/ext/* + API_SECRET_KEY bearer auth All worker and AE-panel endpoints (Section 15 adds per-machine keys as an option)
Shot.shotVersion, Shot.exrOutput Remain the canonical "current version" mirror; pipeline updates them on Queue Export exactly as the panel's PATCH does today
Version model + upload/review flows Preview MOVs are created as Versions; internal/client review unchanged
lib/storage.ts abstraction Upload of preview MOV, thumbnail, metadata JSON, delivery reports
/api/files/{...key} Serving previews/thumbnails as today
lib/frame-utils.ts, lib/edl-utils.ts Frame counting, timecode math in validation and manifest building
SystemConfig table Pipeline-wide settings (poll intervals, lease durations, tool paths defaults)
NextAuth sessions All new internal UI pages and internal APIs
Existing ext API conventions (JSON errors, Zod validation, pagination shape) All new endpoints
AE panel shot lookup / episode / shot list endpoints Unchanged; panel keeps using them

2.3 Explicit non-goals

  • Uploading EXR sequences to object storage (they stay on the shared filesystem; studio decision 18.1-Q2).
  • Deadline/Tractor-style generic render farm. This is an AE-only, purpose-built queue.
  • Automatic Content Hub upload (no API exists; manual step stays).

3. System Overview

3.1 Component diagram

flowchart LR
  subgraph Workstation["Artist Workstation"]
    AE["After Effects + VFXReview Panel (CEP/ExtendScript)"]
  end

  subgraph Server["VFXReview (Next.js + Prisma + PostgreSQL)"]
    EXT["/api/ext/* (API-key auth)"]
    INT["/api/* internal (NextAuth)"]
    DB[(PostgreSQL)]
    ST["Storage abstraction lib/storage.ts"]
  end

  subgraph RenderNode["Render Machine(s) — Windows"]
    W["VFXReview RenderWorker (Windows Service)"]
    AER["aerender.exe"]
    FF["ffmpeg / oiiotool"]
  end

  SAN[("Shared filesystem (projects, AEPs, render roots, delivery roots)")]
  OBJ[("Object storage (Hetzner / S3-compat)")]
  UI["Web UI (Render Queue, QC, Deliveries, Machines)"]

  AE -- "Queue Export, QC Pass/Fail" --> EXT
  W -- "claim / progress / validate / complete / heartbeat" --> EXT
  EXT --> DB
  INT --> DB
  UI --> INT
  W --> AER
  W --> FF
  AER --> SAN
  FF --> SAN
  AE --> SAN
  W -- "preview MOV / thumb / metadata upload" --> EXT
  EXT --> ST --> OBJ

3.2 Happy-path sequence

sequenceDiagram
  autonumber
  participant A as AE Panel
  participant S as VFXReview API
  participant W as RenderWorker
  participant F as Shared FS

  A->>S: POST /api/ext/exports (manifest)
  S->>S: increment Shot.shotVersion, create Export(QUEUED) + RenderJob(attempt 1)
  S-->>A: exportId, versionString v004
  loop poll (default 10 s)
    W->>S: POST /api/ext/render/jobs/claim
  end
  S-->>W: job + manifest (Export → RENDERING)
  W->>F: launch aerender.exe, parse stdout
  W->>S: PATCH progress (frame N of M, ETA)
  W->>S: POST complete-render (Export → VALIDATING)
  W->>F: scan EXR sequence, run checks
  W->>S: POST validation results (pass) (Export → GENERATING_PREVIEW)
  W->>F: ffmpeg MOV + thumbnail + metadata JSON
  W->>S: presign + upload preview artifacts
  W->>S: POST finalize (Export → READY_FOR_QC, Version row created)
  A->>S: GET /api/ext/qc/queue → artist QCs in AE
  A->>S: POST /api/ext/exports/{id}/qc {result: PASS}
  S->>S: Export → READY_FOR_DELIVERY
  Note over S: Coordinator clicks "Build Netflix Delivery" in web UI
  S->>S: DeliveryPackage(QUEUED) + DeliveryItems (version-locked)
  W->>S: claim build job
  W->>F: hard-link/copy EXRs + MOVs, write report + manifest
  W->>S: POST package complete (PACKAGED)
  Note over S: Human uploads folder to Content Hub, clicks "Mark Delivered"
  S->>S: Package DELIVERED, items' Exports → DELIVERED

4. Export Lifecycle State Machine

The state machine lives on Export (not on Shot, not on RenderJob). Shot.status remains derived exactly as today; RenderJob has its own small execution status. "Working" is the implicit pre-state before any Export row exists.

4.1 State diagram

stateDiagram-v2
  [*] --> QUEUED : Queue Export (AE panel)
  QUEUED --> RENDERING : worker claims job
  QUEUED --> CANCELLED : user cancels
  RENDERING --> RENDER_FAILED : aerender error / crash / lease expiry after max retries
  RENDERING --> QUEUED : lease expired, retries remain (new attempt)
  RENDERING --> VALIDATING : aerender exit 0
  RENDERING --> CANCELLED : user cancels (worker kills process)
  RENDER_FAILED --> QUEUED : Retry (new RenderJob attempt)
  VALIDATING --> VALIDATION_FAILED : any check fails
  VALIDATING --> GENERATING_PREVIEW : all checks pass
  VALIDATION_FAILED --> QUEUED : Retry (re-render)
  GENERATING_PREVIEW --> PREVIEW_FAILED : ffmpeg/upload error
  PREVIEW_FAILED --> GENERATING_PREVIEW : Retry preview only
  GENERATING_PREVIEW --> READY_FOR_QC : preview Version registered
  READY_FOR_QC --> QC_FAILED : QC Fail
  READY_FOR_QC --> READY_FOR_DELIVERY : QC Pass
  QC_FAILED --> [*] : artist fixes comp, queues new Export (this one → SUPERSEDED)
  READY_FOR_DELIVERY --> PACKAGED : included in a built DeliveryPackage
  PACKAGED --> DELIVERED : package marked delivered
  READY_FOR_DELIVERY --> SUPERSEDED : newer Export passes QC
  READY_FOR_QC --> SUPERSEDED : newer Export queued for same shot
  QUEUED --> SUPERSEDED : newer Export queued for same shot
  DELIVERED --> ARCHIVED : archival policy / manual
  CANCELLED --> [*]
  SUPERSEDED --> [*]
  ARCHIVED --> [*]

4.2 Transition table (normative)

# From To Trigger Actor Side effects
T1 (Working) QUEUED POST /api/ext/exports AE panel Shot.shotVersion incremented; Shot.exrOutput updated; RenderJob attempt 1 created; any older non-terminal Export for the shot → SUPERSEDED
T2 QUEUED RENDERING Atomic claim Worker RenderJob.machineId/claimedAt/leaseExpiresAt set
T3 RENDERING VALIDATING complete-render report, exit 0 Worker render duration recorded
T4 RENDERING RENDER_FAILED fail report or lease expiry with attempt >= maxAttempts Worker / reaper stderr tail + exit code stored on RenderJob
T5 RENDERING QUEUED Lease expiry, attempt < maxAttempts Server reaper new RenderJob row, attempt+1
T6 RENDER_FAILED QUEUED Retry button / retry endpoint User (web/panel) new RenderJob attempt
T7 VALIDATING VALIDATION_FAILED Validation report contains a FAIL Worker ValidationResult rows persisted; pipeline stops
T8 VALIDATING GENERATING_PREVIEW All checks PASS/WARN Worker ValidationResult rows persisted
T9 VALIDATION_FAILED QUEUED Retry (full re-render) User new attempt; old validation rows kept for history
T10 GENERATING_PREVIEW PREVIEW_FAILED ffmpeg/upload failure Worker error stored
T11 PREVIEW_FAILED GENERATING_PREVIEW Retry preview User validation is NOT re-run
T12 GENERATING_PREVIEW READY_FOR_QC finalize report Worker Version row created (isLatest, never client-shared), thumbnail set, metadata JSON key stored; no task/shot status change — QC typically runs on already client-approved shots and must not disturb review state (Section 10)
T13 READY_FOR_QC QC_FAILED POST .../qc {FAIL} Artist (AE) or user (web) QCReview row; task → CHANGES (drives existing Shot REVISIONS derivation)
T14 READY_FOR_QC READY_FOR_DELIVERY POST .../qc {PASS} Artist / user QCReview row
T15 READY_FOR_DELIVERY PACKAGED Delivery build completes Worker DeliveryItem links Export to package
T16 PACKAGED DELIVERED "Mark Delivered" User (web) DeliveryPackage.deliveredAt/By set
T17 QUEUED / READY_FOR_QC / READY_FOR_DELIVERY / QC_FAILED SUPERSEDED Newer Export queued (T1) or newer Export reaches READY_FOR_DELIVERY Server automatic; PACKAGED/DELIVERED Exports are never auto-superseded (delivery history is immutable)
T18 QUEUED / RENDERING CANCELLED Cancel endpoint User worker told to kill via claim-refresh response
T19 DELIVERED ARCHIVED Manual / retention job Admin terminal

Rules:

  • Terminal states: CANCELLED, SUPERSEDED, ARCHIVED. DELIVERED is terminal except T19.
  • Illegal transitions are rejected server-side with 409 { "error": "Invalid transition RENDERING → READY_FOR_QC" }. The server is the sole authority on state; workers request transitions.
  • Every transition writes Export.statusChangedAt and appends to ExportEvent (Section 5.9) for auditability.

5. Database Design

All additions are new models plus two nullable columns on existing models. No existing column is modified or removed. Prisma-style definitions below are normative in shape; implementers map to house style (cuid ids, createdAt/updatedAt timestamps as in the existing schema).

5.1 New enums

enum ExportStatus {
  QUEUED
  RENDERING
  RENDER_FAILED
  VALIDATING
  VALIDATION_FAILED
  GENERATING_PREVIEW
  PREVIEW_FAILED
  READY_FOR_QC
  QC_FAILED
  READY_FOR_DELIVERY
  PACKAGED
  DELIVERED
  SUPERSEDED
  ARCHIVED
  CANCELLED
}

enum RenderJobStatus { QUEUED  CLAIMED  RUNNING  COMPLETED  FAILED  CANCELLED  EXPIRED }
enum RenderJobType   { AE_RENDER  PREVIEW_ONLY  DELIVERY_BUILD }
enum ValidationStatus { PASS  FAIL  WARN  SKIPPED }
enum QCResult         { PASS  FAIL }
enum MachineStatus    { ONLINE  OFFLINE  DISABLED }
enum DeliveryStatus   { DRAFT  QUEUED  BUILDING  READY  DELIVERED  FAILED  CANCELLED }

5.2 Export

The central pipeline entity: one row per "Queue Export" click. Carries the Section 4 state machine.

model Export {
  id              String       @id @default(cuid())
  shotId          String
  shot            Shot         @relation(fields: [shotId], references: [id])
  projectId       String
  taskId          String?      // comp task the preview Version attaches to
  versionId       String?      // preview Version created at READY_FOR_QC
  versionNumber   Int          // 4
  versionString   String       // "v004"
  status          ExportStatus @default(QUEUED)
  statusChangedAt DateTime     @default(now())

  // Manifest (denormalised for querying; full manifest JSON on RenderJob)
  aepPath         String
  compName        String
  rendererType    String       // "aerender"
  outputDir       String       // render root for this export
  outputPattern   String       // "UNG_106_010_020_cmp_TT_v004.[####].exr"
  frameStart      Int
  frameEnd        Int
  fps             Float
  width           Int
  height          Int
  colorspace      String?      // expected, e.g. "ACES - ACEScg"

  // Artifacts
  deliveryMovPath String?      // slate/burn-in delivery MOV on the SAN (Section 9); packaged in deliveries
  previewMovKey   String?      // web H.264 transcode in object storage (also Version.fileUrl)
  thumbnailKey    String?
  metadataKey     String?      // metadata JSON in object storage
  exrFileCount    Int?
  exrTotalBytes   BigInt?
  checksum        String?      // sequence-level digest (xxHash of per-file hashes)

  submittedById   String?      // resolved User, else null
  submittedByName String?      // free text from panel config as fallback
  supersededById  String?      // newer Export that replaced this one

  renderJobs      RenderJob[]
  validations     ValidationResult[]
  qcReviews       QCReview[]
  deliveryItems   DeliveryItem[]
  events          ExportEvent[]

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@unique([shotId, versionNumber])
  @@index([status])
  @@index([projectId, status])
}

Design note — why Export is separate from Version: Version is the review-media entity (an MOV with comments/annotations/approvals). An Export is a render lifecycle that eventually produces a Version (T12). Overloading Version with render state would break the existing review flows this spec promises not to touch. Export.versionId links them 1:1 once the preview exists.

Design note — why Export ↔ RenderJob is 1:N: each retry is a fresh RenderJob row (attempt N). This keeps a complete execution history (which machine, which logs, how long) without mutating past attempts, and makes "Render History" (Section 13) a trivial query.

5.3 RenderJob

One execution attempt of work by a worker. Also used (with type: DELIVERY_BUILD) for delivery package builds, so there is exactly one queue/claim/lease/heartbeat mechanism in the whole system.

model RenderJob {
  id              String          @id @default(cuid())
  type            RenderJobType   @default(AE_RENDER)
  exportId        String?         // set for AE_RENDER / PREVIEW_ONLY
  export          Export?         @relation(fields: [exportId], references: [id])
  deliveryId      String?         // set for DELIVERY_BUILD
  attempt         Int             @default(1)
  maxAttempts     Int             @default(3)
  status          RenderJobStatus @default(QUEUED)
  priority        Int             @default(50)   // lower = sooner
  manifest        Json            // full Render Manifest snapshot (Section 6.2)

  machineId       String?
  machine         Machine?        @relation(fields: [machineId], references: [id])
  claimedAt       DateTime?
  leaseExpiresAt  DateTime?       // claim + leaseSeconds; renewed by progress reports
  startedAt       DateTime?
  finishedAt      DateTime?

  progress        Float           @default(0)    // 0..1
  currentFrame    Int?
  totalFrames     Int?
  etaSeconds      Int?
  exitCode        Int?
  errorMessage    String?
  logTail         String?         // last ~200 lines of aerender output
  logFileKey      String?         // full log uploaded to object storage on finish/fail

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([status, priority, createdAt])
  @@index([machineId, status])
}

5.4 ValidationResult

One row per check per validation run, so the UI can show exactly what failed and history is preserved across retries.

model ValidationResult {
  id          String           @id @default(cuid())
  exportId    String
  export      Export           @relation(fields: [exportId], references: [id])
  renderJobId String           // which attempt produced this run
  checkName   String           // "frame_count", "missing_frames", "resolution", ...
  status      ValidationStatus
  expected    String?          // "120"
  actual      String?          // "119"
  message     String?          // "Frame 1057 missing"
  details     Json?            // e.g. list of missing frame numbers
  createdAt   DateTime @default(now())

  @@index([exportId, renderJobId])
}

5.5 QCReview

model QCReview {
  id           String   @id @default(cuid())
  exportId     String
  export       Export   @relation(fields: [exportId], references: [id])
  result       QCResult
  notes        String?
  reviewedById String?  // resolved User where possible
  reviewerName String?  // fallback for API-key reviews from the panel
  source       String   // "AE_PANEL" | "WEB"
  createdAt    DateTime @default(now())

  @@index([exportId])
}

Multiple rows per Export are allowed (fail → re-QC after preview retry); the latest row is authoritative and drives T13/T14.

5.6 Machine and WorkerHeartbeat

model Machine {
  id            String        @id @default(cuid())
  name          String        @unique   // "RENDER-01"
  hostname      String
  status        MachineStatus @default(OFFLINE)
  enabled       Boolean       @default(true)   // admin kill-switch: disabled machines cannot claim
  lastSeenAt    DateTime?
  workerVersion String?
  aeVersion     String?       // "2026 (24.x)"
  capabilities  Json?         // { maxConcurrentJobs: 1, tools: {ffmpeg: "7.1", oiiotool: "2.5"} }
  availability  Json?         // Section 7.10: { mode, windows, allowUrgentAnytime } — null = ALWAYS
  renderNowUntil DateTime?    // manual "Render Now" override (web toggle); claims allowed until this time
  apiKeyHash    String?       // optional per-machine key (kept for the future; Section 15 — shared key for now)
  renderJobs    RenderJob[]
  heartbeats    WorkerHeartbeat[]
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}

model WorkerHeartbeat {
  id            String   @id @default(cuid())
  machineId     String
  machine       Machine  @relation(fields: [machineId], references: [id])
  createdAt     DateTime @default(now())
  cpuPercent    Float?
  memPercent    Float?
  diskFreeGb    Float?
  currentJobId  String?

  @@index([machineId, createdAt])
}

Heartbeats also update Machine.lastSeenAt/status in place. A daily prune keeps 7 days of heartbeat rows (Section 7.8). ONLINE = heartbeat within 3× the heartbeat interval; the machine-monitor page derives this, and the reaper derives lease expiry independently of it.

5.7 DeliveryPackage and DeliveryItem

model DeliveryPackage {
  id             String         @id @default(cuid())
  projectId      String
  episode        String         // "101"
  name           String         // "260805_Delivery" (per 18.1-Q6 convention)
  packageNumber  Int            // sequential per project → "Package #15"
  status         DeliveryStatus @default(DRAFT)
  rootPath       String?        // final path on delivery volume
  configSnapshot Json           // naming template + options at build time (immutable record)
  manifest       Json?          // built manifest: files, sizes, checksums
  reportKey      String?        // delivery report (object storage) — also written into the folder
  errorMessage   String?
  createdById    String
  createdAt      DateTime @default(now())
  builtAt        DateTime?
  deliveredAt    DateTime?
  deliveredById  String?
  items          DeliveryItem[]

  @@unique([projectId, packageNumber])
  @@index([projectId, episode])
}

model DeliveryItem {
  id            String          @id @default(cuid())
  packageId     String
  package       DeliveryPackage @relation(fields: [packageId], references: [id])
  exportId      String          // version lock: the exact Export delivered
  export        Export          @relation(fields: [exportId], references: [id])
  shotId        String
  shotCode      String          // denormalised: survives shot renames
  versionString String          // denormalised: "v004"
  exrPath       String?         // path inside package
  movPath       String?
  fileCount     Int?
  totalBytes    BigInt?
  checksum      String?
  status        String          @default("PENDING") // PENDING | COPIED | VERIFIED | FAILED

  @@unique([packageId, exportId])
}

Version locking: the package references Export rows, and items denormalise shotCode/versionString at creation. Later re-renders of a shot never mutate a package — delivery history is immutable by construction.

5.8 Changes to existing models (additive only)

Model Addition Purpose
Shot exports Export[] back-relation navigation only; no column
Project deliveryConfig Json? (nullable) per-production delivery naming/layout templates (Section 11.2); falls back to SystemConfig defaults when null
Version (no change) preview Versions are ordinary rows; Export.versionId points at them
SystemConfig new keys (no schema change) render.leaseSeconds, render.pollSeconds, render.maxAttempts, delivery.defaultTemplate, etc.

5.9 ExportEvent (audit log)

model ExportEvent {
  id         String   @id @default(cuid())
  exportId   String
  export     Export   @relation(fields: [exportId], references: [id])
  fromStatus String?
  toStatus   String
  actorType  String   // "WORKER" | "USER" | "SYSTEM"
  actorId    String?  // machineId or userId
  note       String?
  createdAt  DateTime @default(now())

  @@index([exportId, createdAt])
}

Cheap, append-only, powers the timeline strip on the Export detail page and answers "why did this fail at 3 am".

5.10 Entity-relationship diagram (new + touching models)

erDiagram
  Project ||--o{ Shot : has
  Shot ||--o{ Version : has
  Shot ||--o{ Task : has
  Shot ||--o{ Export : has
  Export ||--o{ RenderJob : attempts
  Export ||--o{ ValidationResult : checks
  Export ||--o{ QCReview : reviews
  Export ||--o{ ExportEvent : audit
  Export |o--o| Version : "produces preview"
  Machine ||--o{ RenderJob : executes
  Machine ||--o{ WorkerHeartbeat : reports
  Project ||--o{ DeliveryPackage : has
  DeliveryPackage ||--o{ DeliveryItem : contains
  DeliveryItem }o--|| Export : "locks version of"
  RenderJob }o--o| DeliveryPackage : "builds (type=DELIVERY_BUILD)"

6. API Design

6.1 Conventions (inherited from existing ext API)

  • Base URL, auth headers (Authorization: Bearer <key> / X-Api-Key), JSON error shape { "error": "...", "details": [...] }, and status-code table are identical to EXT_API_REFERENCE.md.
  • New external namespaces: /api/ext/exports, /api/ext/render, /api/ext/qc, /api/ext/workers, /api/ext/deliveries.
  • New internal (NextAuth-session) namespaces mirror them: /api/render/*, /api/deliveries/*, /api/machines/* for the web UI. Internal routes are thin wrappers over the same service-layer functions (lib/render-pipeline/*.ts) — one implementation, two auth fronts, matching the existing actions/route split.
  • All state-changing worker endpoints are idempotent: repeating a report for a job already past that state returns 200 { "alreadyApplied": true }, not an error — except contradictory reports (e.g. complete after fail), which return 409.

6.2 The Render Manifest

Uploaded by the AE panel on Queue Export; snapshotted verbatim into RenderJob.manifest; handed to the worker on claim. The panel builds it from the existing /api/ext/shots/lookup payload plus local AE knowledge.

{
  "manifestVersion": 1,
  "projectCode": "UNG_S1",
  "shotCode": "UNG_106_010_020",
  "shotId": "clxxxxxxxxxxxxxx",
  "aepPath": "//SAN/projects/UNG_S1/106/UNG_106_010_020/ae/UNG_106_010_020_comp.aep",
  "compName": "UNG_106_010_020_cmp",
  "rendererType": "aerender",
  "aeVersionHint": "2026",
  "outputDir": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004",
  "outputPattern": "UNG_106_010_020_cmp_TT_v004.[####].exr",
  "outputModuleTemplate": "VFXR_EXR_SEQ",
  "renderSettingsTemplate": "VFXR_FULL",
  "frameStart": 1001,
  "frameEnd": 1120,
  "fps": 24,
  "width": 3840,
  "height": 2160,
  "expected": {
    "colorspace": "ACES - ACEScg",
    "bitDepth": "half",
    "exrCompression": "PIZ",
    "alpha": false,
    "timecodeStart": "00:59:49:12"
  },
  "preview": {
    "template": "default",
    "burnins": true
  }
}

The server validates the manifest with Zod, cross-checks shotId/shotCode/frame range against the DB, and the server (not the panel) decides the new version number — race-free single source of truth.

6.3 Endpoint summary

# Method Path Auth Caller Purpose
E1 POST /api/ext/exports API key AE panel Queue Export (creates Export + RenderJob)
E2 GET /api/ext/exports/latest?shotCode=&projectCode= API key AE panel Latest export + status for panel display
E3 GET /api/ext/exports/{exportId} API key panel/worker Export detail incl. validations, QC
E4 POST /api/ext/exports/{exportId}/qc API key AE panel QC Pass / QC Fail
E5 GET /api/ext/qc/queue?projectCode=&episode= API key AE panel Exports in READY_FOR_QC
E6 POST /api/ext/workers/register API key worker Register/upsert machine
E7 POST /api/ext/workers/{machineId}/heartbeat API key worker Heartbeat + cancel-signal channel
E8 POST /api/ext/render/jobs/claim API key worker Atomically claim next job
E9 PATCH /api/ext/render/jobs/{jobId}/progress API key worker Progress/ETA; renews lease
E10 POST /api/ext/render/jobs/{jobId}/fail API key worker Report failure
E11 POST /api/ext/render/jobs/{jobId}/complete-render API key worker aerender finished OK → VALIDATING
E12 POST /api/ext/render/jobs/{jobId}/validation API key worker Report all validation results
E13 POST /api/ext/render/jobs/{jobId}/finalize API key worker Preview artifacts done → READY_FOR_QC
E14 POST /api/ext/exports/{exportId}/retry API key panel Retry failed export
E15 POST /api/render/jobs/{jobId}/retry · /cancel session web UI Retry / cancel
E16 POST /api/deliveries session web UI Create package (DRAFT→QUEUED)
E17 GET /api/deliveries?projectId=&episode= session web UI List packages / history
E18 GET /api/deliveries/ready?projectId=&episode= session web UI READY_FOR_DELIVERY exports grouped by episode
E19 POST /api/deliveries/{id}/delivered session web UI Mark Delivered
E20 POST /api/ext/render/jobs/{jobId}/artifact-presign API key worker Presigned upload for preview/thumb/metadata/log
E21 GET /api/render/queue · /api/render/exports · /api/machines session web UI Queue/monitoring pages (standard pagination)

6.4 Request/response examples (normative)

E1 — Queue Export

POST /api/ext/exports
Authorization: Bearer <API_SECRET_KEY>
Content-Type: application/json

{ "manifest": { ...Section 6.2..., "outputDir": "auto", "outputPattern": "auto" },
  "submittedByEmail": "jane@studio.com" }

outputDir/outputPattern may be "auto": the server generates them from the shot's exrOutput convention and the new version number, and returns them; the panel does not guess paths for a version it doesn't know yet.

201
{
  "export": {
    "id": "exp_01",
    "shotCode": "UNG_106_010_020",
    "versionString": "v004",
    "status": "QUEUED",
    "outputDir": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004",
    "outputPattern": "UNG_106_010_020_cmp_TT_v004.[####].exr"
  },
  "renderJob": { "id": "rj_01", "attempt": 1, "priority": 50 },
  "shot": { "id": "clxxx", "shotVersion": "v004", "exrOutput": "UNG_106_010_020_cmp_TT_v004" },
  "superseded": ["exp_00"]
}

Errors: 409 if an active (QUEUED/RENDERING/VALIDATING/GENERATING_PREVIEW) Export exists for the shot and force is not set; 422 manifest validation failure.

E2 — Latest export for panel display

GET /api/ext/exports/latest?shotCode=UNG_106_010_020&projectCode=UNG_S1
200
{
  "export": {
    "id": "exp_01", "versionString": "v004", "status": "READY_FOR_QC",
    "statusChangedAt": "2026-08-01T12:04:11Z",
    "renderJob": { "progress": 1, "machineName": "RENDER-01" },
    "validation": { "overall": "PASS", "failed": [] },
    "qc": { "latestResult": null },
    "previewUrl": "/api/files/videos/uuid-UNG_106_010_020_cmp_TT_v004.mov"
  }
}

Returns { "export": null } when the shot has never been exported.

E6 — Worker registration (idempotent upsert on name)

POST /api/ext/workers/register
{ "name": "RENDER-01", "hostname": "render01.studio.local",
  "workerVersion": "1.0.3", "aeVersion": "24.3",
  "capabilities": { "maxConcurrentJobs": 1, "tools": { "ffmpeg": "7.1", "oiiotool": "2.5.4" } } }
200 { "machine": { "id": "mac_01", "name": "RENDER-01", "enabled": true },
      "config": { "pollSeconds": 10, "heartbeatSeconds": 30, "leaseSeconds": 300 } }

Server-supplied config (from SystemConfig) means fleet tuning without touching worker installs.

E7 — Heartbeat (also the cancel channel)

POST /api/ext/workers/mac_01/heartbeat
{ "cpuPercent": 82.5, "memPercent": 61.0, "diskFreeGb": 512.3, "currentJobId": "rj_01" }
200 { "ok": true, "commands": [ { "type": "CANCEL_JOB", "jobId": "rj_01" } ] }

commands is normally []. Piggybacking cancellation on the heartbeat avoids any server→worker connection (workers may be NAT-ed; no websockets needed).

E8 — Claim next job (the critical atomic operation)

POST /api/ext/render/jobs/claim
{ "machineId": "mac_01", "types": ["AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD"] }
200
{ "job": { "id": "rj_01", "type": "AE_RENDER", "attempt": 1, "exportId": "exp_01",
           "leaseExpiresAt": "2026-08-01T11:35:00Z",
           "manifest": { ... } } }

204 No Content when the queue is empty. Server-side claim must be atomic under concurrent workers:

UPDATE "RenderJob" SET status='CLAIMED', "machineId"=$1,
       "claimedAt"=now(), "leaseExpiresAt"=now() + ($2 * interval '1 second')
WHERE id = (
  SELECT id FROM "RenderJob"
  WHERE status='QUEUED' AND type = ANY($3)
  ORDER BY priority ASC, "createdAt" ASC
  FOR UPDATE SKIP LOCKED LIMIT 1 )
RETURNING *;

(Implemented as prisma.$queryRaw/$executeRaw; FOR UPDATE SKIP LOCKED is the standard Postgres queue idiom and needs no extra infrastructure. Claiming also transitions the Export to RENDERING.)

The claim additionally enforces machine availability (Section 7.10): outside a workstation's render window the server answers 204 even when jobs are queued — unless the machine has an active "Render Now" override (renderNowUntil) or the head-of-queue job is urgent priority (priority <= 20 and the machine's allowUrgentAnytime is true). Centralising this in the claim endpoint means workers poll dumbly and all scheduling policy lives in the server/UI.

E9 — Progress (renews lease; recommended every 10 s or every frame, whichever is coarser)

PATCH /api/ext/render/jobs/rj_01/progress
{ "machineId": "mac_01", "progress": 0.42, "currentFrame": 1051,
  "totalFrames": 120, "etaSeconds": 96, "logTail": "PROGRESS: 0:00:02:03 (51): ..." }
200 { "ok": true, "leaseExpiresAt": "2026-08-01T11:40:00Z", "cancelRequested": false }

E10 — Fail

POST /api/ext/render/jobs/rj_01/fail
{ "machineId": "mac_01", "stage": "RENDER", "exitCode": 9,
  "errorMessage": "aerender ERROR: layer source file missing",
  "logTail": "...", "retryable": true }
200 { "job": { "status": "FAILED" }, "export": { "status": "RENDER_FAILED" },
      "autoRequeued": false }

If retryable: true and attempt < maxAttempts, the server auto-creates the next attempt and returns "autoRequeued": true with the new job id. stage is one of RENDER | VALIDATION_RUNNER | PREVIEW | PACKAGE (a crash of the validation runner, as opposed to a failed validation check, which uses E12).

E12 — Validation report (one call carries the entire run)

POST /api/ext/render/jobs/rj_01/validation
{ "machineId": "mac_01",
  "overall": "FAIL",
  "sequence": { "fileCount": 119, "totalBytes": 5482196992, "checksum": "xxh64:9f2a..." },
  "checks": [
    { "checkName": "frame_count",     "status": "FAIL", "expected": "120", "actual": "119" },
    { "checkName": "missing_frames",  "status": "FAIL", "expected": "none", "actual": "1057",
      "details": { "missing": [1057] } },
    { "checkName": "resolution",      "status": "PASS", "expected": "3840x2160", "actual": "3840x2160" },
    { "checkName": "colourspace",     "status": "PASS", "expected": "ACES - ACEScg", "actual": "ACES - ACEScg" },
    { "checkName": "bit_depth",       "status": "PASS", "expected": "half", "actual": "half" }
  ] }
200 { "export": { "status": "VALIDATION_FAILED" } }

E13 — Finalize (after successful preview generation and artifact uploads)

POST /api/ext/render/jobs/rj_01/finalize
{ "machineId": "mac_01",
  "artifacts": { "deliveryMovPath": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004/mov/UNG_106_010_020_cmp_TT_v004.mov",
                 "previewMovKey": "videos/uuid-UNG_..._v004.mov",
                 "thumbnailKey": "image/uuid-UNG_..._v004_thumb.jpg",
                 "metadataKey":  "renders/uuid-UNG_..._v004_meta.json",
                 "logFileKey":   "renders/uuid-rj_01.log" },
  "renderStats": { "renderSeconds": 812, "previewSeconds": 44 } }
200 { "export": { "id": "exp_01", "status": "READY_FOR_QC" },
      "version": { "id": "ver_09", "versionNumber": 4, "isLatest": true } }

Server-side, finalize reuses the existing version-creation service (previous versions un-latested, new Version created with fileUrl/thumbnailUrl) but in pipeline mode: the Version is flagged not-client-visible and no task or shot status is mutated. Delivery QC runs on shots the client has usually already approved (Section 10) — a post-approval technical render must never resurface in the review flow or client portal. finalize also reports deliveryMovPath (the SAN path of the slate/burn-in delivery MOV) alongside the object-storage artifact keys.

E4 — QC

POST /api/ext/exports/exp_01/qc
{ "result": "PASS", "notes": "Grain matched, edges clean",
  "reviewerEmail": "jane@studio.com", "source": "AE_PANEL" }
200 { "export": { "status": "READY_FOR_DELIVERY" },
      "qcReview": { "id": "qc_01", "result": "PASS", "createdAt": "..." } }

QC FAIL additionally sets the comp task to CHANGES (existing enum), which drives the existing Shot REVISIONS derivation — reviewers see it in today's dashboards with no UI change.

E5 — QC queue

GET /api/ext/qc/queue?projectCode=UNG_S1&episode=106
200 { "items": [ { "exportId": "exp_01", "shotCode": "UNG_106_010_020",
        "versionString": "v004", "readySince": "2026-08-01T12:04:11Z",
        "outputDir": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004",
        "previewUrl": "/api/files/videos/uuid-....mov",
        "thumbnailUrl": "/api/files/image/uuid-...jpg" } ],
      "total": 1 }

E16 — Create delivery package

POST /api/deliveries      (NextAuth session)
{ "projectId": "cmp6l5...", "episode": "101",
  "exportIds": ["exp_01", "exp_02"],        // preselected from E18; server re-verifies
  "options": { "includeMovs": true, "date": "2026-08-05" } }
201 { "package": { "id": "dp_15", "packageNumber": 15, "name": "260805_Delivery",
        "episode": "101", "rootPath": "//SAN/DELIVERY/101/260805_Delivery",
        "status": "QUEUED", "itemCount": 18 },
      "buildJob": { "id": "rj_77", "type": "DELIVERY_BUILD" } }

Server verifies every export is READY_FOR_DELIVERY (else 422 listing offenders), snapshots Project.deliveryConfig into configSnapshot, creates items, and enqueues a DELIVERY_BUILD RenderJob that any worker can claim via E8.

E19 — Mark Delivered

POST /api/deliveries/dp_15/delivered
{ "note": "Uploaded to Content Hub 2026-08-05 16:20 by Chris" }
200 { "package": { "status": "DELIVERED", "deliveredAt": "...", "deliveredBy": "Chris" },
      "exportsUpdated": 18 }

7. Render Worker

7.1 Technology choice

Recommendation: .NET 8 Windows Service (worker-service template). Trade-offs considered:

Option Pros Cons
.NET 8 Windows Service (recommended) First-class Windows Service lifecycle, single self-contained exe, robust process management (System.Diagnostics.Process), easy MSI/sc.exe install, no runtime to install Different language than the Next.js codebase
Node.js + node-windows / NSSM Same language as server; could share TS types Fragile as a service; process supervision and single-file distribution weaker on Windows
Python + pywin32 Studio pipeline familiarity Packaging/service story weakest; dependency management on render nodes painful

The worker shares no code with the server anyway (it only speaks HTTP), so language uniformity buys little; operational robustness on Windows render nodes buys a lot. The API contract in Section 6 is the interface, which is why more machines can be added without any API change.

7.2 Internal architecture

flowchart TB
  subgraph Worker["RenderWorker service"]
    MAIN["Main loop"] --> REG["Registration (E6, startup)"]
    MAIN --> HB["Heartbeat timer (E7, 30 s)"]
    MAIN --> POLL["Claim poller (E8, 10 s, only when idle)"]
    POLL --> EXEC["Job executor (one job at a time)"]
    EXEC --> P1["Stage 1: aerender runner"]
    EXEC --> P2["Stage 2: validation engine (Section 8)"]
    EXEC --> P3["Stage 3: preview generator (Section 9)"]
    EXEC --> P4["Alt: delivery builder (Section 11)"]
    P1 --> REP["Reporter (E9-E13, retry with backoff, offline spool)"]
    P2 --> REP
    P3 --> REP
    P4 --> REP
    EXEC --> LOG["Rolling file logs %ProgramData%\\VFXReviewWorker\\logs"]
  end

One job at a time per machine by default (capabilities.maxConcurrentJobs: 1) — aerender saturates a box; concurrency is a config knob, not an architecture change.

7.3 aerender execution

aerender.exe -project "<aepPath>" -comp "<compName>"
  -s <frameStart> -e <frameEnd>
  -RStemplate "<renderSettingsTemplate>" -OMtemplate "<outputModuleTemplate>"
  -output "<outputDir>\<outputPattern>"
  -mp -continueOnMissingFootage false
  • stdout is parsed line-by-line: PROGRESS: ... (N) lines yield currentFrame; ETA = rolling average seconds/frame × frames remaining.
  • stderr and non-progress stdout accumulate into the rolling logTail (last 200 lines) and full log file.
  • Failure detection: non-zero exit code, aerender ERROR lines, or no progress line for stallTimeoutSeconds (default 600 — some frames legitimately take minutes) → kill process tree, report E10.
  • outputDir is created by the worker before launch; a pre-existing non-empty dir for the same attempt is cleared (it can only contain output from a previous crashed attempt of this same immutable version — new versions always get new dirs).

7.4 Job locking and lease renewal

  • Claiming (E8) is atomic server-side (FOR UPDATE SKIP LOCKED); a job can never be claimed twice.
  • Every progress report renews the lease (leaseSeconds, default 300).
  • A server-side reaper (see 7.7) requeues or fails jobs whose lease expired — this covers worker power loss, BSOD, and network partition with no worker cooperation.
  • Workers include machineId in every job-scoped call; the server rejects reports from a machine that doesn't hold the job's lease (409) — a resurrected zombie worker cannot corrupt a reassigned job.

7.5 Crash recovery (worker side)

On service start: register (E6), then reconcile: if a local state file (current-job.json, written on claim, deleted on finish) names a job, query E3; if the server reassigned or failed it, discard local state and clean the orphaned output dir. Never resume a partial aerender — re-render from frame 1 of the range (attempt integrity beats partial-resume complexity; typical shots are ≤ 200 frames).

7.6 Retries

Failure Retry behaviour
aerender non-zero exit, retryable: true Server auto-requeues up to maxAttempts (3); attempt N+1 is a fresh RenderJob
Deterministic errors (missing footage, missing comp) Worker sets retryable: false → RENDER_FAILED immediately; humans fix the comp
Validation FAIL Never auto-retried — re-rendering identical inputs reproduces the failure. Human retries (T9) after fixing cause
Preview failure PREVIEW_ONLY retry re-runs ffmpeg only (validated EXRs are fine)
Report HTTP failures Exponential backoff 1 s→60 s; reports spool to disk and replay in order — rendering continues during server downtime

7.7 Server-side reaper

A small periodic task (Next.js route invoked by the existing container's cron-less pattern — recommend a setInterval in instrumentation.ts (Next.js server startup hook), 60 s):

  1. RenderJob CLAIMED/RUNNING with leaseExpiresAt < now() → mark EXPIRED; if attempts remain → new QUEUED attempt (T5), else Export → RENDER_FAILED (T4).
  2. Machine.lastSeenAt older than 3× heartbeat → status OFFLINE.
  3. Prune WorkerHeartbeat older than 7 days.

This is the only "background job" the web app gains, and it is a single idempotent SQL sweep. (Mechanism confirmed as studio decision 18.1-Q8.)

7.8 Configuration

C:\ProgramData\VFXReviewWorker\config.json (machine-local, minimal — everything tunable lives in server SystemConfig and arrives via E6):

{
  "serverUrl": "https://review.twotalesvfx.com",
  "apiKey": "<API_SECRET_KEY or per-machine key>",
  "machineName": "RENDER-01",
  "aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
  "ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
  "oiiotoolPath": "C:\\pipeline\\bin\\oiiotool.exe",
  "pathMappings": [ { "from": "//SAN/", "to": "S:/" } ]
}

pathMappings translates manifest UNC paths to local drive mappings — manifests store canonical UNC paths; each machine maps them locally.

7.9 Logging

  • Rolling local logs (14 days) under %ProgramData%\VFXReviewWorker\logs.
  • Per-job full aerender log uploaded via E20 on finish/fail (RenderJob.logFileKey) so the web UI can show complete logs without filesystem access.

7.10 Workstation deployment, render windows and "Render Now"

Studio decision (18.1): the worker runs on the two artist workstations — no dedicated render nodes initially. That makes availability scheduling a first-class feature, not an afterthought:

Mode (Machine.availability.mode) Behaviour
ALWAYS Machine claims whenever idle (default when availability is null; suits any future dedicated node)
SCHEDULE Machine claims only inside configured windows, e.g. { "windows": [{ "days": ["mon","tue","wed","thu","fri"], "from": "19:00", "to": "08:00" }, { "days": ["sat","sun"], "from": "00:00", "to": "24:00" }] } — the after-hours auto-render case
MANUAL Machine never claims unless explicitly triggered

Overrides, in priority order:

  1. Render Now — a button on the Machine Monitoring page (and available to the artist locally via a small tray companion, optional) sets renderNowUntil = now() + N hours. The machine claims normally until it expires. This is the "urgent render during the day" path: the artist queues the export, then hits Render Now on their own machine.
  2. Urgent jobs — jobs queued with urgent priority (priority <= 20, settable from the panel via a "queue as urgent" checkbox or from the Render Queue page) may be claimed even outside windows on machines with allowUrgentAnytime: true.
  3. Machine.enabled = false beats everything (admin kill-switch, unchanged).

Enforcement is entirely server-side in E8 (workers keep polling on their normal cadence; polling is a no-op HTTP call). Practical workstation considerations:

  • The service runs under a studio account with SAN access and works whether or not an artist is logged in.
  • aerender competing with a working artist is prevented by the schedule, not by idle-detection heuristics — simple and predictable. If mid-day renders on the other artist's machine become routine, revisit with an idle-detection option then.
  • Window times are per-machine config values collected at Phase 2 rollout (18.2-C5); accepted defaults: weekdays 19:0008:00 + full weekends, Render Now = 4 h, urgent queueing for all users.

8. Validation Engine

Runs on the worker immediately after a successful render (Export = VALIDATING). Tools: oiiotool/OpenImageIO for EXR introspection (recommended over parsing with a hand-rolled EXR reader; ships as a static exe), plus plain directory scanning.

8.1 Checks (normative list)

checkName Method FAIL condition
file_count dir listing vs frameEnd-frameStart+1 count mismatch
missing_frames parse frame numbers from filenames, diff against expected range any gap
duplicate_frames same parse same frame number twice (e.g. differing padding)
first_frame / last_frame min/max parsed frame frameStart / frameEnd
filename_consistency every file matches outputPattern regex (prefix, padding, extension) any nonconforming file
resolution oiiotool on first+middle+last frame ≠ manifest width×height
bit_depth EXR channel types expected.bitDepth (half/float)
exr_type header: scanline vs tiled, compression expected.exrCompression (WARN if compression differs but readable; FAIL if not valid EXR)
alpha_channel channel list contains A presence ≠ expected.alpha
colourspace EXR metadata (chromaticities / ACES container flag / colorspace attr written by AE OCIO) expected.colorspace (working space is linear ACES2065-1 per 18.1-Q4); colorPipeline is required per project (18.1-Q12), so SKIPPED only on legacy projects predating pipeline enablement
timecode_consistency EXR timeCode attr across sampled frames non-monotonic, or first frame ≠ expected.timecodeStart when set
zero_byte / corrupt every file size > 0; oiiotool header-read on sampled frames (first, last, every Nth) any zero-byte or unreadable file

Full-sequence checks (count, gaps, names, sizes) run on every file; header checks sample first/middle/last + every 25th frame (fast, catches systemic issues; per-frame full decode is not worth minutes per shot — tunable via SystemConfig: validation.sampleEvery).

8.2 Semantics

  • 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.
  • 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).

9. Preview Generation

Runs on the worker after validation passes (Export = GENERATING_PREVIEW).

Studio decision (18.1): previews are generated with the existing AE slate/burn-in templates, not ffmpeg drawtext. The studio already maintains: a slate + overlay template (1-frame slate at the head of the shot, overlays/burn-ins throughout), connector functions that build preview comps and pull slate/overlay data from the API, and the per-project colour chain to match reference: linear ACES2065-1 → per-project log space → show LUT. The pipeline reuses all of it headlessly.

9.1 Stages

  1. Build + render preview comp (AE, headless). The worker launches AfterFX.com -noui -r vfxr_build_preview.jsx, passing a job-context JSON file (manifest + slate/overlay fields fetched from E3). The script is the connector's existing preview-comp code, packaged with the worker: it imports the validated EXR sequence, builds the preview comp from the studio template (head slate populated from API data, overlays/burn-ins throughout), applies the colour chain (linear → log → show LUT from colorPipeline config), queues it with the project's MOV output-module template, and renders the delivery-grade preview MOV to the SAN beside the EXRs (Export.deliveryMovPath). This is the "MOV LT Preview" that ships inside delivery packages (Section 11).
  2. Web transcode (ffmpeg). The delivery MOV is transcoded to a web-playable H.264 (1920-wide, CRF 18) and a thumbnail JPEG (frame at 25% duration, 960-wide), uploaded via E20 presign.
  3. Metadata JSON. Worker-composed (manifest + validation summary + render stats + checksums + tool/template versions), uploaded via E20.
Artifact Produced by Destination
Delivery MOV (slate + burn-ins, graded via show LUT, delivery codec from AE output template) AE headless render SAN → Export.deliveryMovPath; copied/linked into delivery packages
Web preview MOV (H.264) ffmpeg transcode of the delivery MOV object storage videos/Version.fileUrl
Thumbnail ffmpeg image/Version.thumbnailUrl + Export.thumbnailKey
Metadata JSON worker renders/Export.metadataKey

Because the web preview is a transcode of the AE-generated MOV, review media and delivery media are guaranteed to show identical slates, burn-ins, and colour — one source of truth.

9.2 Colour configuration

Project.deliveryConfig.colorPipeline (per 18.1-Q4/Q12) — required and unique per project; enabling the pipeline on a project without it is a validation error, and values must never be carried over from another show:

{ "workingSpace": "ACES2065-1",
  "logSpace": "<per project, e.g. ACEScct>",
  "showLut": "//SAN/luts/UNG/UNG_show_v02.cube" }

The AE template consumes these via the existing connector transform-loading functions; validation's colourspace check expects workingSpace on the EXRs.

9.3 Fallback engine

SystemConfig: preview.engine = "ae" | "ffmpeg" (default "ae"). The ffmpeg path (no slate, drawtext burn-ins, LUT applied via lut3d) exists only as an emergency fallback if headless AE proves unreliable on a machine — it produces clearly-marked non-delivery-grade previews ("FALLBACK PREVIEW" burn-in) and never populates deliveryMovPath, so a fallback preview can never leak into a delivery package.

Cost note: the AE preview pass roughly doubles machine time per export. This matches current studio practice (preview comps are rendered today) — it is now simply unattended.

Finalize (E13) then creates the Version row server-side (Section 6.4) in pipeline mode — visible internally, never client-shared, no task/shot status changes.


10. QC Workflow

10.0 Purpose (studio decision, 18.1-Q11)

This QC stage is technical/final-delivery QC on approved shots — the last human check before EXRs ship. The shot has usually already been client-approved through the existing review process; QC exists to catch what that process can't: mask slips, export glitches/artifacts, and technical faults (missing frames, incorrect metadata/timecodes, wrong format/resolution/colourspace). The automated validation (Section 8) covers the mechanical half; human QC covers the visual half. Consequently:

  • QC PASS means "ready to upload" and nothing else. It never shares anything to the client review platform, never changes approval state, never creates client-visible media.
  • Pipeline-created preview Versions are internal-only, permanently (T12).

10.1 Flow

  1. Export reaches READY_FOR_QC; it appears in the panel's QC queue (E5) and the web QC page.
  2. Artist opens the lightweight QC AE project; selecting a queue entry makes the panel import the EXR sequence from outputDir into the QC comp (panel already has import-renders machinery) — frame-accurate, full-res QC in AE, not the H.264 preview.
  3. Artist clicks QC Pass or QC Fail (optional notes) → E4.
  4. PASS → READY_FOR_DELIVERY. FAIL → QC_FAILED + comp task → CHANGES; the artist fixes and Queue Export creates the next version (old Export → SUPERSEDED).

10.2 Rules

  • QC is per-Export. A new Export always requires new QC.
  • Web users can also QC from the Export detail page (same service function, session auth) — QC is not locked to AE.
  • Self-QC is allowed (single-artist reality); the QCReview row records who, so a policy can be layered later without schema change.
  • No client-review side effects on PASS (Section 10.0). QC FAIL still sets the comp task to CHANGES — the shot genuinely needs rework, and the existing REVISIONS derivation correctly surfaces that on internal dashboards.

11. Delivery Builder

11.1 Flow

  1. Web UI: user picks project + episode → E18 shows e.g. "Episode 101 — Ready: 18 shots" with the exact Export versions.
  2. Build Netflix Delivery → E16 creates the package (QUEUED) + DELIVERY_BUILD job.
  3. A worker claims it (same E8 queue), and for each item: create folders per template, hard-link EXRs (fallback copy), copy/link MOV, verify per-file checksums against Export.checksum data, write manifest + report, then report completion (package READY, exports PACKAGED). Progress via E9 (items done / total).
  4. Human uploads the folder to Content Hub, clicks Mark Delivered (E19) → DELIVERED.

11.2 Naming configuration — Project.deliveryConfig

Default template encodes the studio's confirmed structure (18.1-Q6): /DELIVERY/{episode}/{YYMMDD}_Delivery/{shotCode}/ with EXR sequence and the slate/burn-in preview MOV together in each shot folder.

{
  "packageNameTemplate": "{yymmdd}_Delivery",
  "layout": {
    "packageDir": "{deliveryRoot}/{episode}/{packageName}",
    "shotDir":    "{packageDir}/{shotCode}",
    "reportFile": "{packageDir}/{packageName}_report.csv"
  },
  "deliveryRoot": "//SAN/DELIVERY",
  "includeMovs": true,
  "checksumAlgo": "xxh64",
  "reportFormats": ["csv", "json"],
  "colorPipeline": { "workingSpace": "ACES2065-1", "logSpace": "ACEScct", "showLut": "//SAN/luts/UNG/UNG_show_v02.cube" },
  "preview": { "engine": "ae", "movOutputTemplate": "VFXR_PREVIEW_MOV" }
}

Per shot folder the builder places the EXR sequence files and the delivery MOV (Export.deliveryMovPath) side by side — no exr//mov/ subfolders, matching current practice. {episode} renders as 101 (season.episode as used today); {yymmdd} from the build date (overridable in E16 options.date).

Tokens: {showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shotCode} {versionString} {packageNumber}. Null config → SystemConfig default template. The resolved config is snapshotted into DeliveryPackage.configSnapshot, so historic packages always show the rules they were built with, and future productions with different specs are a config change, not a code change.

Recommendation: hard link when deliveryRoot and render root share an NTFS volume; transparent fallback to copy otherwise (cross-volume links are impossible). Hard links make an 18-shot EXR package near-instant and ~zero extra disk; the risk (mutating a linked file changes both) is acceptable because rendered EXR versions are immutable by pipeline design — a version is never re-rendered in place (new version → new directory). Post-link verification: link target identity check; post-copy verification: size + xxHash against validation-time values. DeliveryItem.status → VERIFIED per item. Config override "copyMode": "always-copy" for productions that mandate physical separation.

11.4 Manifest and delivery report

  • Manifest (DeliveryPackage.manifest, also manifest.json inside the package): every file with relative path, bytes, checksum, source Export id, plus tool/pipeline versions.
  • Delivery report (CSV per reportFormats, uploaded via storage abstraction to deliveries/ and written into the folder): one row per shot — shotCode, versionString, frame range, frame count, resolution, colourspace, EXR count/bytes, MOV name, QC reviewer/date, checksums. This is the client-facing document; columns confirmed against the delivery spec before first real delivery (18.2-C2) and configurable via deliveryConfig.

11.5 Delivery history

The Delivery Packages page (Section 13) lists every package permanently: #15 · 101/260805_Delivery · Episode 101 · 18 shots · built 2026-08-05 · delivered 2026-08-05 by Chris, expandable to items → Export → full render/validation/QC history. Packages are never deleted; a mistaken package is CANCELLED (DRAFT/QUEUED/FAILED only) or superseded by building a new one.


12. AE Panel Changes

The panel keeps all existing capabilities (episode/shot discovery, overlays, picture-lock pull, manual queue buttons remain as escape hatches). New/changed behaviour:

12.1 Panel status header (always visible per selected shot)

Field Source
Shot existing /api/ext/shots/lookup
Current Version shot.shotVersion (lookup)
Current Status shot.status (lookup)
Latest Export + QC status new E2 exports/latest — e.g. v004 — READY_FOR_QC, v004 — RENDERING 42% (ETA 1:36 on RENDER-01), v003 — QC FAILED: "grain mismatch"

While an export is active the panel polls E2 every 15 s to live-update the header.

12.2 Buttons and their API calls

Button Behaviour API calls
Queue Export Save project (scripted), build manifest from lookup data + active comp, submit. No local rendering, no version click. Confirms: "Queued v004 on render farm." GET lookupPOST /api/ext/exports (E1)
Open Latest Export Import latest export's EXR sequence into the current project (existing import-renders code, pointed at E2's outputDir) E2, then local import
Load QC Queue Populate QC list (shot, version, ready-since, thumbnail); selection imports that render into the QC comp GET /api/ext/qc/queue (E5), E3
QC Pass One click; optional notes field beside it POST /api/ext/exports/{id}/qc {PASS} (E4)
QC Fail Notes strongly encouraged (panel nags if empty) E4 {FAIL}
Retry Export (shown only when latest export is in a *_FAILED state) Re-queue POST /api/ext/exports/{id}/retry (E14)

12.3 Removed from the artist's world

Increment Version button (server does it on E1 — panel may keep it hidden behind an "advanced" toggle for emergencies), Prep Delivery PowerShell scripts (replaced by Section 11), manual preview-comp/MP4 queueing for review purposes (worker generates the review MOV).


13. Web UI Changes

All new pages use existing NextAuth sessions, existing layout/navigation, and the internal mirrors of the APIs (E15E21). Suggested routes under /(dashboard):

Page Route Contents
Pipeline Dashboard /pipeline Cards: queued / rendering / failed / ready-for-QC / ready-for-delivery counts; machines online; last 24 h throughput; recent failures
Render Queue /pipeline/queue Live table (poll 5 s): shot, version, status, progress bar, ETA, machine, attempt; row actions Retry/Cancel/Bump priority; filters project/episode/status
Export detail /pipeline/exports/[id] State timeline (ExportEvent), manifest, per-attempt logs (logFileKey), validation table, preview player (existing player component), QC history, delivery membership
QC Queue /pipeline/qc READY_FOR_QC grid with thumbnails; preview player; Pass/Fail with notes (same service as E4)
Delivery Packages /pipeline/deliveries Episode picker → "Ready: N shots" → Build button; package list with status chips; detail = items, report download, Mark Delivered
Machine Monitoring /pipeline/machines Machine cards: status, current job, CPU/mem/disk sparkline (WorkerHeartbeat), AE/worker versions; Enable/Disable toggle
Validation Results inside Export detail + /pipeline/validation Filterable failed-check browser across exports (spot systemic issues, e.g. every shot failing colourspace)
Render History /pipeline/history All RenderJobs: durations, machines, success rate; per-machine and per-episode aggregates

Small touches to existing pages: Shot detail gains an "Exports" tab (list of Exports with status chips); the existing version player is untouched (previews are Versions).


14. Configuration Summary

14.1 New SystemConfig keys (defaults)

Key Default Meaning
render.pollSeconds 10 worker claim poll
render.heartbeatSeconds 30 heartbeat interval
render.leaseSeconds 300 job lease
render.maxAttempts 3 auto-retry ceiling
render.stallTimeoutSeconds 600 no-progress kill
validation.sampleEvery 25 header-check sampling stride
preview.maxWidth 1920 preview scale
delivery.defaultConfig JSON fallback deliveryConfig

14.2 New environment variables

None required server-side (deliberate — everything is SystemConfig or per-project). Worker config is file-based (7.8).


15. Security and Authentication

Surface Mechanism
Worker + panel endpoints (/api/ext/*) Existing shared API_SECRET_KEY bearer/x-api-key — confirmed studio decision (18.1-Q7): one key for now, all phases. Machine.apiKeyHash stays in the schema so per-machine keys are a config feature, not a migration, if the fleet grows
Web UI + internal APIs Existing NextAuth session; new pages respect existing role checks (delivery build + mark-delivered restricted to admin/coordinator roles per existing role model)
Attribution submittedByEmail/reviewerEmail resolved to User rows by email where possible; stored as free text otherwise (panel is API-key-authed, not user-authed — acceptable per 18.1-Q7)
Middleware /api/ext/ is already allow-listed in middleware with per-route auth in handlers; new routes follow the identical pattern
Path safety Server-generated output/delivery paths only from templates + validated tokens (shotCode regex, episode regex); worker refuses to delete/clear any directory outside configured render/delivery roots

16. Failure Handling and Operations Summary

Scenario Behaviour
Worker crash / power loss mid-render Lease expires → reaper requeues (T5) or fails after max attempts; orphan output cleaned by next attempt
Server down mid-render Worker spools reports to disk, keeps rendering, replays in order on reconnect
Two workers race for one job Impossible: FOR UPDATE SKIP LOCKED claim
Zombie worker reports on reassigned job Rejected: machineId ≠ lease holder
Artist queues while a render is active 409 unless force (which supersedes + cancels the active job)
aerender hangs silently stallTimeoutSeconds kill → fail/retry
Validation fails at 3 am Pipeline stops at VALIDATION_FAILED; exact check rows recorded; dashboard + (existing Slack webhook, optional Phase 3 nicety) surface it in the morning
Delivery volume out of space Build job fails with explicit error; package FAILED; retryable after cleanup
Machine misbehaving Admin toggles enabled=false → cannot claim; running job finishes or is cancelled

17. Implementation Plan

Five independently deployable phases. Each phase leaves the previous workflow fully functional; artists can fall back to today's manual flow at any point until Phase 5 completes.

17.1 Phase 1 — Render Queue (schema + queue API + panel Queue Export)

Aspect Content
Objectives Queue Export from panel creates Export/RenderJob; jobs visible in web UI; no worker yet (jobs sit QUEUED; a temporary "mark as done manually" admin action lets the studio keep using the old render path while the queue is validated)
DB Migrations: all Section 5 enums + Export, RenderJob, ExportEvent; Project.deliveryConfig column; @@unique([shotId, versionNumber])
API E1, E2, E3, E14, E21 (queue list), internal retry/cancel (E15); manifest Zod schema; service layer lib/render-pipeline/exports.ts
UI Render Queue page (basic table), Export detail (manifest + events), Shot detail Exports tab
Panel Queue Export button + status header (E2 polling); Increment Version hidden
Risks Version-increment race between panel-PATCH legacy path and E1 → mitigate: E1 increments transactionally and panel stops PATCHing when the new button is used; duplicate @@unique guards
Testing Unit: manifest validation, version increment transaction, state transitions incl. 409s. Integration: two concurrent E1 calls for one shot. Panel: queue against staging
Migration Purely additive migration; deploy = normal prisma migrate deploy in entrypoint; no data backfill

17.2 Phase 2 — Render Worker

Aspect Content
Objectives The two artist workstations render queued jobs end-to-end to "EXRs on disk + job COMPLETED"; after-hours windows + Render Now override working; multi-machine ready by construction
DB Machine (incl. availability, renderNowUntil), WorkerHeartbeat
API E6E11, E20; atomic claim SQL with availability gate (7.10); reaper task; internal machines API incl. Render Now toggle
UI Machine Monitoring page (status, current job, Render Now button, window display); live progress/ETA in Render Queue
Worker .NET service: registration, heartbeat, claim loop, aerender runner, progress parsing, lease renewal, crash recovery, spooled reporting, path mapping, installer + docs
Risks Worker shares hardware with working artists → mitigated by schedule windows + server-side claim gate; AE version drift between the two workstations silently changes renders → registration reports aeVersion, dashboard warns on mismatch; stdout format differences across AE versions → progress parser tolerant, integration-tested per AE release; SAN permissions for the service account
Testing Worker unit tests with recorded aerender transcripts (success, error, stall); kill -9 worker mid-render → verify reaper requeue; two workers + three jobs → no double claim; network-cut replay test; availability-window claim tests (in/out of window, Render Now, urgent priority)
Migration Additive migration; install worker on one workstation first, second after a clean week; render.maxAttempts=1 for first week (observe before auto-retrying)

17.3 Phase 3 — Validation

Aspect Content
Objectives Every render automatically validated; failures stop pipeline with exact reasons
DB ValidationResult
API E12; transitions T3→T7/T8 activated (Phase 2 interim: complete-render goes straight to a provisional READY_FOR_QC; Phase 3 inserts VALIDATING between)
UI Validation table on Export detail; Validation Results browser; failure badge in queue
Worker Validation engine (oiiotool integration, all Section 8.1 checks, checksums); oiiotool bundled with installer
Risks Colourspace/timecode metadata may be absent in AE-written EXRs → those checks SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (validation.overrides: {"exr_type": "warn"})
Testing Golden EXR fixture sets: complete, gapped, duplicate, wrong-res, wrong-depth, no-alpha, corrupt, zero-byte; checksum determinism; large-sequence perf (500 frames < 60 s)
Migration Additive; existing in-flight exports unaffected (transition map keys off manifest version)

17.4 Phase 4 — Preview + QC

Aspect Content
Objectives Automatic slate/burn-in delivery MOV + web preview + thumbnail + metadata; renders appear as internal-only Versions; QC queue live in panel and web
DB QCReview
API E13 (finalize incl. pipeline-mode Version creation via existing version service), E4, E5; QC internal routes
UI QC Queue page; QC history on Export detail
Worker Headless AE preview stage (AfterFX.com -noui -r driving the studio slate/overlay template + colour chain, Section 9.1), ffmpeg web transcode + thumbnail, artifact upload via presign; ffmpeg fallback engine
Panel Load QC Queue, QC Pass/Fail, Open Latest Export; preview-comp JSX refactored so panel and worker share one script source
Risks Headless AE (-noui) reliability on workstations is the phase's main unknown → prove it in week 1 with a spike before building around it (18.2-C4); preview colour must match reference and be supervisor-signed-off before artists trust it; pipeline-mode Version creation must not regress the manual upload path and must never leak client-visible media on approved shots → shared function + explicit regression tests for both
Testing Preview parity: pipeline MOV vs artist-rendered preview of same shot (slate fields, burn-ins, colour) signed off by supervisor; Version integration: pipeline Version invisible in client portal on a shared shot; QC transition tests incl. task→CHANGES and no-side-effect PASS
Migration Additive; announce to artists that preview comps/MOVs are now automatic

17.5 Phase 5 — Delivery Builder

Aspect Content
Objectives One-click dated Netflix package from DB; permanent delivery history; old PowerShell prep retired
DB DeliveryPackage, DeliveryItem
API E16E19; DELIVERY_BUILD job type through existing claim path
UI Delivery Packages page (episode picker, ready counts, build, history, mark delivered); Pipeline Dashboard completed
Worker Delivery builder stage: template resolution, hard-link/copy, verification, manifest + report writing
Risks Folder structure and checksum approach are decided (18.1-Q6/Q13); report columns still need confirming against the delivery spec before first real delivery (18.2-C2); hard links on non-NTFS delivery volume → auto-fallback copy is default-safe
Testing Package build fixture (3 shots) → structure, links vs copies, checksum verification, report content; immutability: re-render a packaged shot → package untouched, new Export not auto-included; permission tests on build/mark-delivered roles
Migration Additive; run one parallel delivery (old script + new builder) and diff the folders before cutover

17.6 Dependency graph

flowchart LR
  P1["Phase 1 Queue"] --> P2["Phase 2 Worker"] --> P3["Phase 3 Validation"] --> P4["Phase 4 Preview+QC"] --> P5["Phase 5 Delivery"]

Strictly sequential; each phase is shippable and useful on its own (P1: visibility; P2: hands-off rendering; P3: trust; P4: hands-off review; P5: hands-off delivery).


18. Design Decisions — Resolved and Remaining

18.1 Resolved decisions (studio input, 2026-07-31)

The original open questions were answered by the studio. The decisions below are normative and already folded into the sections referenced.

Q Topic Decision Where applied
Q1 Render fleet aerender runs on the two artist workstations. After-hours auto-render via per-machine schedule windows; manual "Render Now" trigger for urgent daytime renders; urgent-priority jobs may claim anytime 7.10, 5.6 (Machine.availability, renderNowUntil), E8 gate
Q2 EXR storage EXRs are never uploaded to object storage; SAN only 2.3
Q3 Export concurrency Single active Export per shot (E1 409 + force supersede) 6.4 E1
Q4 Colour management Per-project colorPipeline config. ACES workflows: plates/renders linear ACES2065-1; reference match = linear → per-project log → show LUT 9.2, 8.1 colourspace check
Q5 Burn-ins / slates Use the existing AE slate + overlay templates: 1-frame head slate, burn-ins throughout, connector functions pull slate/overlay data from the API and load the colour transforms. Worker runs this headlessly; ffmpeg is emergency fallback only 9 (rewritten)
Q6 Delivery structure /DELIVERY/{episode}/{YYMMDD}_Delivery/{shotCode}/ containing EXR sequence + slate/burn-in preview MOV together 11.2 default config
Q7 Auth One shared API_SECRET_KEY for now; Machine.apiKeyHash remains as dormant schema support 15
Q8 Reaper As recommended: instrumentation.ts interval + defensive sweep on each E8 claim 7.7
Q9 Outputs per shot One comp → one EXR sequence per version; future ExportOutput child table is an additive migration if ever needed 6.2
Q10 Retention As recommended: pipeline never auto-deletes; "reclaimable space" report; humans delete 16
Q11 QC semantics QC is technical/final-delivery QC on approved shots (mask slips, export glitches, missing frames, metadata/timecode, format/res/colourspace). PASS = ready to upload, nothing shared to client review; pipeline Versions are internal-only 10.0, T12/T14, 6.4 E13
Q12 Per-project colour values colorPipeline (log space + show LUT) is required and unique per project — set at project setup, supervisor signs off one reference comparison before Phase 4 goes live on that project 9.2
Q13 Delivery report / checksums Dual support as recommended: xxh64 for internal verification, md5 available for the client-facing manifest via checksumAlgo; report columns confirmed against the delivery spec before the first real delivery (18.2-C2) 11.4
Q14 Preview MOV codec Lives in the AE output-module template; record the current template name in deliveryConfig.preview.movOutputTemplate at Phase 4 setup (18.2-C3) 9.1, 11.2
Q15 Headless AE approach As recommended: AfterFX.com -noui with the week-1 Phase 4 spike; documented fallbacks stand if the spike fails (18.2-C4) 9.1, 17.4
Q16 Render windows / urgent policy Defaults accepted: weekdays 19:0008:00 + full weekends, Render Now = 4 h, urgent queueing available to all users; exact per-machine times collected at Phase 2 rollout (18.2-C5) 7.10

18.2 Implementation-time checklist

No design decisions remain. These are concrete values to collect or verifications to run at the noted point — each traces back to a resolved decision in 18.1.

# Item When What to do
C1 Per-project colour values Pipeline enablement per project (first: Phase 4 setup) Enter the project's log space and show LUT path into deliveryConfig.colorPipelinerequired, unique per project (Q12); supervisor signs off one pipeline-vs-reference comparison before artists rely on previews
C2 Delivery report columns + client checksum requirement Before the first real Phase 5 delivery Confirm report columns and whether the client mandates MD5 sidecars against the production's delivery spec; set checksumAlgo accordingly (Q13)
C3 Preview MOV output template Phase 4 setup Record the studio's current "LT Preview" AE output-module template name in deliveryConfig.preview.movOutputTemplate (Q14)
C4 Headless AE spike Week 1 of Phase 4, before further preview code Render one preview via AfterFX.com -noui -r under the service account on both workstations, after hours, artist logged out. If it fails: fall back to panel-pre-built preview comp in the artist's AEP (plain aerender second comp), then ffmpeg engine as last resort (Q15)
C5 Render windows + Render Now duration Phase 2 rollout Confirm per-machine window times against the accepted defaults (weekdays 19:0008:00 + weekends, Render Now 4 h, urgent for all users) and enter into Machine.availability (Q16)

19. Document Map for Implementers

Section Governs Primary phase
4 All state transitions (server-enforced) 1
5 Schema migrations 1, 2, 3, 4, 5
6 API contracts + examples 1, 2, 3, 4, 5
7 Worker service 2
8 Validation checks 3
9 Preview artifacts 4
10 QC semantics 4
11 Delivery builder 5
12 AE panel 1, 4
13 Web UI 15
17 Sequencing, testing, migration all
18.1 Resolved studio decisions (normative) all
18.2 Implementation-time checklist (values/verifications) as noted

End of specification.