diff --git a/RenderPipeline2.md b/RenderPipeline2.md new file mode 100644 index 0000000..9c97af4 --- /dev/null +++ b/RenderPipeline2.md @@ -0,0 +1,1380 @@ +# 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 + +```mermaid +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 + +```mermaid +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 + +```mermaid +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 + +```prisma +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. + +```prisma +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. + +```prisma +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. + +```prisma +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 + +```prisma +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 + +```prisma +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 + +```prisma +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) + +```prisma +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) + +```mermaid +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 ` / `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. + +```json +{ + "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** + +```http +POST /api/ext/exports +Authorization: Bearer +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. + +```json +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** + +```http +GET /api/ext/exports/latest?shotCode=UNG_106_010_020&projectCode=UNG_S1 +``` + +```json +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`) + +```http +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" } } } +``` + +```json +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) + +```http +POST /api/ext/workers/mac_01/heartbeat +{ "cpuPercent": 82.5, "memPercent": 61.0, "diskFreeGb": 512.3, "currentJobId": "rj_01" } +``` + +```json +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) + +```http +POST /api/ext/render/jobs/claim +{ "machineId": "mac_01", "types": ["AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD"] } +``` + +```json +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: + +```sql +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) + +```http +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): ..." } +``` + +```json +200 { "ok": true, "leaseExpiresAt": "2026-08-01T11:40:00Z", "cancelRequested": false } +``` + +**E10 — Fail** + +```http +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 } +``` + +```json +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) + +```http +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" } + ] } +``` + +```json +200 { "export": { "status": "VALIDATION_FAILED" } } +``` + +**E13 — Finalize** (after successful preview generation and artifact uploads) + +```http +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 } } +``` + +```json +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** + +```http +POST /api/ext/exports/exp_01/qc +{ "result": "PASS", "notes": "Grain matched, edges clean", + "reviewerEmail": "jane@studio.com", "source": "AE_PANEL" } +``` + +```json +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** + +```http +GET /api/ext/qc/queue?projectCode=UNG_S1&episode=106 +``` + +```json +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** + +```http +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" } } +``` + +```json +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** + +```http +POST /api/deliveries/dp_15/delivered +{ "note": "Uploaded to Content Hub 2026-08-05 16:20 by Chris" } +``` + +```json +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 + +```mermaid +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 "" -comp "" + -s -e + -RStemplate "" -OMtemplate "" + -output "\" + -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): + +```json +{ + "serverUrl": "https://review.twotalesvfx.com", + "apiKey": "", + "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:00–08: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: + +```json +{ "workingSpace": "ACES2065-1", + "logSpace": "", + "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. + +```json +{ + "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. + +### 11.3 Hard links vs copying + +**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 lookup` → `POST /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 (E15–E21). 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 | E6–E11, 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 | E16–E19; 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 + +```mermaid +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:00–08: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.colorPipeline` — **required, 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:00–08: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 | 1–5 | +| 17 | Sequencing, testing, migration | all | +| 18.1 | Resolved studio decisions (normative) | all | +| 18.2 | Implementation-time checklist (values/verifications) | as noted | + +End of specification. diff --git a/RenderWorker/.gitignore b/RenderWorker/.gitignore new file mode 100644 index 0000000..4d4cd45 --- /dev/null +++ b/RenderWorker/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +publish/ +*.user diff --git a/RenderWorker/README.md b/RenderWorker/README.md new file mode 100644 index 0000000..fbc213c --- /dev/null +++ b/RenderWorker/README.md @@ -0,0 +1,137 @@ +# VFXReview RenderWorker + +Windows service that drives `aerender.exe` on the artist workstations (RenderPipeline2 §7). +It talks **only HTTP** to the VFXReview server (`/api/ext/*`, API-key auth) — it never +gets a database connection. All scheduling policy (render windows, Render Now, +urgent priority) is enforced server-side in the claim endpoint; the worker polls dumbly. + +## One click → EXR + MOV + MP4 + +A single **Queue Export** click produces all three deliverables. The server +chains two jobs: + +1. **AE_RENDER** — `aerender` writes the clean EXR sequence (no overlay, no LUT). +2. **PREVIEW_ONLY** — created automatically when the render completes. The + worker runs `AfterFX.com -noui -r scripts/vfxr_build_preview.jsx`, which opens + the studio slate/overlay **template** AEP, imports the rendered EXRs, rebuilds + the shot around them (OCIO → `_SHOW LUT` → `UNG_VFX_OVERLAY`), duplicates + `UNG_EXPORT_TEMPLATE` into a preview comp with the slate filled in, queues the + MOV (`4444 Tri`) and MP4 (`REVIEW_PREVIEW`) output modules, and **saves a + throwaway AEP**. The worker then runs `aerender -project ` with no + `-comp`, rendering both outputs in one launch. + +The MOV and MP4 land beside the EXRs. The MP4 is uploaded and registered as an +ordinary `Version` — internal-only, never client-visible, and it changes no task +or shot status (§10.0). Preview settings live in `SystemConfig` under +`preview.*`, so template names and paths are changed without redeploying. + +If the preview stage fails, only it is retried — the validated EXRs are never +re-rendered. The temp AEP is kept on failure so you can open it and see exactly +what the farm built. + +### Proving headless AE first + +Headless AE is the one real unknown, so prove it before relying on it +(spec 18.2-C4). **Close After Effects**, then: + +```powershell +.\scripts\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\...\v002" -ShotCode "UNG_111_001_030" -Version "v002" +``` + +It runs the exact build the worker runs and prints the result plus any warnings +(missing LUT comp, missing slate layer, …), then gives you the `aerender` command +to render what it built. If it reports no result file, AE cannot script headlessly +under that account — fall back to having the panel pre-build the preview comp in +the artist's AEP (then it is pure `aerender`), or the ffmpeg engine. + +## What it does (Phase 2 scope) + +- Registers on startup (E6) and receives server-supplied tuning (poll/heartbeat/lease/stall). +- Heartbeats every 30 s (E7) — the heartbeat response is also the cancel channel. +- Claims one `AE_RENDER` job at a time (E8), runs `aerender.exe` with the manifest's + comp/frame-range/templates, parses `PROGRESS:` lines, reports progress + ETA (E9, + renews the lease). +- Stall watchdog: no progress for `stallTimeoutSeconds` (default 600) → kill process tree, retryable fail. +- Deterministic errors (missing footage / missing comp / unopenable project) → non-retryable fail (E10); + transient errors auto-requeue server-side up to `maxAttempts`. +- On success: uploads the full aerender log via presign (E20), reports complete (E11). +- Crash recovery: `current-job.json` written on claim; on restart the worker asks the server + what became of the job and reports a retryable fail if it was still ours. Partial renders + are never resumed — the next attempt clears its own output files and re-renders. +- Durable reporting: complete/fail reports spool to disk and replay in order with backoff — + rendering continues while the server is down. + +Preview generation (Phase 4) and validation (Phase 3) plug into this same service later. + +## Build + +Requires the .NET 8+ SDK. + +```bash +cd RenderWorker/VFXReviewWorker +dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o publish +``` + +Produces a single `publish/VFXReviewWorker.exe` — no runtime install needed on render nodes. + +## Configure + +Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8): + +```json +{ + "serverUrl": "https://review.twotalesvfx.com", + "apiKey": "", + "machineName": "RENDER-01", + "aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe", + "aeVersion": "24.3", + "ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe", + "pathMappings": [ + { "from": "//SAN/", "to": "S:/" } + ] +} +``` + +`pathMappings` translate the manifest's canonical UNC paths to this machine's drive +mappings. Everything tunable (poll interval, lease, stall timeout, max attempts) lives in +the server's SystemConfig and arrives at registration — no per-machine tuning files. + +## Run interactively (first-time smoke test) + +```bash +VFXReviewWorker.exe +``` + +Logs go to the console-less service log at `%ProgramData%\VFXReviewWorker\logs\worker_YYYYMMDD.log` +(14-day rolling). Confirm the machine appears on the web **Pipeline → Machines** page, then stop it. + +## Install as a Windows service + +Run as a studio account with SAN access (works whether or not an artist is logged in): + +```powershell +sc.exe create VFXReviewRenderWorker binPath= "C:\pipeline\VFXReviewWorker\VFXReviewWorker.exe" start= auto obj= "STUDIO\svc-render" password= "" +sc.exe description VFXReviewRenderWorker "VFXReview render pipeline worker (aerender)" +sc.exe start VFXReviewRenderWorker +``` + +Uninstall: `sc.exe stop VFXReviewRenderWorker && sc.exe delete VFXReviewRenderWorker`. + +## Rollout notes (spec §17.2) + +- Install on **one** workstation first; add the second after a clean week. +- Set `render.maxAttempts = 1` in SystemConfig for the first week (observe before auto-retrying). +- Enter each machine's render windows on the Machines page (`availability`), defaults: + weekdays 19:00–08:00 + full weekends; Render Now override = 4 h. +- The dashboard warns when the two workstations report different AE versions. + +## Tests + +```bash +cd RenderWorker +dotnet test +``` + +Covers the progress parser against recorded aerender transcript lines (success, error, +non-retryable), ETA math, path mapping, log-tail ring buffer, and the output-dir +clearing guard (only files matching the job's own pattern prefix are ever deleted). diff --git a/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs b/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs new file mode 100644 index 0000000..1b3d33f --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs @@ -0,0 +1,87 @@ +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +public class PathMapperTests +{ + private static PathMapper Mapper(params (string from, string to)[] maps) => + new(maps.Select(m => new PathMapping { From = m.from, To = m.to })); + + [Fact] + public void MapsUncToDriveLetter() + { + var mapper = Mapper(("//SAN/", "S:/")); + Assert.Equal(@"S:\renders\UNG_S1\106\shot\v004", mapper.Map("//SAN/renders/UNG_S1/106/shot/v004")); + } + + [Fact] + public void MapsBackslashInputToo() + { + var mapper = Mapper(("//SAN/", "S:/")); + Assert.Equal(@"S:\projects\a.aep", mapper.Map(@"\\SAN\projects\a.aep")); + } + + [Fact] + public void CaseInsensitiveMatch() + { + var mapper = Mapper(("//SAN/", "S:/")); + Assert.Equal(@"S:\x", mapper.Map("//san/x")); + } + + [Fact] + public void FirstMatchingMappingWins() + { + var mapper = Mapper(("//SAN/renders/", "R:/"), ("//SAN/", "S:/")); + Assert.Equal(@"R:\a", mapper.Map("//SAN/renders/a")); + Assert.Equal(@"S:\projects\a", mapper.Map("//SAN/projects/a")); + } + + [Fact] + public void UnmappedPathPassesThroughWithLocalSeparators() + { + var mapper = Mapper(("//SAN/", "S:/")); + Assert.Equal(@"D:\local\thing", mapper.Map("D:/local/thing")); + } +} + +public class PrepareOutputDirTests +{ + [Fact] + public void ClearsOnlyFilesMatchingPatternPrefix() + { + var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old"); + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1002.exr"), "old"); + File.WriteAllText(Path.Combine(dir, "unrelated-notes.txt"), "keep me"); + + AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr"); + + Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"))); + Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1002.exr"))); + Assert.True(File.Exists(Path.Combine(dir, "unrelated-notes.txt"))); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void CreatesMissingDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"), "v001"); + try + { + AerenderRunner.PrepareOutputDir(dir, "X.[####].exr"); + Assert.True(Directory.Exists(dir)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dir)!, recursive: true); + } + } +} diff --git a/RenderWorker/VFXReviewWorker.Tests/ProgressParserTests.cs b/RenderWorker/VFXReviewWorker.Tests/ProgressParserTests.cs new file mode 100644 index 0000000..2ad8a5f --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/ProgressParserTests.cs @@ -0,0 +1,120 @@ +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +/// +/// Recorded-transcript tests (§17.2): lines below are representative aerender +/// stdout from AE 2024/2025/2026 successes, errors, and stalls. +/// +public class ProgressParserTests +{ + [Theory] + [InlineData("PROGRESS: 0:00:02:03 (51): 0 Seconds", 51)] + [InlineData("PROGRESS: 2 (2): 1 Seconds", 2)] + [InlineData("PROGRESS: 0:00:00:00 (1): 12 Seconds", 1)] + [InlineData("PROGRESS: 0:00:04:23 (120): 3 Seconds", 120)] + public void ParsesProgressFrames(string line, int expected) + { + Assert.Equal(expected, ProgressParser.ParseFrame(line)); + } + + [Theory] + [InlineData("PROGRESS: Rendering frame data")] + [InlineData("Starting composition \"UNG_106_010_020_cmp\".")] + [InlineData("aerender version 24.3x52")] + [InlineData("")] + public void IgnoresNonFrameLines(string line) + { + Assert.Null(ProgressParser.ParseFrame(line)); + } + + [Theory] + [InlineData("aerender ERROR: An existing connection was forcibly closed")] + [InlineData("aerender ERROR -1610153464: After Effects error")] + [InlineData("ERROR: Unable to open project")] + [InlineData("aerender SYNTAX ERROR: Illegal argument flag: false")] + public void DetectsErrorLines(string line) + { + Assert.True(ProgressParser.IsErrorLine(line)); + } + + [Fact] + public void ProgressLineIsNotAnError() + { + Assert.False(ProgressParser.IsErrorLine("PROGRESS: 0:00:02:03 (51): 0 Seconds")); + } + + [Theory] + [InlineData("aerender ERROR: After Effects error: layer source file is missing or inaccessible")] + [InlineData("aerender ERROR: No comp was found with the given name.")] + [InlineData("aerender ERROR: project file could not be opened")] + [InlineData("WARNING: Missing footage in comp")] + [InlineData("aerender SYNTAX ERROR: Illegal argument flag: false")] + public void DeterministicErrorsAreNonRetryable(string line) + { + Assert.True(ProgressParser.IsNonRetryableError(line)); + } + + [Theory] + [InlineData("aerender ERROR: An existing connection was forcibly closed")] + [InlineData("aerender ERROR -1610153464: After Effects crashed")] + public void TransientErrorsStayRetryable(string line) + { + Assert.False(ProgressParser.IsNonRetryableError(line)); + } +} + +public class EtaCalculatorTests +{ + [Fact] + public void NoFramesMeansNoEstimate() + { + var eta = new EtaCalculator(); + Assert.Null(eta.EstimateSeconds(100)); + } + + [Fact] + public void EstimatesFromRollingAverage() + { + var eta = new EtaCalculator(); + var t = DateTimeOffset.UtcNow; + eta.RecordFrame(t); + eta.RecordFrame(t.AddSeconds(2)); + eta.RecordFrame(t.AddSeconds(4)); // 2 s/frame average + Assert.Equal(20, eta.EstimateSeconds(10)); + } + + [Fact] + public void ZeroRemainingMeansNoEstimate() + { + var eta = new EtaCalculator(); + var t = DateTimeOffset.UtcNow; + eta.RecordFrame(t); + eta.RecordFrame(t.AddSeconds(1)); + Assert.Null(eta.EstimateSeconds(0)); + } + + [Fact] + public void RollingWindowDropsOldFrames() + { + var eta = new EtaCalculator(windowSize: 2); + var t = DateTimeOffset.UtcNow; + eta.RecordFrame(t); + eta.RecordFrame(t.AddSeconds(100)); // old slow frame, will roll out + eta.RecordFrame(t.AddSeconds(101)); + eta.RecordFrame(t.AddSeconds(102)); // window now [1, 1] + Assert.Equal(10, eta.EstimateSeconds(10)); + } +} + +public class LogTailTests +{ + [Fact] + public void KeepsOnlyLastNLines() + { + var tail = new LogTail(3); + for (var i = 1; i <= 5; i++) tail.Add($"line {i}"); + Assert.Equal("line 3\nline 4\nline 5", tail.ToString()); + } +} diff --git a/RenderWorker/VFXReviewWorker.Tests/VFXReviewWorker.Tests.csproj b/RenderWorker/VFXReviewWorker.Tests/VFXReviewWorker.Tests.csproj new file mode 100644 index 0000000..7834d37 --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/VFXReviewWorker.Tests.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/RenderWorker/VFXReviewWorker/AeSession.cs b/RenderWorker/VFXReviewWorker/AeSession.cs new file mode 100644 index 0000000..5537b19 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/AeSession.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; + +namespace VFXReviewWorker; + +/// +/// Detects an *interactive* After Effects session on this machine. +/// +/// This matters because `AfterFX.com -noui -r script.jsx` is handed to an +/// already-running After Effects instance rather than starting an isolated +/// one — so running the preview build while an artist has AE open would take +/// over their session and quit it, losing unsaved work. +/// +/// Deliberately conservative: this matches any After Effects process, the +/// interactive `AfterFX.exe` as well as the `AfterFX.com` render engine that +/// aerender drives. Waiting for either to finish also avoids two AE instances +/// competing for the same box, and the worst case is only that a preview build +/// starts a little later. +/// +public static class AeSession +{ + public static bool InteractiveRunning() + { + try + { + return Process.GetProcessesByName("AfterFX").Length > 0; + } + catch + { + // If we cannot tell, assume an artist is working — never risk their session. + return true; + } + } +} diff --git a/RenderWorker/VFXReviewWorker/AerenderRunner.cs b/RenderWorker/VFXReviewWorker/AerenderRunner.cs new file mode 100644 index 0000000..a748288 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/AerenderRunner.cs @@ -0,0 +1,298 @@ +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +public sealed record RenderResult( + bool Success, + int ExitCode, + bool Retryable, + string? ErrorMessage, + string LogTail, + string FullLogPath, + double RenderSeconds); + +/// +/// Stage 1: aerender execution (§7.3). Launches aerender.exe, parses stdout +/// for PROGRESS/ERROR lines, reports progress (renewing the lease), enforces +/// the stall timeout, and honours cancellation by killing the process tree. +/// +public sealed class AerenderRunner +{ + private readonly WorkerOptions _options; + private readonly ApiClient _api; + private readonly ILogger _log; + + public AerenderRunner(WorkerOptions options, ApiClient api, ILogger log) + { + _options = options; + _api = api; + _log = log; + } + + /// + /// Renders every queued item in an already-prepared project (no -comp), used + /// by the preview stage: the build script saved an AEP with the MOV and MP4 + /// output modules already queued, so one launch produces both. + /// + public Task RunProjectAsync( + string jobId, + string machineId, + string projectPathLocal, + int totalFrames, + ServerConfig config, + CancellationToken cancelJob, + CancellationToken shutdown) + { + var args = new List { "-project", projectPathLocal, "-mp" }; + _log.LogInformation("Rendering prepared project for job {JobId}: {Project}", jobId, projectPathLocal); + return ExecuteAsync(jobId, machineId, args, totalFrames, 0, config, $"aerender_{jobId}_preview.log", cancelJob, shutdown); + } + + public async Task RunAsync( + ClaimedJob job, + RenderManifest m, + string machineId, + string outputDirLocal, + ServerConfig config, + CancellationToken cancelJob, + CancellationToken shutdown) + { + var mapper = new PathMapper(_options.PathMappings); + var aepLocal = mapper.Map(m.AepPath); + var outputArg = Path.Combine(outputDirLocal, m.OutputPattern); + + PrepareOutputDir(outputDirLocal, m.OutputPattern); + + var args = new List + { + "-project", aepLocal, + "-comp", m.CompName, + "-s", m.FrameStart.ToString(), + "-e", m.FrameEnd.ToString(), + }; + if (!string.IsNullOrEmpty(m.RenderSettingsTemplate)) { args.Add("-RStemplate"); args.Add(m.RenderSettingsTemplate); } + if (!string.IsNullOrEmpty(m.OutputModuleTemplate)) { args.Add("-OMtemplate"); args.Add(m.OutputModuleTemplate); } + args.Add("-output"); args.Add(outputArg); + // NB: no "-continueOnMissingFootage" — the real flag takes no value + // (passing "false" is an aerender SYNTAX ERROR), and its *presence* + // enables skipping missing footage. aerender's default is to stop on + // missing footage, which is exactly what the pipeline wants. + + _log.LogInformation("Launching aerender for job {JobId}: {Aep} comp \"{Comp}\" frames {S}-{E} → {Out}", + job.Id, aepLocal, m.CompName, m.FrameStart, m.FrameEnd, outputArg); + + return await ExecuteAsync(job.Id, machineId, args, m.TotalFrames, m.FrameStart, config, + $"aerender_{job.Id}.log", cancelJob, shutdown); + } + + private async Task ExecuteAsync( + string jobId, + string machineId, + List args, + int totalFrames, + int frameStart, + ServerConfig config, + string logFileName, + CancellationToken cancelJob, + CancellationToken shutdown) + { + var fullLogPath = Path.Combine(WorkerOptions.LogsDir, logFileName); + Directory.CreateDirectory(WorkerOptions.LogsDir); + + var psi = new ProcessStartInfo + { + FileName = _options.AerenderPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + var tail = new LogTail(200); + var eta = new EtaCalculator(); + var sw = Stopwatch.StartNew(); + var lastProgressAt = DateTimeOffset.UtcNow; + var lastReportAt = DateTimeOffset.MinValue; + int framesDone = 0; + string? firstError = null; + bool nonRetryable = false; + bool cancelledByServer = false; + + using var process = new Process { StartInfo = psi }; + await using var fullLog = new StreamWriter(fullLogPath, append: false, Encoding.UTF8); + var logLock = new object(); + + void HandleLine(string? line, bool isStderr) + { + if (line is null) return; + lock (logLock) + { + fullLog.WriteLine(line); + tail.Add(line); + } + var frame = ProgressParser.ParseFrame(line); + if (frame is not null) + { + framesDone = Math.Max(framesDone, frame.Value); + lastProgressAt = DateTimeOffset.UtcNow; + eta.RecordFrame(lastProgressAt); + } + if ((isStderr || ProgressParser.IsErrorLine(line)) && !string.IsNullOrWhiteSpace(line)) + { + if (ProgressParser.IsErrorLine(line)) firstError ??= line.Trim(); + if (ProgressParser.IsNonRetryableError(line)) nonRetryable = true; + } + } + + process.OutputDataReceived += (_, e) => HandleLine(e.Data, isStderr: false); + process.ErrorDataReceived += (_, e) => HandleLine(e.Data, isStderr: true); + + try + { + process.Start(); + } + catch (Exception ex) + { + return new RenderResult(false, -1, Retryable: false, + $"Failed to launch aerender at {_options.AerenderPath}: {ex.Message}", + tail.ToString(), fullLogPath, 0); + } + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + var total = totalFrames; + while (!process.HasExited) + { + await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None); + + if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested) + { + _log.LogWarning("Cancellation requested — killing aerender tree for job {JobId}", jobId); + KillTree(process); + cancelledByServer = cancelJob.IsCancellationRequested; + break; + } + + // Stall watchdog (§7.3): no progress line for stallTimeoutSeconds → kill + if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds) + { + _log.LogError("aerender stalled ({Sec}s without progress) — killing job {JobId}", config.StallTimeoutSeconds, jobId); + KillTree(process); + await WaitForExitSafe(process); + return new RenderResult(false, -2, Retryable: true, + $"aerender produced no progress for {config.StallTimeoutSeconds}s (stall timeout)", + tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds); + } + + // Progress report every ~10 s (renews lease); best-effort + if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10) + { + lastReportAt = DateTimeOffset.UtcNow; + var progress = total > 0 ? Math.Min(1.0, (double)framesDone / total) : 0; + try + { + var resp = await _api.ProgressAsync(jobId, machineId, progress, + framesDone > 0 ? frameStart + framesDone - 1 : null, + total, eta.EstimateSeconds(total - framesDone), tail.ToString(), shutdown); + if (resp?.CancelRequested == true) + { + _log.LogWarning("Server requested cancel for job {JobId}", jobId); + KillTree(process); + cancelledByServer = true; + break; + } + } + catch (Exception ex) + { + _log.LogDebug(ex, "Progress report failed (non-fatal; lease covered by reaper)"); + } + } + } + + await WaitForExitSafe(process); + sw.Stop(); + lock (logLock) { fullLog.Flush(); } + + if (cancelledByServer) + { + return new RenderResult(false, -3, Retryable: false, "Cancelled", tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds); + } + + var exitCode = process.ExitCode; + if (exitCode == 0 && firstError is null) + { + return new RenderResult(true, 0, true, null, tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds); + } + return new RenderResult(false, exitCode, Retryable: !nonRetryable, + firstError ?? $"aerender exited with code {exitCode}", + tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds); + } + + /// + /// §7.3: outputDir is created before launch; a pre-existing non-empty dir + /// can only hold output from a previous crashed attempt of this same + /// immutable version. Safety guard: only files matching the job's own + /// output pattern prefix are ever deleted. + /// + /// Filename prefix of an output pattern ("X.[####].exr" → "X"). + internal static string OutputPatternPrefix(string outputPattern) + { + var bracket = outputPattern.IndexOf('['); + return bracket > 0 ? outputPattern[..bracket].TrimEnd('.') : Path.GetFileNameWithoutExtension(outputPattern); + } + + internal static void PrepareOutputDir(string outputDirLocal, string outputPattern) + { + Directory.CreateDirectory(outputDirLocal); + var prefix = OutputPatternPrefix(outputPattern); + if (string.IsNullOrEmpty(prefix)) return; + foreach (var file in Directory.EnumerateFiles(outputDirLocal)) + { + if (Path.GetFileName(file).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + try { File.Delete(file); } catch { /* locked leftovers surface as an aerender error */ } + } + } + } + + private static void KillTree(Process process) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + } + catch { /* already gone */ } + } + + private static async Task WaitForExitSafe(Process process) + { + try { await process.WaitForExitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token); } + catch { try { process.Kill(entireProcessTree: true); } catch { } } + } +} + +/// Ring buffer of the last N output lines (§5.3 logTail). +public sealed class LogTail +{ + private readonly Queue _lines; + private readonly int _capacity; + + public LogTail(int capacity) + { + _capacity = capacity; + _lines = new Queue(capacity); + } + + public void Add(string line) + { + _lines.Enqueue(line); + while (_lines.Count > _capacity) _lines.Dequeue(); + } + + public override string ToString() => string.Join('\n', _lines); +} diff --git a/RenderWorker/VFXReviewWorker/ApiClient.cs b/RenderWorker/VFXReviewWorker/ApiClient.cs new file mode 100644 index 0000000..1f31acf --- /dev/null +++ b/RenderWorker/VFXReviewWorker/ApiClient.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// HTTP client for the VFXReview /api/ext pipeline endpoints. Workers talk +/// only HTTP — never a database (§2.1 principle 4). +/// +public sealed class ApiClient +{ + private readonly HttpClient _http; + private readonly ILogger _log; + + public ApiClient(WorkerOptions options, ILogger log) + { + _log = log; + _http = new HttpClient + { + BaseAddress = new Uri(options.ServerUrl.TrimEnd('/') + "/"), + Timeout = TimeSpan.FromSeconds(30), + }; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey); + } + + private static readonly JsonSerializerOptions Json = WorkerOptions.JsonOpts; + + // E6 + public async Task RegisterAsync(WorkerOptions o, CancellationToken ct) + { + var res = await _http.PostAsJsonAsync("api/ext/workers/register", new + { + name = o.MachineName, + hostname = Dns.GetHostName(), + workerVersion = o.WorkerVersion, + aeVersion = o.AeVersion, + capabilities = new { maxConcurrentJobs = 1, tools = new { ffmpeg = o.FfmpegPath != null, oiiotool = o.OiiotoolPath != null } }, + }, Json, ct); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync(Json, ct))!; + } + + // E7 + public async Task HeartbeatAsync(string machineId, string? currentJobId, CancellationToken ct) + { + var res = await _http.PostAsJsonAsync($"api/ext/workers/{machineId}/heartbeat", new + { + cpuPercent = (double?)null, + memPercent = (double?)null, + diskFreeGb = GetDiskFreeGb(), + currentJobId, + }, Json, ct); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync(Json, ct))!; + } + + // E8 — null when the queue is empty / machine outside its render window (204) + public async Task ClaimAsync(string machineId, string[] types, CancellationToken ct) + { + var res = await _http.PostAsJsonAsync("api/ext/render/jobs/claim", new + { + machineId, + types, + }, Json, ct); + if (res.StatusCode == HttpStatusCode.NoContent) return null; + res.EnsureSuccessStatusCode(); + var body = await res.Content.ReadFromJsonAsync(Json, ct); + return body?.Job; + } + + // E9 — best-effort; failures are swallowed by the caller (lease/reaper covers us) + public async Task ProgressAsync(string jobId, string machineId, double progress, + int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress") + { + Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json), + }; + var res = await _http.SendAsync(req, ct); + if (!res.IsSuccessStatusCode) return null; + return await res.Content.ReadFromJsonAsync(Json, ct); + } + + // E10 / E11 — lifecycle reports, sent via the spooler for durability. + // Returns true when the server accepted (2xx) OR permanently rejected (409 + // contradictory / 404 gone) — both mean "stop retrying". + public async Task SendLifecycleAsync(string kind, string jobId, Dictionary payload, CancellationToken ct) + { + var path = kind switch + { + "fail" => $"api/ext/render/jobs/{jobId}/fail", + "complete-render" => $"api/ext/render/jobs/{jobId}/complete-render", + "finalize" => $"api/ext/render/jobs/{jobId}/finalize", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"), + }; + var res = await _http.PostAsJsonAsync(path, payload, Json, ct); + if (res.IsSuccessStatusCode) return true; + if (res.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound) + { + _log.LogWarning("Server permanently rejected {Kind} for job {JobId}: {Status} {Body}", + kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct)); + return true; // don't retry contradictory/vanished reports + } + _log.LogWarning("Lifecycle report {Kind} for {JobId} failed with {Status}; will retry", kind, jobId, (int)res.StatusCode); + return false; + } + + // E20 + PUT upload. Returns the storage key, or null when the upload failed. + public async Task UploadArtifactAsync( + string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct) + { + try + { + if (!File.Exists(filePath)) + { + _log.LogWarning("Artifact {Kind} not found at {Path}", kind, filePath); + return null; + } + + var presignRes = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-presign", new + { + machineId, + kind, + fileName = Path.GetFileName(filePath), + contentType, + }, Json, ct); + if (!presignRes.IsSuccessStatusCode) + { + _log.LogWarning("Presign for {Kind} failed: {Status}", kind, (int)presignRes.StatusCode); + return null; + } + var presign = await presignRes.Content.ReadFromJsonAsync(Json, ct); + if (presign is null) return null; + + // Media files can be large — generous timeout, separate client so the + // presigned URL is not sent with our Authorization header. + using var upload = new HttpClient { Timeout = TimeSpan.FromMinutes(30) }; + await using var stream = File.OpenRead(filePath); + var content = new StreamContent(stream); + content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + var putRes = await upload.PutAsync(presign.PresignedUrl, content, ct); + if (!putRes.IsSuccessStatusCode) + { + _log.LogWarning("Upload of {Kind} failed: {Status}", kind, (int)putRes.StatusCode); + return null; + } + return presign.Key; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Artifact upload ({Kind}) failed for job {JobId}", kind, jobId); + return null; + } + } + + public Task UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) => + UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct); + + // E3 — used by crash-recovery reconcile (§7.5) + public async Task GetExportAsync(string exportId, CancellationToken ct) + { + var res = await _http.GetAsync($"api/ext/exports/{exportId}", ct); + if (!res.IsSuccessStatusCode) return null; + var body = await res.Content.ReadFromJsonAsync(Json, ct); + return body?.Export; + } + + private static double? GetDiskFreeGb() + { + try + { + var root = Path.GetPathRoot(Environment.SystemDirectory); + if (root is null) return null; + return Math.Round(new DriveInfo(root).AvailableFreeSpace / 1_073_741_824.0, 1); + } + catch + { + return null; + } + } +} diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs new file mode 100644 index 0000000..4a347b8 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/Dtos.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace VFXReviewWorker; + +// Wire types for the /api/ext pipeline endpoints (RenderPipeline2 §6). + +public sealed class RegisterResponse +{ + public MachineInfo Machine { get; set; } = new(); + public ServerConfig Config { get; set; } = new(); +} + +public sealed class MachineInfo +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public bool Enabled { get; set; } +} + +/// Server-supplied tuning (E6) — fleet-tunable without touching installs. +public sealed class ServerConfig +{ + public int PollSeconds { get; set; } = 10; + public int HeartbeatSeconds { get; set; } = 30; + public int LeaseSeconds { get; set; } = 300; + public int MaxAttempts { get; set; } = 3; + public int StallTimeoutSeconds { get; set; } = 600; +} + +public sealed class ClaimResponse +{ + public ClaimedJob Job { get; set; } = new(); +} + +public sealed class ClaimedJob +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = "AE_RENDER"; + public int Attempt { get; set; } + public int MaxAttempts { get; set; } + public string? ExportId { get; set; } + public DateTimeOffset? LeaseExpiresAt { get; set; } + + /// Manifest shape depends on the job type — deserialize per stage. + public JsonElement Manifest { get; set; } + + public RenderManifest AsRenderManifest() => + Manifest.Deserialize(WorkerOptions.JsonOpts) + ?? throw new InvalidOperationException("Job manifest is not a render manifest"); + + public PreviewManifest AsPreviewManifest() => + Manifest.Deserialize(WorkerOptions.JsonOpts) + ?? throw new InvalidOperationException("Job manifest is not a preview manifest"); +} + +/// PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9). +public sealed class PreviewManifest +{ + public string Stage { get; set; } = "PREVIEW"; + public string ExportId { get; set; } = ""; + public string ShotCode { get; set; } = ""; + public string VersionString { get; set; } = ""; + public string OutputDir { get; set; } = ""; + public string OutputPattern { get; set; } = ""; + public int FrameStart { get; set; } + public int FrameEnd { get; set; } + public double Fps { get; set; } = 24; + public string TemplateAep { get; set; } = ""; + public string TemplateComp { get; set; } = ""; + public string? OverlayComp { get; set; } + public string? LutComp { get; set; } + public string MovTemplate { get; set; } = ""; + public string Mp4Template { get; set; } = ""; + public string MovOutput { get; set; } = ""; + public string Mp4Output { get; set; } = ""; + public string? SlateScopeProp { get; set; } + public string? SlateSubmissionProp { get; set; } + public SlateInfo Slate { get; set; } = new(); + + public int TotalFrames => FrameEnd - FrameStart + 1; +} + +public sealed class SlateInfo +{ + public string VersionName { get; set; } = ""; + public string Date { get; set; } = ""; + public string? Description { get; set; } + public string? Notes { get; set; } + public string ShotCode { get; set; } = ""; + public string? Episode { get; set; } + public string? Scene { get; set; } + public string? VfxScope { get; set; } + public string? SubmissionNote { get; set; } +} + +/// Result JSON written by vfxr_build_preview.jsx. +public sealed class PreviewBuildResult +{ + public bool Ok { get; set; } + public string? Error { get; set; } + public List Warnings { get; set; } = new(); + public string? TempAep { get; set; } + public string? PreviewComp { get; set; } + public int? FrameCount { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } +} + +/// The Render Manifest (§6.2) — only the fields the Phase 2 render stage needs; the rest is preserved raw. +public sealed class RenderManifest +{ + public string AepPath { get; set; } = ""; + public string CompName { get; set; } = ""; + public string OutputDir { get; set; } = ""; + public string OutputPattern { get; set; } = ""; + public string? OutputModuleTemplate { get; set; } + public string? RenderSettingsTemplate { get; set; } + public int FrameStart { get; set; } + public int FrameEnd { get; set; } + + [JsonExtensionData] + public Dictionary? Extra { get; set; } + + public int TotalFrames => FrameEnd - FrameStart + 1; +} + +public sealed class ProgressResponse +{ + public bool Ok { get; set; } + public bool CancelRequested { get; set; } + public DateTimeOffset? LeaseExpiresAt { get; set; } +} + +public sealed class HeartbeatResponse +{ + public bool Ok { get; set; } + public List Commands { get; set; } = new(); +} + +public sealed class WorkerCommand +{ + public string Type { get; set; } = ""; + public string? JobId { get; set; } +} + +public sealed class PresignResponse +{ + public string PresignedUrl { get; set; } = ""; + public string Key { get; set; } = ""; + public string Url { get; set; } = ""; +} + +public sealed class ExportDetailResponse +{ + public ExportDetail Export { get; set; } = new(); +} + +public sealed class ExportDetail +{ + public string Id { get; set; } = ""; + public string Status { get; set; } = ""; + public List RenderJobs { get; set; } = new(); +} + +public sealed class RenderJobDetail +{ + public string Id { get; set; } = ""; + public string Status { get; set; } = ""; +} + +/// Durable lifecycle report persisted to the spool directory (§7.6). +public sealed class SpooledReport +{ + public string Kind { get; set; } = ""; // "fail" | "complete-render" + public string JobId { get; set; } = ""; + public Dictionary Payload { get; set; } = new(); + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +/// current-job.json — crash recovery state (§7.5). +public sealed class CurrentJobState +{ + public string JobId { get; set; } = ""; + public string? ExportId { get; set; } = ""; + public string MachineId { get; set; } = ""; + public string OutputDirLocal { get; set; } = ""; + public DateTimeOffset ClaimedAt { get; set; } +} diff --git a/RenderWorker/VFXReviewWorker/FileLogger.cs b/RenderWorker/VFXReviewWorker/FileLogger.cs new file mode 100644 index 0000000..5169143 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/FileLogger.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// Minimal rolling file logger (§7.9): one file per day under +/// %ProgramData%\VFXReviewWorker\logs, files older than 14 days pruned on +/// startup and at midnight rollover. No external logging dependencies. +/// +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly object _lock = new(); + private StreamWriter? _writer; + private DateOnly _currentDay; + + public FileLoggerProvider() + { + Directory.CreateDirectory(WorkerOptions.LogsDir); + Prune(); + } + + public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName); + + internal void Write(string category, LogLevel level, string message, Exception? ex) + { + lock (_lock) + { + var today = DateOnly.FromDateTime(DateTime.Now); + if (_writer is null || today != _currentDay) + { + _writer?.Dispose(); + _currentDay = today; + _writer = new StreamWriter( + Path.Combine(WorkerOptions.LogsDir, $"worker_{today:yyyyMMdd}.log"), + append: true) { AutoFlush = true }; + Prune(); + } + _writer.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{level,-5}] {Shorten(category)}: {message}{(ex is null ? "" : $"\n{ex}")}"); + } + } + + private static string Shorten(string category) => category[(category.LastIndexOf('.') + 1)..]; + + private static void Prune() + { + try + { + var cutoff = DateTime.Now.AddDays(-14); + foreach (var f in Directory.EnumerateFiles(WorkerOptions.LogsDir, "*.log")) + { + if (File.GetLastWriteTime(f) < cutoff) File.Delete(f); + } + } + catch { /* best effort */ } + } + + public void Dispose() + { + lock (_lock) { _writer?.Dispose(); _writer = null; } + } + + private sealed class FileLogger : ILogger + { + private readonly FileLoggerProvider _provider; + private readonly string _category; + + public FileLogger(FileLoggerProvider provider, string category) + { + _provider = provider; + _category = category; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) return; + _provider.Write(_category, logLevel, formatter(state, exception), exception); + } + } +} diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs new file mode 100644 index 0000000..8c0067a --- /dev/null +++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs @@ -0,0 +1,326 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// Executes one claimed job end-to-end: writes crash-recovery state, runs +/// aerender, uploads the full log, and enqueues the durable complete/fail +/// report. One job at a time per machine (§7.2). +/// +public sealed class JobExecutor +{ + private readonly WorkerOptions _options; + private readonly ApiClient _api; + private readonly AerenderRunner _runner; + private readonly PreviewStage _preview; + private readonly ReportSpooler _spooler; + private readonly ILogger _log; + + public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview, ReportSpooler spooler, ILogger log) + { + _options = options; + _api = api; + _runner = runner; + _preview = preview; + _spooler = spooler; + _log = log; + } + + public async Task RunAsync(ClaimedJob job, string machineId, ServerConfig config, CancellationToken cancelJob, CancellationToken shutdown) + { + if (job.Type == "PREVIEW_ONLY") + { + await RunPreviewAsync(job, machineId, config, cancelJob, shutdown); + return; + } + + var manifest = job.AsRenderManifest(); + var mapper = new PathMapper(_options.PathMappings); + var outputDirLocal = mapper.Map(manifest.OutputDir); + + WriteState(new CurrentJobState + { + JobId = job.Id, + ExportId = job.ExportId, + MachineId = machineId, + OutputDirLocal = outputDirLocal, + ClaimedAt = DateTimeOffset.UtcNow, + }); + + try + { + var result = await _runner.RunAsync(job, manifest, machineId, outputDirLocal, config, cancelJob, shutdown); + var logKey = await _api.UploadLogAsync(job.Id, machineId, result.FullLogPath, shutdown); + + if (result.Success) + { + long totalBytes = 0; + int fileCount = 0; + var prefix = AerenderRunner.OutputPatternPrefix(manifest.OutputPattern); + try + { + foreach (var f in Directory.EnumerateFiles(outputDirLocal)) + { + if (!Path.GetFileName(f).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + fileCount++; + totalBytes += new FileInfo(f).Length; + } + } + catch { /* stats are best-effort in Phase 2; validation owns this in Phase 3 */ } + + if (fileCount == 0) + { + // aerender can exit 0 having rendered nothing (bad frame + // range, template mismatch, silently swallowed error). + // Until Phase 3 validation exists, an empty output dir must + // never become READY_FOR_QC. + _log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*\" files exist in {Dir} — reporting failure", + job.Id, prefix, outputDirLocal); + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "RENDER", + ["exitCode"] = 0, + ["errorMessage"] = "aerender exited 0 but produced no output files in " + outputDirLocal, + ["logTail"] = result.LogTail, + ["logFileKey"] = logKey, + ["retryable"] = false, + }); + return; + } + + _log.LogInformation("Job {JobId} complete: {Frames} frames in {Sec:F0}s ({Files} files, {Bytes} bytes)", + job.Id, manifest.TotalFrames, result.RenderSeconds, fileCount, totalBytes); + _spooler.Enqueue("complete-render", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["renderSeconds"] = (int)result.RenderSeconds, + ["logFileKey"] = logKey, + ["logTail"] = result.LogTail, + ["exrFileCount"] = fileCount, + ["exrTotalBytes"] = totalBytes, + }); + } + else if (result.ErrorMessage == "Cancelled") + { + // Ack the cancel so the server clears the CANCEL_JOB command + _log.LogInformation("Job {JobId} cancelled — acknowledging", job.Id); + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "RENDER", + ["errorMessage"] = "Cancelled by server", + ["logTail"] = result.LogTail, + ["logFileKey"] = logKey, + ["retryable"] = false, + }); + } + else + { + _log.LogError("Job {JobId} failed (exit {Exit}, retryable {Retryable}): {Error}", + job.Id, result.ExitCode, result.Retryable, result.ErrorMessage); + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "RENDER", + ["exitCode"] = result.ExitCode, + ["errorMessage"] = result.ErrorMessage, + ["logTail"] = result.LogTail, + ["logFileKey"] = logKey, + ["retryable"] = result.Retryable, + }); + } + } + catch (Exception ex) + { + _log.LogError(ex, "Unexpected executor error for job {JobId}", job.Id); + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "RENDER", + ["errorMessage"] = $"Worker exception: {ex.Message}", + ["retryable"] = true, + }); + } + finally + { + ClearState(); + } + } + + /// + /// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate + /// and burn-ins, render the delivery MOV + review MP4, upload the review + /// media, and finalize (which registers the internal-only Version). + /// + private async Task RunPreviewAsync(ClaimedJob job, string machineId, ServerConfig config, CancellationToken cancelJob, CancellationToken shutdown) + { + var manifest = job.AsPreviewManifest(); + + WriteState(new CurrentJobState + { + JobId = job.Id, + ExportId = job.ExportId, + MachineId = machineId, + OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.OutputDir), + ClaimedAt = DateTimeOffset.UtcNow, + }); + + try + { + var result = await _preview.RunAsync(job, manifest, machineId, config, cancelJob, shutdown); + + if (!result.Success) + { + _log.LogError("Preview job {JobId} failed (retryable {Retryable}): {Error}", + job.Id, result.Retryable, result.ErrorMessage); + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "PREVIEW", + ["errorMessage"] = result.ErrorMessage, + ["retryable"] = result.Retryable, + }); + return; // temp AEP is deliberately kept for debugging + } + + var previewKey = await _api.UploadArtifactAsync( + job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown); + if (previewKey is null) + { + // The renders are on the SAN and fine — only the upload failed, + // so this is worth retrying without rebuilding anything. + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "PREVIEW", + ["errorMessage"] = "Preview MP4 upload failed", + ["retryable"] = true, + }); + return; + } + + string? thumbKey = null; + if (result.ThumbnailPath is not null) + { + thumbKey = await _api.UploadArtifactAsync( + job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown); + } + + _spooler.Enqueue("finalize", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["artifacts"] = new Dictionary + { + ["deliveryMovPath"] = manifest.MovOutput, + ["previewMovKey"] = previewKey, + ["thumbnailKey"] = thumbKey, + }, + ["renderStats"] = new Dictionary + { + ["previewSeconds"] = (int)result.Seconds, + }, + ["media"] = new Dictionary + { + ["width"] = result.Build?.Width, + ["height"] = result.Build?.Height, + ["frameCount"] = result.Build?.FrameCount, + ["fps"] = manifest.Fps, + ["fileName"] = Path.GetFileName(result.Mp4Path!), + }, + }); + + // Only clean up once the outputs are safely reported. + CleanScratch(job.Id, result.TempAep); + } + catch (Exception ex) + { + _log.LogError(ex, "Unexpected preview error for job {JobId}", job.Id); + _spooler.Enqueue("fail", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "PREVIEW", + ["errorMessage"] = $"Worker exception: {ex.Message}", + ["retryable"] = true, + }); + } + finally + { + ClearState(); + } + } + + private static void CleanScratch(string jobId, string? tempAep) + { + PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_context.json")); + PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_result.json")); + PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_run.jsx")); + PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg")); + if (tempAep is not null) PreviewStage.TryDelete(tempAep); + } + + /// + /// Crash recovery (§7.5): on service start, if current-job.json names a + /// job, ask the server what became of it. Still ours (CLAIMED/RUNNING) → + /// we crashed mid-render; report a retryable fail so the server requeues + /// (never resume a partial aerender). Reassigned/finished → discard state; + /// the next attempt's PrepareOutputDir clears any orphaned frames. + /// + public async Task ReconcileAsync(string machineId, CancellationToken ct) + { + var state = ReadState(); + if (state is null) return; + _log.LogWarning("Found crash-recovery state for job {JobId} — reconciling", state.JobId); + try + { + var stillOurs = false; + if (!string.IsNullOrEmpty(state.ExportId)) + { + var export = await _api.GetExportAsync(state.ExportId!, ct); + var job = export?.RenderJobs.FirstOrDefault(j => j.Id == state.JobId); + stillOurs = job?.Status is "CLAIMED" or "RUNNING"; + } + if (stillOurs) + { + _log.LogWarning("Server still shows job {JobId} as ours — reporting crash for requeue", state.JobId); + _spooler.Enqueue("fail", state.JobId, new Dictionary + { + ["machineId"] = machineId, + ["stage"] = "RENDER", + ["errorMessage"] = "Worker restarted mid-render (crash/power loss); never resuming a partial render", + ["retryable"] = true, + }); + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Reconcile failed for job {JobId}; discarding local state (reaper covers the lease)", state.JobId); + } + ClearState(); + } + + private static void WriteState(CurrentJobState state) + { + Directory.CreateDirectory(WorkerOptions.DataDir); + File.WriteAllText(WorkerOptions.StateFilePath, JsonSerializer.Serialize(state, WorkerOptions.JsonOpts)); + } + + private static CurrentJobState? ReadState() + { + try + { + if (!File.Exists(WorkerOptions.StateFilePath)) return null; + return JsonSerializer.Deserialize(File.ReadAllText(WorkerOptions.StateFilePath), WorkerOptions.JsonOpts); + } + catch + { + return null; + } + } + + private static void ClearState() + { + try { File.Delete(WorkerOptions.StateFilePath); } catch { } + } +} diff --git a/RenderWorker/VFXReviewWorker/PathMapper.cs b/RenderWorker/VFXReviewWorker/PathMapper.cs new file mode 100644 index 0000000..5a2f4e3 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/PathMapper.cs @@ -0,0 +1,36 @@ +namespace VFXReviewWorker; + +/// +/// Translates canonical UNC paths from manifests to this machine's local +/// drive mappings (§7.8 pathMappings). Comparison is case-insensitive and +/// slash-direction tolerant; the first matching mapping wins. +/// +public sealed class PathMapper +{ + private readonly List<(string From, string To)> _mappings; + + public PathMapper(IEnumerable mappings) + { + _mappings = mappings + .Where(m => !string.IsNullOrWhiteSpace(m.From)) + .Select(m => (Normalize(m.From), m.To)) + .ToList(); + } + + private static string Normalize(string p) => p.Replace('\\', '/'); + + public string Map(string path) + { + if (string.IsNullOrEmpty(path)) return path; + var normalized = Normalize(path); + foreach (var (from, to) in _mappings) + { + if (normalized.StartsWith(from, StringComparison.OrdinalIgnoreCase)) + { + var mapped = to + normalized[from.Length..]; + return mapped.Replace('/', Path.DirectorySeparatorChar); + } + } + return path.Replace('/', Path.DirectorySeparatorChar); + } +} diff --git a/RenderWorker/VFXReviewWorker/PreviewStage.cs b/RenderWorker/VFXReviewWorker/PreviewStage.cs new file mode 100644 index 0000000..9eec8f3 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/PreviewStage.cs @@ -0,0 +1,283 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +public sealed record PreviewStageResult( + bool Success, + bool Retryable, + string? ErrorMessage, + string? MovPath, + string? Mp4Path, + string? ThumbnailPath, + string? TempAep, + PreviewBuildResult? Build, + double Seconds); + +/// +/// Stage 3 — preview generation (RenderPipeline2 §9), the second half of a +/// one-click export. +/// +/// Runs in two steps so the unproven part stays small: +/// 1. AfterFX.com -noui -r → builds the shot around the rendered EXRs using +/// the studio slate/overlay template, queues the MOV + MP4 output modules +/// and saves a throwaway AEP. Does no rendering. +/// 2. aerender -project <that aep> (no -comp) → renders the whole queue in +/// one launch, through the same proven runner as the EXR stage. +/// +public sealed class PreviewStage +{ + private readonly WorkerOptions _options; + private readonly AerenderRunner _runner; + private readonly ILogger _log; + + public PreviewStage(WorkerOptions options, AerenderRunner runner, ILogger log) + { + _options = options; + _runner = runner; + _log = log; + } + + public async Task RunAsync( + ClaimedJob job, + PreviewManifest manifest, + string machineId, + ServerConfig config, + CancellationToken cancelJob, + CancellationToken shutdown) + { + var sw = Stopwatch.StartNew(); + var mapper = new PathMapper(_options.PathMappings); + Directory.CreateDirectory(WorkerOptions.ScratchDir); + + var contextPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_context.json"); + var resultPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_result.json"); + var wrapperPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_run.jsx"); + var tempAep = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_preview.aep"); + + var movLocal = mapper.Map(manifest.MovOutput); + var mp4Local = mapper.Map(manifest.Mp4Output); + + // Everything the script needs, in machine-local paths. + var context = new + { + jobId = job.Id, + exportId = manifest.ExportId, + shotCode = manifest.ShotCode, + versionString = manifest.VersionString, + outputDirLocal = mapper.Map(manifest.OutputDir), + templateAep = mapper.Map(manifest.TemplateAep), + templateComp = manifest.TemplateComp, + overlayComp = manifest.OverlayComp, + lutComp = manifest.LutComp, + movTemplate = manifest.MovTemplate, + mp4Template = manifest.Mp4Template, + movOutput = movLocal, + mp4Output = mp4Local, + tempAep, + resultPath, + frameStart = manifest.FrameStart, + fps = manifest.Fps, + slateScopeProp = manifest.SlateScopeProp, + slateSubmissionProp = manifest.SlateSubmissionProp, + slate = manifest.Slate, + }; + + await File.WriteAllTextAsync(contextPath, JsonSerializer.Serialize(context, WorkerOptions.JsonOpts), shutdown); + + var scriptPath = _options.ResolvePreviewScriptPath(); + if (!File.Exists(scriptPath)) + { + return Fail($"Preview build script not found at {scriptPath}", retryable: false, sw); + } + + // ExtendScript cannot read argv — hand the context path over through a + // generated wrapper that evaluates the shared build script. + var wrapper = new StringBuilder() + .AppendLine("// generated by VFXReviewWorker — do not edit") + .AppendLine($"var VFXR_CONTEXT_PATH = {JsonSerializer.Serialize(contextPath)};") + .AppendLine($"$.evalFile({JsonSerializer.Serialize(scriptPath)});") + .ToString(); + await File.WriteAllTextAsync(wrapperPath, wrapper, shutdown); + + TryDelete(resultPath); + + // ── Step 1: headless build ─────────────────────────────────────────── + var afterFx = _options.ResolveAfterFxPath(); + if (!File.Exists(afterFx)) + { + return Fail($"AfterFX.com not found at {afterFx}", retryable: false, sw); + } + + // Belt and braces: the claim loop already avoids preview jobs while AE + // is open, but an artist may have launched it in between. Never take + // over a live session — defer instead (retryable). + if (AeSession.InteractiveRunning()) + { + return Fail( + "An interactive After Effects session is open on this machine — preview build deferred to avoid taking it over", + retryable: true, sw); + } + + _log.LogInformation("Building preview comp for job {JobId} via {AfterFx}", job.Id, afterFx); + var buildExit = await RunAfterFxAsync(afterFx, wrapperPath, config, cancelJob, shutdown); + + if (!File.Exists(resultPath)) + { + return Fail( + $"Headless AE produced no result file (exit {buildExit}). Check that AfterFX.com can run under this account.", + retryable: true, sw); + } + + PreviewBuildResult? build; + try + { + build = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(resultPath, shutdown), WorkerOptions.JsonOpts); + } + catch (Exception ex) + { + return Fail($"Could not parse preview build result: {ex.Message}", retryable: false, sw); + } + + if (build is null || !build.Ok) + { + // A build failure is deterministic (missing template comp, missing + // output module, bad layer name) — humans fix the template. + return Fail($"Preview build failed: {build?.Error ?? "unknown error"}", retryable: false, sw, build); + } + foreach (var w in build.Warnings) + { + _log.LogWarning("Preview build warning (job {JobId}): {Warning}", job.Id, w); + } + if (string.IsNullOrWhiteSpace(build.TempAep) || !File.Exists(build.TempAep)) + { + return Fail("Preview build reported success but the temp project is missing", retryable: false, sw, build); + } + + // ── Step 2: render the prepared queue ──────────────────────────────── + var render = await _runner.RunProjectAsync( + job.Id, machineId, build.TempAep, build.FrameCount ?? manifest.TotalFrames, + config, cancelJob, shutdown); + + if (!render.Success) + { + return new PreviewStageResult(false, render.Retryable, render.ErrorMessage, + null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds); + } + + // aerender can exit 0 without writing — verify both outputs exist. + var missing = new List(); + if (!File.Exists(movLocal)) missing.Add(movLocal); + if (!File.Exists(mp4Local)) missing.Add(mp4Local); + if (missing.Count > 0) + { + return new PreviewStageResult(false, false, + "aerender exited 0 but expected preview outputs are missing: " + string.Join(", ", missing), + null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds); + } + + var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, shutdown); + + sw.Stop(); + _log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})", + job.Id, sw.Elapsed.TotalSeconds, thumbnail is null ? "" : " + thumbnail"); + + return new PreviewStageResult(true, true, null, movLocal, mp4Local, thumbnail, + build.TempAep, build, sw.Elapsed.TotalSeconds); + } + + private async Task RunAfterFxAsync( + string afterFx, string wrapperPath, ServerConfig config, + CancellationToken cancelJob, CancellationToken shutdown) + { + var psi = new ProcessStartInfo + { + FileName = afterFx, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-noui"); + psi.ArgumentList.Add("-r"); + psi.ArgumentList.Add(wrapperPath); + + using var process = new Process { StartInfo = psi }; + process.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) _log.LogDebug("[AfterFX] {Line}", e.Data); }; + process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) _log.LogDebug("[AfterFX] {Line}", e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // The build does no rendering, so it should finish in well under the + // stall timeout. A hang here means AE is waiting on something (a dialog, + // a licence prompt) and must be killed rather than left holding the job. + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancelJob, shutdown); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(120, config.StallTimeoutSeconds))); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + _log.LogError("Headless AE build timed out or was cancelled — killing process tree"); + try { process.Kill(entireProcessTree: true); } catch { } + return -1; + } + return process.ExitCode; + } + + /// Optional 960-wide poster frame at 25% duration (§9.1). Never fatal. + private async Task TryMakeThumbnailAsync(string jobId, string mp4Path, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_options.FfmpegPath) || !File.Exists(_options.FfmpegPath)) + { + return null; + } + var outPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg"); + TryDelete(outPath); + + var psi = new ProcessStartInfo + { + FileName = _options.FfmpegPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var a in new[] { "-y", "-i", mp4Path, "-vf", "thumbnail,scale=960:-1", "-frames:v", "1", outPath }) + { + psi.ArgumentList.Add(a); + } + + try + { + using var process = Process.Start(psi); + if (process is null) return null; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromMinutes(2)); + await process.WaitForExitAsync(timeout.Token); + return File.Exists(outPath) ? outPath : null; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Thumbnail generation failed (non-fatal)"); + return null; + } + } + + private static PreviewStageResult Fail(string message, bool retryable, Stopwatch sw, PreviewBuildResult? build = null) + { + sw.Stop(); + return new PreviewStageResult(false, retryable, message, null, null, null, build?.TempAep, build, sw.Elapsed.TotalSeconds); + } + + internal static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } +} diff --git a/RenderWorker/VFXReviewWorker/Program.cs b/RenderWorker/VFXReviewWorker/Program.cs new file mode 100644 index 0000000..c3a0372 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/Program.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VFXReviewWorker; + +// VFXReview RenderWorker (RenderPipeline2 §7) — Windows service driving +// aerender.exe on artist workstations / render nodes. All state flows through +// /api/ext/* with API-key auth; the worker never touches the database. +// +// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json] +// Install as a service: see RenderWorker/README.md + +var configPath = args.FirstOrDefault(a => !a.StartsWith('-')); +var options = WorkerOptions.Load(configPath); + +// Positional args (config path) are consumed above and deliberately not +// forwarded — the command-line configuration provider rejects bare tokens. +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddWindowsService(o => o.ServiceName = "VFXReviewRenderWorker"); +builder.Logging.AddProvider(new FileLoggerProvider()); + +builder.Services.AddSingleton(options); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +await builder.Build().RunAsync(); diff --git a/RenderWorker/VFXReviewWorker/ProgressParser.cs b/RenderWorker/VFXReviewWorker/ProgressParser.cs new file mode 100644 index 0000000..4f03d98 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/ProgressParser.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; + +namespace VFXReviewWorker; + +/// +/// Parses aerender stdout line-by-line (§7.3): PROGRESS lines yield the +/// current frame; ERROR lines are collected; deterministic errors (missing +/// footage/comp/project) mark the job non-retryable — humans fix the comp. +/// +public static partial class ProgressParser +{ + // e.g. "PROGRESS: 0:00:02:03 (51): 0 Seconds" / "PROGRESS: 2 (2): ..." + [GeneratedRegex(@"^PROGRESS:.*\((\d+)\)", RegexOptions.Compiled)] + private static partial Regex ProgressLine(); + + [GeneratedRegex(@"aerender\s+(SYNTAX\s+)?ERROR|^ERROR:", RegexOptions.Compiled | RegexOptions.IgnoreCase)] + private static partial Regex ErrorLine(); + + private static readonly string[] NonRetryablePatterns = + { + "missing footage", + "layer source file missing", + "file is missing", + "no comp was found", + "no composition", + "project file could not be opened", + "can not be opened", + "cannot be opened", + "the file format module could not parse the file", + "no render settings template", + "no output module template", + "syntax error", + "illegal argument", + }; + + /// Returns the rendered-frame ordinal (1-based within the range) or null. + public static int? ParseFrame(string line) + { + var m = ProgressLine().Match(line); + return m.Success && int.TryParse(m.Groups[1].Value, out var f) ? f : null; + } + + public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line); + + /// Deterministic failures re-render identically — don't burn retries on them. + public static bool IsNonRetryableError(string line) + { + foreach (var p in NonRetryablePatterns) + { + if (line.Contains(p, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } +} + +/// ETA = rolling average seconds/frame × frames remaining (§7.3). +public sealed class EtaCalculator +{ + private readonly int _windowSize; + private readonly Queue _frameSeconds = new(); + private DateTimeOffset? _lastFrameAt; + + public EtaCalculator(int windowSize = 10) => _windowSize = windowSize; + + public void RecordFrame(DateTimeOffset now) + { + if (_lastFrameAt is { } last) + { + _frameSeconds.Enqueue((now - last).TotalSeconds); + while (_frameSeconds.Count > _windowSize) _frameSeconds.Dequeue(); + } + _lastFrameAt = now; + } + + public int? EstimateSeconds(int framesRemaining) + { + if (_frameSeconds.Count == 0 || framesRemaining <= 0) return null; + return (int)Math.Round(_frameSeconds.Average() * framesRemaining); + } +} diff --git a/RenderWorker/VFXReviewWorker/ReportSpooler.cs b/RenderWorker/VFXReviewWorker/ReportSpooler.cs new file mode 100644 index 0000000..2e4a889 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/ReportSpooler.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// Durable lifecycle reports (§7.6): fail/complete reports are written to the +/// spool directory before the first send attempt and replayed strictly in +/// order with exponential backoff (1 s → 60 s). Rendering continues during +/// server downtime; a report is deleted only once the server accepts it (or +/// permanently rejects it as contradictory). +/// +public sealed class ReportSpooler +{ + private readonly ApiClient _api; + private readonly ILogger _log; + private readonly string _dir; + private readonly SemaphoreSlim _wake = new(0); + private long _seq; + + public ReportSpooler(ApiClient api, ILogger log, string? dir = null) + { + _api = api; + _log = log; + _dir = dir ?? WorkerOptions.SpoolDir; + Directory.CreateDirectory(_dir); + } + + public void Enqueue(string kind, string jobId, Dictionary payload) + { + var report = new SpooledReport { Kind = kind, JobId = jobId, Payload = payload }; + var name = $"{DateTimeOffset.UtcNow.UtcTicks:D20}_{Interlocked.Increment(ref _seq):D6}.json"; + var tmp = Path.Combine(_dir, name + ".tmp"); + var final = Path.Combine(_dir, name); + File.WriteAllText(tmp, JsonSerializer.Serialize(report, WorkerOptions.JsonOpts)); + File.Move(tmp, final); + _wake.Release(); + } + + public bool HasPending => Directory.EnumerateFiles(_dir, "*.json").Any(); + + /// Long-running replay loop; started once by the worker service. + public async Task RunAsync(CancellationToken ct) + { + var backoff = TimeSpan.FromSeconds(1); + while (!ct.IsCancellationRequested) + { + var next = Directory.EnumerateFiles(_dir, "*.json").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault(); + if (next is null) + { + try { await _wake.WaitAsync(TimeSpan.FromSeconds(5), ct); } catch (OperationCanceledException) { break; } + continue; + } + + bool done; + try + { + var report = JsonSerializer.Deserialize(File.ReadAllText(next), WorkerOptions.JsonOpts); + if (report is null || string.IsNullOrEmpty(report.JobId)) + { + _log.LogWarning("Discarding unreadable spool file {File}", next); + done = true; + } + else + { + done = await _api.SendLifecycleAsync(report.Kind, report.JobId, report.Payload, ct); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Spool replay attempt failed for {File}", next); + done = false; + } + + if (done) + { + try { File.Delete(next); } catch { /* re-sent next pass; server side is idempotent */ } + backoff = TimeSpan.FromSeconds(1); + } + else + { + try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { break; } + backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60)); + } + } + } +} diff --git a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj new file mode 100644 index 0000000..abccf80 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + VFXReviewWorker + VFXReviewWorker + + true + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/RenderWorker/VFXReviewWorker/WorkerOptions.cs b/RenderWorker/VFXReviewWorker/WorkerOptions.cs new file mode 100644 index 0000000..da43ca8 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/WorkerOptions.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace VFXReviewWorker; + +/// +/// Machine-local config (RenderPipeline2 §7.8) — deliberately minimal; +/// everything tunable lives in server SystemConfig and arrives via E6. +/// Default path: C:\ProgramData\VFXReviewWorker\config.json +/// +public sealed class WorkerOptions +{ + public string ServerUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; + public string MachineName { get; set; } = Environment.MachineName; + public string AerenderPath { get; set; } = @"C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\aerender.exe"; + /// AfterFX.com — the console AE used for the headless preview build. Defaults beside aerender. + public string? AfterFxPath { get; set; } + /// vfxr_build_preview.jsx — defaults to scripts\ beside the worker exe. + public string? PreviewScriptPath { get; set; } + public string? FfmpegPath { get; set; } + public string? OiiotoolPath { get; set; } + public List PathMappings { get; set; } = new(); + public string WorkerVersion { get; set; } = "1.0.0"; + public string? AeVersion { get; set; } + + public static string DataDir => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "VFXReviewWorker"); + + public static string DefaultConfigPath => Path.Combine(DataDir, "config.json"); + public static string LogsDir => Path.Combine(DataDir, "logs"); + public static string SpoolDir => Path.Combine(DataDir, "spool"); + public static string ScratchDir => Path.Combine(DataDir, "scratch"); + public static string StateFilePath => Path.Combine(DataDir, "current-job.json"); + + /// AfterFX.com sits beside aerender.exe in a standard AE install. + public string ResolveAfterFxPath() + { + if (!string.IsNullOrWhiteSpace(AfterFxPath)) return AfterFxPath; + var dir = Path.GetDirectoryName(AerenderPath); + return dir is null ? "AfterFX.com" : Path.Combine(dir, "AfterFX.com"); + } + + public string ResolvePreviewScriptPath() + { + if (!string.IsNullOrWhiteSpace(PreviewScriptPath)) return PreviewScriptPath; + return Path.Combine(AppContext.BaseDirectory, "scripts", "vfxr_build_preview.jsx"); + } + + public static WorkerOptions Load(string? path = null) + { + path ??= DefaultConfigPath; + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"Worker config not found at {path}. Create it with serverUrl, apiKey and machineName (see RenderWorker/README.md)."); + } + var json = File.ReadAllText(path); + var opts = JsonSerializer.Deserialize(json, JsonOpts) + ?? throw new InvalidOperationException($"Could not parse {path}"); + if (string.IsNullOrWhiteSpace(opts.ServerUrl)) throw new InvalidOperationException("config.json: serverUrl is required"); + if (string.IsNullOrWhiteSpace(opts.ApiKey)) throw new InvalidOperationException("config.json: apiKey is required"); + if (string.IsNullOrWhiteSpace(opts.MachineName)) opts.MachineName = Environment.MachineName; + return opts; + } + + public static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} + +public sealed class PathMapping +{ + public string From { get; set; } = ""; + public string To { get; set; } = ""; +} diff --git a/RenderWorker/VFXReviewWorker/WorkerService.cs b/RenderWorker/VFXReviewWorker/WorkerService.cs new file mode 100644 index 0000000..bb8bf24 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/WorkerService.cs @@ -0,0 +1,159 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// Main loop (§7.2): register on startup, heartbeat on its own timer (the +/// cancel-signal channel), claim-poll only when idle, one job at a time. +/// Workers poll dumbly — all scheduling policy (render windows, Render Now, +/// urgent priority) lives server-side in the claim endpoint (§7.10). +/// +public sealed class WorkerService : BackgroundService +{ + private readonly WorkerOptions _options; + private readonly ApiClient _api; + private readonly JobExecutor _executor; + private readonly ReportSpooler _spooler; + private readonly ILogger _log; + + private string? _machineId; + private ServerConfig _config = new(); + private volatile string? _currentJobId; + private CancellationTokenSource? _cancelCurrentJob; + private bool? _lastAeOpen; + + public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler, ILogger log) + { + _options = options; + _api = api; + _executor = executor; + _spooler = spooler; + _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _log.LogInformation("VFXReview RenderWorker {Version} starting as \"{Name}\" → {Server}", + _options.WorkerVersion, _options.MachineName, _options.ServerUrl); + + _ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken); + + await RegisterWithRetryAsync(stoppingToken); + await _executor.ReconcileAsync(_machineId!, stoppingToken); + + _ = Task.Run(() => HeartbeatLoopAsync(stoppingToken), stoppingToken); + + // Claim loop — only while idle + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Preview builds drive a full AE instance, which would hijack an + // artist's open session (§9.1). Simply don't ask for those jobs + // while AE is running — they stay queued for a quiet moment + // instead of being claimed and failed. + var aeOpen = AeSession.InteractiveRunning(); + if (aeOpen != _lastAeOpen) + { + _log.LogInformation(aeOpen + ? "Interactive After Effects detected — not claiming preview jobs until it closes" + : "No interactive After Effects — preview jobs enabled"); + _lastAeOpen = aeOpen; + } + var types = aeOpen + ? new[] { "AE_RENDER" } + : new[] { "AE_RENDER", "PREVIEW_ONLY" }; + + var job = await _api.ClaimAsync(_machineId!, types, stoppingToken); + if (job is not null) + { + _log.LogInformation("Claimed job {JobId} (attempt {Attempt}/{Max}, export {ExportId})", + job.Id, job.Attempt, job.MaxAttempts, job.ExportId); + _cancelCurrentJob = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + _currentJobId = job.Id; + try + { + await _executor.RunAsync(job, _machineId!, _config, _cancelCurrentJob.Token, stoppingToken); + } + finally + { + _currentJobId = null; + _cancelCurrentJob.Dispose(); + _cancelCurrentJob = null; + } + continue; // immediately look for the next job + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Claim poll failed (server unreachable?) — retrying on normal cadence"); + } + try { await Task.Delay(TimeSpan.FromSeconds(_config.PollSeconds), stoppingToken); } + catch (OperationCanceledException) { break; } + } + _log.LogInformation("RenderWorker stopping"); + } + + private async Task RegisterWithRetryAsync(CancellationToken ct) + { + var backoff = TimeSpan.FromSeconds(1); + while (!ct.IsCancellationRequested) + { + try + { + var reg = await _api.RegisterAsync(_options, ct); + _machineId = reg.Machine.Id; + _config = reg.Config; + _log.LogInformation( + "Registered as machine {Id} ({Name}); poll={Poll}s heartbeat={Hb}s lease={Lease}s stall={Stall}s enabled={Enabled}", + reg.Machine.Id, reg.Machine.Name, _config.PollSeconds, _config.HeartbeatSeconds, + _config.LeaseSeconds, _config.StallTimeoutSeconds, reg.Machine.Enabled); + return; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _log.LogWarning("Registration failed ({Error}); retrying in {Backoff}s", ex.Message, backoff.TotalSeconds); + try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { return; } + backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60)); + } + } + } + + private async Task HeartbeatLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + var resp = await _api.HeartbeatAsync(_machineId!, _currentJobId, ct); + foreach (var cmd in resp.Commands) + { + if (cmd.Type == "CANCEL_JOB" && cmd.JobId is not null && cmd.JobId == _currentJobId) + { + _log.LogWarning("Heartbeat carried CANCEL_JOB for current job {JobId}", cmd.JobId); + _cancelCurrentJob?.Cancel(); + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _log.LogDebug(ex, "Heartbeat failed (non-fatal)"); + } + try { await Task.Delay(TimeSpan.FromSeconds(_config.HeartbeatSeconds), ct); } + catch (OperationCanceledException) { return; } + } + } +} diff --git a/RenderWorker/scripts/test-preview-build.ps1 b/RenderWorker/scripts/test-preview-build.ps1 new file mode 100644 index 0000000..3cddf9e --- /dev/null +++ b/RenderWorker/scripts/test-preview-build.ps1 @@ -0,0 +1,138 @@ +<# +.SYNOPSIS + Headless AE spike / debug tool for the preview build stage. + +.DESCRIPTION + Runs vfxr_build_preview.jsx exactly the way the RenderWorker does, but + standalone, so the risky part (AfterFX.com -noui -r under a service + account) can be proven before wiring the whole pipeline together. + + It does NOT render - it builds the preview comp, queues the MOV + MP4 + output modules and saves a temp AEP, then prints the build result. + + IMPORTANT: close After Effects first. `AfterFX.com -r` hands the script to + an already-running instance, which would open the template in your session + and then quit it. + +.EXAMPLE + .\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG\renders\UNG_S1\111\UNG_111_001_030\v002" ` + -ShotCode "UNG_111_001_030" -Version "v002" + +.EXAMPLE + # then render what it built, exactly as the worker would: + & "C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\aerender.exe" -project "$env:ProgramData\VFXReviewWorker\scratch\spike_preview.aep" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$ExrDir, + [Parameter(Mandatory = $true)][string]$ShotCode, + [string]$Version = "v001", + [string]$TemplateAep = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep", + [string]$TemplateComp = "UNG_EXPORT_TEMPLATE", + [string]$OverlayComp = "UNG_VFX_OVERLAY", + [string]$LutComp = "_SHOW LUT", + [string]$MovTemplate = "4444 Tri", + [string]$Mp4Template = "REVIEW_PREVIEW", + [string]$AfterFx = "C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\AfterFX.com", + [int]$FrameStart = 1001, + [double]$Fps = 24, + [int]$TimeoutSeconds = 300 +) + +$ErrorActionPreference = "Stop" + +$running = Get-Process AfterFX -ErrorAction SilentlyContinue +if ($running) { + Write-Warning "After Effects is running (PID $($running.Id -join ', '))." + Write-Warning "AfterFX.com -r would target that instance and quit it. Close AE first." + return +} + +$scratch = Join-Path $env:ProgramData "VFXReviewWorker\scratch" +if (-not (Test-Path $scratch)) { New-Item -ItemType Directory -Force -Path $scratch | Out-Null } + +$scriptPath = Join-Path $PSScriptRoot "vfxr_build_preview.jsx" +if (-not (Test-Path $scriptPath)) { throw "Build script not found: $scriptPath" } +if (-not (Test-Path $AfterFx)) { throw "AfterFX.com not found: $AfterFx" } +if (-not (Test-Path $ExrDir)) { throw "EXR folder not found: $ExrDir" } + +$contextPath = Join-Path $scratch "spike_context.json" +$resultPath = Join-Path $scratch "spike_result.json" +$wrapperPath = Join-Path $scratch "spike_run.jsx" +$tempAep = Join-Path $scratch "spike_preview.aep" +$base = "${ShotCode}_cmp_TT_${Version}" + +Remove-Item $resultPath -ErrorAction SilentlyContinue + +$context = [ordered]@{ + jobId = "spike" + exportId = "spike" + shotCode = $ShotCode + versionString = $Version + outputDirLocal = $ExrDir + templateAep = $TemplateAep + templateComp = $TemplateComp + overlayComp = $OverlayComp + lutComp = $LutComp + movTemplate = $MovTemplate + mp4Template = $Mp4Template + movOutput = (Join-Path $ExrDir "$base.mov") + mp4Output = (Join-Path $ExrDir "$base.mp4") + tempAep = $tempAep + resultPath = $resultPath + frameStart = $FrameStart + fps = $Fps + slate = [ordered]@{ + versionName = $base + date = (Get-Date -Format "yyyy/MM/dd") + description = "Spike test" + notes = "" + shotCode = $ShotCode + episode = "" + scene = "" + } +} +$context | ConvertTo-Json -Depth 6 | Out-File -FilePath $contextPath -Encoding utf8 + +$ctxJs = ($contextPath -replace '\\', '/') | ConvertTo-Json +$scriptJs = ($scriptPath -replace '\\', '/') | ConvertTo-Json +$wrapper = @( + "// generated by test-preview-build.ps1", + ("var VFXR_CONTEXT_PATH = {0};" -f $ctxJs), + ('$.evalFile({0});' -f $scriptJs) +) -join "`r`n" +$wrapper | Out-File -FilePath $wrapperPath -Encoding utf8 + +Write-Host "Launching headless AE build..." -ForegroundColor Cyan +$proc = Start-Process -FilePath $AfterFx -ArgumentList @("-noui", "-r", $wrapperPath) -PassThru -NoNewWindow +if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) { + Write-Warning "Timed out after $TimeoutSeconds s - killing AE." + try { $proc.Kill($true) } catch {} +} + +if (-not (Test-Path $resultPath)) { + Write-Host "FAILED: no result file produced." -ForegroundColor Red + Write-Host "AE could not run the script headlessly under this account. See RenderWorker/README.md fallbacks." -ForegroundColor Red + return +} + +$result = Get-Content $resultPath -Raw | ConvertFrom-Json +if ($result.ok) { + Write-Host "BUILD OK" -ForegroundColor Green + Write-Host " temp AEP : $($result.tempAep)" + Write-Host " preview comp: $($result.previewComp)" + Write-Host (" frames : {0} ({1} x {2})" -f $result.frameCount, $result.width, $result.height) + Write-Host (" queued : {0} output(s)" -f @($result.queued).Count) + foreach ($q in @($result.queued)) { + Write-Host (" - {0}: {1} -> {2}" -f $q.label, $q.template, $q.output) + } + Write-Host "" + Write-Host "Render it with:" -ForegroundColor Cyan + Write-Host (' "{0}\aerender.exe" -project "{1}"' -f (Split-Path $AfterFx), $result.tempAep) +} else { + Write-Host "BUILD FAILED: $($result.error)" -ForegroundColor Red +} +if ($result.warnings -and $result.warnings.Count -gt 0) { + Write-Host "Warnings:" -ForegroundColor Yellow + $result.warnings | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } +} diff --git a/RenderWorker/scripts/vfxr_build_preview.jsx b/RenderWorker/scripts/vfxr_build_preview.jsx new file mode 100644 index 0000000..960500c --- /dev/null +++ b/RenderWorker/scripts/vfxr_build_preview.jsx @@ -0,0 +1,527 @@ +/* + vfxr_build_preview.jsx — VFXReview render pipeline, preview build stage. + + Runs headlessly: AfterFX.com -noui -r .jsx + where the wrapper sets var VFXR_CONTEXT_PATH = "...json"; then + $.evalFile()s this script. + + It does NOT render. It opens the studio slate/overlay template, rebuilds + the shot around the *rendered* EXR sequence (OCIO → show LUT → overlay), + duplicates the export template into a preview comp with the slate filled + in, queues the MOV and MP4 output modules, and saves a throwaway AEP. + The worker then runs `aerender -project ` with no -comp, which + renders the whole queue in one launch. + + Build logic mirrors the VFXReviewConnector panel functions + buildShotFromCode / updateOverlayForComp / buildPreviewForShot so the + farm and the artist produce identical slates and burn-ins. + + Everything is reported back through the result JSON — never a dialog. +*/ + +(function vfxrBuildPreview() { + var ctx = null; + var result = { + ok: false, + error: null, + warnings: [], + tempAep: null, + previewComp: null, + queued: [], + frameCount: null, + width: null, + height: null + }; + var resultPath = null; + + // ── tiny helpers (ES3 / ExtendScript safe) ─────────────────────────────── + + function readTextFile(path) { + var f = new File(path); + var text; + if (!f.exists) { + return null; + } + f.encoding = "UTF-8"; + if (!f.open("r")) { + return null; + } + text = f.read(); + f.close(); + return text; + } + + function writeTextFile(path, text) { + var f = new File(path); + try { + f.encoding = "UTF-8"; + if (!f.open("w")) { + return false; + } + f.write(text); + f.close(); + return true; + } catch (writeError) { + return false; + } + } + + function jsonEscape(value) { + var text = String(value); + var out = ""; + var i; + var ch; + var code; + + for (i = 0; i < text.length; i += 1) { + ch = text.charAt(i); + code = text.charCodeAt(i); + if (ch === "\"") { + out += "\\\""; + } else if (ch === "\\") { + out += "\\\\"; + } else if (ch === "\n") { + out += "\\n"; + } else if (ch === "\r") { + out += "\\r"; + } else if (ch === "\t") { + out += "\\t"; + } else if (code < 32) { + out += "\\u" + ("000" + code.toString(16)).slice(-4); + } else { + out += ch; + } + } + return out; + } + + function toJSON(value) { + var parts = []; + var i; + var key; + + if (value === null || value === undefined) { + return "null"; + } + if (typeof value === "number") { + return isFinite(value) ? String(value) : "null"; + } + if (typeof value === "boolean") { + return String(value); + } + if (typeof value === "string") { + return "\"" + jsonEscape(value) + "\""; + } + if (value instanceof Array) { + for (i = 0; i < value.length; i += 1) { + parts.push(toJSON(value[i])); + } + return "[" + parts.join(",") + "]"; + } + for (key in value) { + if (value.hasOwnProperty(key) && value[key] !== undefined) { + parts.push("\"" + jsonEscape(key) + "\":" + toJSON(value[key])); + } + } + return "{" + parts.join(",") + "}"; + } + + function warn(message) { + result.warnings.push(String(message)); + } + + function findComp(name) { + var i; + var item; + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof CompItem && item.name === name) { + return item; + } + } + return null; + } + + function findFolder(name) { + var i; + var item; + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof FolderItem && item.name === name) { + return item; + } + } + return null; + } + + // Mirrors the panel's addOCIOToLayer: effect added but disabled, so the + // artist/farm chain matches exactly. + function addOCIOToLayer(layer) { + var ocio; + var outputProp; + + try { + ocio = layer.property("Effects").addProperty("OCIO Color Space Transform"); + ocio.enabled = false; + } catch (ocioError) { + warn("OCIO effect unavailable: " + ocioError.toString()); + return; + } + + try { + outputProp = ocio.property("Output Color Space"); + } catch (byNameError) { + outputProp = null; + } + if (!outputProp) { + try { + outputProp = ocio.property(2); + } catch (byIndexError) { + outputProp = null; + } + } + if (outputProp) { + try { + outputProp.setValue(93); + } catch (setError) { + warn("Could not set OCIO output colour space: " + setError.toString()); + } + } + } + + function setEssentialProperty(layer, propName, value) { + if (value === null || value === undefined || value === "") { + return false; + } + try { + layer.property("Essential Properties").property(propName).setValue(value); + return true; + } catch (setError) { + return false; + } + } + + function setEssentialPropertyByIndex(layer, index, value) { + try { + layer.property("Essential Properties").property(index).setValue(value); + return true; + } catch (setError) { + return false; + } + } + + function listEssentialPropertyNames(layer) { + var names = []; + var group; + var i; + + try { + group = layer.property("Essential Properties"); + for (i = 1; i <= group.numProperties; i += 1) { + try { + names.push(i + ":" + group.property(i).name); + } catch (oneError) { + } + } + } catch (groupError) { + } + return names; + } + + // Per-submission fields (VFX Scope / Submission Note). The property names + // come from config; if one does not exist, report the template's actual + // property names so it can be corrected without another build. + function setSlateField(layer, propName, value, label) { + if (!propName) { + return; + } + if (value === null || value === undefined || value === "") { + return; // nothing to write; leave the template's own default + } + if (!setEssentialProperty(layer, propName, value)) { + warn(label + ": Essential Property \"" + propName + "\" not found or not settable. " + + "Available: " + listEssentialPropertyNames(layer).join(", ")); + } + } + + function setCompStartFrame(comp, frameNumber) { + try { + comp.displayStartFrame = frameNumber; + return; + } catch (frameError) { + } + try { + comp.displayStartTime = frameNumber / comp.frameRate; + } catch (timeError) { + } + } + + function applyOutputModuleTemplate(outputModule, templateName) { + var templates; + var i; + + try { + templates = outputModule.templates; + for (i = 0; i < templates.length; i += 1) { + if (templates[i] === templateName) { + outputModule.applyTemplate(templateName); + return true; + } + } + } catch (templateError) { + } + return false; + } + + function importExrSequence(dirPath) { + var folder = new Folder(dirPath); + var files; + var importOptions; + var footage; + + if (!folder.exists) { + throw new Error("Render output folder not found: " + dirPath); + } + + files = folder.getFiles("*.exr"); + if (!files || files.length < 1) { + throw new Error("No EXR files found in " + dirPath); + } + files.sort(); + + importOptions = new ImportOptions(files[0]); + importOptions.sequence = true; + footage = app.project.importFile(importOptions); + try { + footage.mainSource.conformFrameRate = ctx.fps || 24; + } catch (conformError) { + warn("Could not conform frame rate: " + conformError.toString()); + } + return footage; + } + + function queueOutput(comp, templateName, outputPath, label) { + var item; + var outputModule; + + if (!templateName || !outputPath) { + warn("Skipping " + label + ": no template or output path configured"); + return false; + } + + try { + item = app.project.renderQueue.items.add(comp); + outputModule = item.outputModule(1); + if (!applyOutputModuleTemplate(outputModule, templateName)) { + item.remove(); + throw new Error("Output module template not found: \"" + templateName + "\""); + } + outputModule.file = new File(outputPath); + result.queued.push({ label: label, template: templateName, output: outputPath }); + return true; + } catch (queueError) { + try { + if (item) { + item.remove(); + } + } catch (removeError) { + } + throw queueError; + } + } + + // ── main ───────────────────────────────────────────────────────────────── + + try { + if (typeof VFXR_CONTEXT_PATH === "undefined" || !VFXR_CONTEXT_PATH) { + throw new Error("VFXR_CONTEXT_PATH was not set by the wrapper script"); + } + + var contextText = readTextFile(VFXR_CONTEXT_PATH); + if (!contextText) { + throw new Error("Could not read job context: " + VFXR_CONTEXT_PATH); + } + ctx = eval("(" + contextText + ")"); + resultPath = ctx.resultPath; + + try { + app.beginSuppressDialogs(); + } catch (suppressError) { + } + + // 1. Open the studio template (slate, overlay, show LUT, export template) + var templateFile = new File(ctx.templateAep); + if (!templateFile.exists) { + throw new Error("Template project not found: " + ctx.templateAep); + } + app.open(templateFile); + + var templateComp = findComp(ctx.templateComp); + if (!templateComp) { + throw new Error("Export template comp \"" + ctx.templateComp + "\" not found in template project"); + } + + // 2. Import the rendered EXR sequence + var footage = importExrSequence(ctx.outputDirLocal); + var footageFolder = findFolder("_FOOTAGE_4K"); + if (footageFolder) { + footage.parentFolder = footageFolder; + } + footage.name = ctx.shotCode + "_" + ctx.versionString; + + var width = footage.width; + var height = footage.height; + var duration = footage.duration; + var fps = ctx.fps || 24; + + result.width = width; + result.height = height; + result.frameCount = Math.round(duration * fps); + + // 3. Footage precomp (mirrors buildShotFromCode) + var precompFolder = findFolder("_PRECOMPS"); + var footageComp = app.project.items.addComp( + ctx.shotCode + "_FOOTAGE", width, height, 1, duration, fps); + if (precompFolder) { + footageComp.parentFolder = precompFolder; + } + if (ctx.frameStart !== null && ctx.frameStart !== undefined) { + setCompStartFrame(footageComp, ctx.frameStart); + } + addOCIOToLayer(footageComp.layers.add(footage)); + + // 4. Shot comp: footage + show LUT + overlay + var shotComp = app.project.items.addComp(ctx.shotCode, width, height, 1, duration, fps); + if (ctx.frameStart !== null && ctx.frameStart !== undefined) { + setCompStartFrame(shotComp, ctx.frameStart); + } + shotComp.layers.add(footageComp); + + if (ctx.lutComp) { + var showLut = findComp(ctx.lutComp); + if (showLut) { + var lutLayer = shotComp.layers.add(showLut); + lutLayer.collapseTransformation = true; + } else { + warn("Show LUT comp \"" + ctx.lutComp + "\" not found — rendering without it"); + } + } + + if (ctx.overlayComp) { + var overlayComp = findComp(ctx.overlayComp); + if (overlayComp) { + var overlayLayer = shotComp.layers.add(overlayComp); + overlayLayer.moveToBeginning(); + overlayLayer.enabled = true; + setEssentialProperty(overlayLayer, "DATE", ctx.slate.date); + setEssentialProperty(overlayLayer, "SHOT NAME", ctx.slate.versionName); + } else { + warn("Overlay comp \"" + ctx.overlayComp + "\" not found — rendering without burn-ins"); + } + } + + // 5. Preview comp from the export template (mirrors buildPreviewForShot) + var previewsFolder = findFolder("_PREVIEWS"); + var previewComp = templateComp.duplicate(); + previewComp.name = ctx.shotCode + "_PREVIEW"; + if (previewsFolder) { + previewComp.parentFolder = previewsFolder; + } + + previewComp.layer("SHOT").replaceSource(shotComp, false); + try { + previewComp.layer("THUMBNAIL").replaceSource(shotComp, false); + } catch (thumbError) { + warn("THUMBNAIL layer not updated: " + thumbError.toString()); + } + + var slateLayer = previewComp.layer("NETFLIX_SLATE"); + if (slateLayer) { + // Index-addressed properties match the panel exactly (3 = version + // name, 10 = shot code); the rest are addressed by name. + setEssentialPropertyByIndex(slateLayer, 3, ctx.slate.versionName); + setEssentialProperty(slateLayer, "Date", ctx.slate.date); + setEssentialProperty(slateLayer, "Desc", ctx.slate.description); + setEssentialPropertyByIndex(slateLayer, 10, ctx.slate.shotCode); + setEssentialProperty(slateLayer, "Episode", ctx.slate.episode); + setEssentialProperty(slateLayer, "Scene", ctx.slate.scene); + setEssentialProperty(slateLayer, "Frames", result.frameCount); + + setSlateField(slateLayer, ctx.slateScopeProp, ctx.slate.vfxScope, "VFX Scope"); + // The slate's Notes field carries this submission's note; when the + // artist left it blank, fall back to the shot's own notes so the + // slate looks the same as an artist-built preview. + setSlateField(slateLayer, ctx.slateSubmissionProp, + (ctx.slate.submissionNote !== null && + ctx.slate.submissionNote !== undefined && + ctx.slate.submissionNote !== "") + ? ctx.slate.submissionNote + : ctx.slate.notes, + "Submission Note"); + } else { + warn("NETFLIX_SLATE layer not found in template — slate fields not filled"); + } + + var shotLayer = previewComp.layer("SHOT"); + var targetOutPoint = shotLayer.inPoint + shotComp.duration; + if (targetOutPoint > previewComp.duration) { + previewComp.duration = targetOutPoint; + } + shotLayer.outPoint = targetOutPoint; + previewComp.duration = targetOutPoint; + + result.previewComp = previewComp.name; + + // 6. Queue both outputs against the same preview comp. aerender will + // render the whole queue in one launch. + app.project.renderQueue.showWindow(false); + while (app.project.renderQueue.numItems > 0) { + app.project.renderQueue.item(1).remove(); + } + queueOutput(previewComp, ctx.movTemplate, ctx.movOutput, "mov"); + queueOutput(previewComp, ctx.mp4Template, ctx.mp4Output, "mp4"); + + if (result.queued.length < 1) { + throw new Error("Nothing was queued — check output module templates"); + } + + // 7. Save the throwaway project the worker will hand to aerender + var tempFile = new File(ctx.tempAep); + var tempParent = tempFile.parent; + if (tempParent && !tempParent.exists) { + tempParent.create(); + } + app.project.save(tempFile); + result.tempAep = ctx.tempAep; + result.ok = true; + } catch (buildError) { + result.ok = false; + result.error = buildError && buildError.toString ? buildError.toString() : String(buildError); + try { + if (buildError && buildError.line) { + result.error += " (line " + buildError.line + ")"; + } + } catch (lineError) { + } + } + + try { + app.endSuppressDialogs(false); + } catch (endSuppressError) { + } + + if (!resultPath && ctx && ctx.resultPath) { + resultPath = ctx.resultPath; + } + if (resultPath) { + writeTextFile(resultPath, toJSON(result)); + } + + // -noui leaves the app running otherwise; the worker waits on exit. + try { + app.quit(); + } catch (quitError) { + } +}()); diff --git a/TECHNICAL_ARCHITECTURE_REPORT_CURRENT_STATE.md b/TECHNICAL_ARCHITECTURE_REPORT_CURRENT_STATE.md new file mode 100644 index 0000000..b745de8 --- /dev/null +++ b/TECHNICAL_ARCHITECTURE_REPORT_CURRENT_STATE.md @@ -0,0 +1,753 @@ +# Technical Architecture Report (Current State) + +Date: 2026-07-31 +Scope: Current implementation only (no redesign suggestions) + +--- + +## 1. Database + +Authoritative schema source: [prisma/schema.prisma](prisma/schema.prisma) + +### Full Prisma Schema +The full schema is defined in [prisma/schema.prisma](prisma/schema.prisma). + +### Models related to requested domains + +- Users/Auth + - User + - Account + - Session + - VerificationToken + - ClientAccess +- Projects/Episodes + - Project + - EpisodeDueDate + - Client +- Shots/Tasks/Versions/Reviews + - Shot + - ShotGroup + - Task + - Version + - Comment + - CommentReply + - Annotation + - Approval + - ReviewSession +- Files/Storage + - FootagePlate + - ShotReference + - SystemConfig +- Delivery/Export adjacent + - Shot fields: highResKey, highResFilename, exrOutput, shotVersion + - Version fields: fileUrl, fileName, proxyUrl, thumbnailUrl, posterUrl + +### Relationship summary + +- Client 1:N Project +- Project 1:N Shot +- Project 1:N Task +- Project 1:N ReviewSession +- Project 1:N EpisodeDueDate +- Shot 1:N Task +- Shot 1:N Version +- Shot 1:N FootagePlate +- Shot 1:N ShotReference +- Task 1:N Version +- Version 1:N Comment +- Version 1:N Annotation +- Version 1:N Approval +- Comment 1:N CommentReply +- User has many assigned/created entities across shots, tasks, versions, comments, approvals + +### Existing status enums + +From [prisma/schema.prisma](prisma/schema.prisma): + +- ProjectStatus: ACTIVE, ON_HOLD, COMPLETED, ARCHIVED +- ShotStatus: WAITING, IN_PROGRESS, INTERNAL_REVIEW, READY_FOR_CLIENT, CLIENT_REVIEW, REVISIONS, COMPLETE +- ShotApprovalStatus: PENDING, INTERNALLY_APPROVED, CLIENT_APPROVED +- TaskStatus: TODO, IN_PROGRESS, INTERNAL_REVIEW, CLIENT_REVIEW, CHANGES, DONE +- ApprovalStatus: PENDING_REVIEW, APPROVED, REJECTED, NEEDS_CHANGES +- ReviewStatus: PENDING, INTERNAL_APPROVED, CLIENT_APPROVED, NEEDS_CHANGES, FINAL_APPROVED + +Notes: +- There is no dedicated Delivery model. +- There is no dedicated Export model. +- Delivery/export state is represented by file pointers and shot/version metadata fields. + +--- + +## 2. API + +### 2.1 Shots + +#### External shot APIs + +- GET /api/ext/projects + - URL: /api/ext/projects + - Method: GET + - Purpose: List projects for pipeline tools + - Request body: None + - Response: projects[] with id, name, code, showId, projectType, status, dates, _count + - Source: [app/api/ext/projects/route.ts](app/api/ext/projects/route.ts) + +- GET /api/ext/projects/{projectCode}/episodes + - URL: /api/ext/projects/{projectCode}/episodes + - Method: GET + - Purpose: List distinct episodes and optionally shot payloads per episode + - Request body: None + - Response: project + episodes[]; optional shots[] includes exrOutput/timecodes + - Source: [app/api/ext/projects/[projectCode]/episodes/route.ts](app/api/ext/projects/%5BprojectCode%5D/episodes/route.ts) + +- GET /api/ext/projects/{projectCode}/shots + - URL: /api/ext/projects/{projectCode}/shots + - Method: GET + - Purpose: List shots by project code with filters/pagination + - Request body: None + - Response: project + pagination + shots[] + - Source: [app/api/ext/projects/[projectCode]/shots/route.ts](app/api/ext/projects/%5BprojectCode%5D/shots/route.ts) + +- GET /api/ext/shots + - URL: /api/ext/shots + - Method: GET + - Purpose: Legacy listing by projectId + - Request body: None + - Response: shots[] + total + - Source: [app/api/ext/shots/route.ts](app/api/ext/shots/route.ts) + +- POST /api/ext/shots + - URL: /api/ext/shots + - Method: POST + - Purpose: Create shot from external tool (JSON or multipart thumbnail) + - Request body: + - projectId, scene + - optional episode, description, artistId, priority, fps, frameStart, frameEnd, dueDate, thumbnailUrl, shotGroupName, shotCode + - optional thumbnail file (multipart) + - Response: created shot object + - Source: [app/api/ext/shots/route.ts](app/api/ext/shots/route.ts) + +- GET /api/ext/shots/lookup + - URL: /api/ext/shots/lookup + - Method: GET + - Purpose: Canonical shot lookup by shotCode (+ optional projectCode) + - Request body: None + - Response: shot object with project, artist, tasks, latest version, exrOutput, shotVersion, source/seq timecodes + - Source: [app/api/ext/shots/lookup/route.ts](app/api/ext/shots/lookup/route.ts) + +- GET /api/ext/shots/{shotId} + - URL: /api/ext/shots/{shotId} + - Method: GET + - Purpose: Shot detail by DB id, or byCode mode + - Request body: None + - Response: full shot detail with tasks/latest version/counts + - Source: [app/api/ext/shots/[shotId]/route.ts](app/api/ext/shots/%5BshotId%5D/route.ts) + +- PATCH /api/ext/shots/{shotId} + - URL: /api/ext/shots/{shotId} + - Method: PATCH + - Purpose: Update mutable shot field(s) from pipeline tools + - Request body: shotVersion (v###) currently supported + - Response: success + updated shot id/shotVersion + - Source: [app/api/ext/shots/[shotId]/route.ts](app/api/ext/shots/%5BshotId%5D/route.ts) + +#### Internal shot APIs + +- GET /api/shots/{shotId} + - URL: /api/shots/{shotId} + - Method: GET + - Purpose: Internal dashboard shot detail payload + - Request body: None + - Response: shot + tasks + artists + permissions flags + - Source: [app/api/shots/[shotId]/route.ts](app/api/shots/%5BshotId%5D/route.ts) + +- GET /api/projects/{projectId}/episodes + - URL: /api/projects/{projectId}/episodes + - Method: GET + - Purpose: Internal distinct episode list for project + - Request body: None + - Response: episodes[] + - Source: [app/api/projects/[projectId]/episodes/route.ts](app/api/projects/%5BprojectId%5D/episodes/route.ts) + +### 2.2 Reviews + +- GET /api/review-sessions + - Purpose: list review sessions +- POST /api/review-sessions + - Purpose: create review session token and portal link +- DELETE /api/review-sessions + - Purpose: deactivate review session +- Source: [app/api/review-sessions/route.ts](app/api/review-sessions/route.ts) + +- POST /api/client/{token}/auth + - Purpose: review password check + unlock cookie + - Source: [app/api/client/[token]/auth/route.ts](app/api/client/%5Btoken%5D/auth/route.ts) + +- GET /api/client/{token}/project + - Purpose: client portal project payload (shared shots/versions) + - Source: [app/api/client/[token]/project/route.ts](app/api/client/%5Btoken%5D/project/route.ts) + +- GET /api/client/{token}/versions/{versionId} + - Purpose: client review detail payload + - Source: [app/api/client/[token]/versions/[versionId]/route.ts](app/api/client/%5Btoken%5D/versions/%5BversionId%5D/route.ts) + +- POST /api/client/{token}/comment + - Purpose: client frame comment + - Request body: versionId, frameNumber, timestamp, text + - Response: created comment + - Source: [app/api/client/[token]/comment/route.ts](app/api/client/%5Btoken%5D/comment/route.ts) + +- POST /api/client/{token}/annotation + - Purpose: client annotation write + - Request body: versionId, frameNumber, drawingData, optional color + - Response: annotation + - Source: [app/api/client/[token]/annotation/route.ts](app/api/client/%5Btoken%5D/annotation/route.ts) + +- POST /api/client/{token}/approve + - Purpose: shot-level approve/changes and legacy version-level approval + - Request body: + - shot mode: shotId + action + - version mode: versionId + status + notes + - Response: success + - Source: [app/api/client/[token]/approve/route.ts](app/api/client/%5Btoken%5D/approve/route.ts) + +- GET /api/versions/{versionId}/comments + - Purpose: internal comments read + - Source: [app/api/versions/[versionId]/comments/route.ts](app/api/versions/%5BversionId%5D/comments/route.ts) + +- GET /api/versions/{versionId}/annotations + - Purpose: internal annotations read + - Source: [app/api/versions/[versionId]/annotations/route.ts](app/api/versions/%5BversionId%5D/annotations/route.ts) + +- GET /api/playlist + - Purpose: latest version per shot playlist + - Source: [app/api/playlist/route.ts](app/api/playlist/route.ts) + +### 2.3 File uploads + +- POST /api/upload + - Purpose: authenticated upload to Hetzner via app server + - Body: multipart file (+ type) + - Response: url, key + - Source: [app/api/upload/route.ts](app/api/upload/route.ts) + +- POST /api/upload/local + - Purpose: authenticated video upload path + - Body: multipart file + - Response: url, key + - Source: [app/api/upload/local/route.ts](app/api/upload/local/route.ts) + +- POST /api/upload/presign + - Purpose: direct browser->object-storage upload URL + - Body: fileName, contentType, optional folder + - Response: presignedUrl, key, url + - Source: [app/api/upload/presign/route.ts](app/api/upload/presign/route.ts) + +- GET/POST /api/uploadthing + - Purpose: UploadThing route handler passthrough (if configured) + - Source: [app/api/uploadthing/route.ts](app/api/uploadthing/route.ts) + +- POST /api/batch-upload/presign + - Purpose: presign high-res upload key + - Body: fileName + - Response: presignedUrl, key + - Source: [app/api/batch-upload/presign/route.ts](app/api/batch-upload/presign/route.ts) + +- POST /api/batch-upload/preview + - Purpose: classify upload actions before upload + - Body: projectId, fileNames[] + - Response: items[] with statuses (new-version, rename-and-upload, create-task, update-highres, etc) + - Source: [app/api/batch-upload/preview/route.ts](app/api/batch-upload/preview/route.ts) + +- POST /api/batch-upload/upload + - Purpose: commit highres key or create version records + - Body: + - update-highres: action, shotId, projectId, key, fileName + - version upload: action, shotId, projectId, file, task routing fields + - Response: success + action result + - Source: [app/api/batch-upload/upload/route.ts](app/api/batch-upload/upload/route.ts) + +### 2.4 EXRs / Rendering / Delivery / Storage / Metadata + +- GET /api/files/{...key} + - Purpose: file serving, range requests, local/Hetzner routing + - Source: [app/api/files/[...key]/route.ts](app/api/files/%5B...key%5D/route.ts) + +- GET/POST /api/admin/migration + - Purpose: local uploads migration status and per-key migration to Hetzner + - Source: [app/api/admin/migration/route.ts](app/api/admin/migration/route.ts) + +- POST/DELETE /api/storage-test + - Purpose: upload test object / delete test object + - Source: [app/api/storage-test/route.ts](app/api/storage-test/route.ts) + +- POST/DELETE /api/shots/{shotId}/highres + - Purpose: upload/remove high-res deliverable on shot + - Source: [app/api/shots/[shotId]/highres/route.ts](app/api/shots/%5BshotId%5D/highres/route.ts) + +- GET /api/shots/{shotId}/highres/download + - Purpose: internal presigned download URL for high-res + - Source: [app/api/shots/[shotId]/highres/download/route.ts](app/api/shots/%5BshotId%5D/highres/download/route.ts) + +- GET /api/client/{token}/shots/{shotId}/highres/download + - Purpose: client token-gated presigned high-res download URL + - Source: [app/api/client/[token]/shots/[shotId]/highres/download/route.ts](app/api/client/%5Btoken%5D/shots/%5BshotId%5D/highres/download/route.ts) + +- GET/POST/DELETE /api/shots/{shotId}/references + - Purpose: shot reference image management + - Source: [app/api/shots/[shotId]/references/route.ts](app/api/shots/%5BshotId%5D/references/route.ts) + +- GET /api/storyboard/pdf + - Purpose: server-rendered printable storyboard HTML with metadata options + - Source: [app/api/storyboard/pdf/route.ts](app/api/storyboard/pdf/route.ts) + +Notes: +- No dedicated REST endpoint that runs ffmpeg in this repository. +- No dedicated REST endpoint that performs EXR rendering on the server. +- No dedicated deliveries API namespace. + +--- + +## 3. Storage + +Primary storage abstraction: [lib/storage.ts](lib/storage.ts) + +### Provider modes + +- local +- uploadthing +- s3 +- r2 +- b2 +- minio + +### Dedicated high-res object storage path + +- Hetzner object storage helper methods are used for high-res and presigned direct uploads. +- Config source precedence: + - SystemConfig table keys + - env fallback + +### Folder/key layout in object storage (current code paths) + +- videos/ +- image/ +- highres/ +- storage-test/ + +### Local storage + +- LOCAL_UPLOAD_DIR (default ./uploads) +- Served via /api/files catch-all route + +### File naming conventions + +- Key format: {folder}/{uuid}-{sanitized-file-name} +- Sanitization done by sanitizeFileName to avoid URL/signature problems in object keys + +### EXR locations + +Within web app/runtime: +- EXR references are naming metadata (Shot.exrOutput) and file-serving pathing. + +Within DCC tooling docs/scripts: +- AE/Nuke workflows target shared export roots and per-shot folders. + +### Preview locations + +- Version media URLs stored in Version.fileUrl +- Accessed through app player routes and /api/files routing where applicable + +### Thumbnail locations + +- Shot.thumbnailUrl +- Version.thumbnailUrl +- Can point to /api/files/{key} or external provider URL + +Relevant sources: +- [lib/storage.ts](lib/storage.ts) +- [app/api/files/[...key]/route.ts](app/api/files/%5B...key%5D/route.ts) +- [actions/settings.ts](actions/settings.ts) + +--- + +## 4. Export Pipeline (Current) + +### AE panel + +Documented in repo: +- [VFXReviewConnector.md](VFXReviewConnector.md) +- [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md) + +Implemented panel script also exists on the host AE installation (outside workspace), and behavior aligns with docs: +- Episodes/shots discovery via ext APIs +- Shot lookup API usage for metadata +- Overlay/slate essential property updates (burn-in style overlays) +- Queue EXR / Queue MP4 / Queue MOV render queue actions +- Queue EXR (review convention) +- Import exported EXR and import renders +- Pull picture lock using seq timecodes +- Increment shot version by PATCH call +- Delivery prep that copies/renames EXR files to delivery folder convention + +### External API endpoints used by DCC tools + +- GET /api/ext/projects/{projectCode}/episodes +- GET /api/ext/projects/{projectCode}/shots +- GET /api/ext/shots/lookup +- PATCH /api/ext/shots/{shotId} + +### Existing render scripts and pipelines + +- AE panel render queue automation (external script) +- Nuke connector script creating write-node outputs and shot scripts: + - [VFXReviewConnector.py](VFXReviewConnector.py) + +### ffmpeg scripts + +- None found in this repository. + +### Proxy generation + +- Version.proxyUrl field exists in schema. +- No implemented proxy-generation worker/function found. + +### Thumbnail generation + +- Upload/assignment flows exist. +- No server-side frame-extract thumbnail generator found. + +### Metadata extraction + +- EDL / picture-tracker CSV metadata parsing implemented: + - [lib/edl-utils.ts](lib/edl-utils.ts) + - [actions/shots.ts](actions/shots.ts) + +### Burn-in generation + +- Achieved via DCC overlay/slate layers and essential properties in AE/Nuke tool workflows. +- No ffmpeg burn-in path found. + +--- + +## 5. Review System + +### Current review workflow + +Core status derivation: +- [lib/shot-status.ts](lib/shot-status.ts) + +Server action orchestration: +- [actions/versions.ts](actions/versions.ts) +- [actions/approvals.ts](actions/approvals.ts) +- [actions/tasks.ts](actions/tasks.ts) +- [actions/shots.ts](actions/shots.ts) +- [actions/comments.ts](actions/comments.ts) + +### Internal review + +- Version upload: + - marks previous versions non-latest + - creates new latest version + - moves Task to INTERNAL_REVIEW + - recalculates Shot.status + +### Client review + +- Tokenized ReviewSession links +- Optional password gate with signed cookie unlock +- Client comment/annotation/approval endpoints +- Share/unshare semantics via shot and version visibility fields + +### Task status flow + +Observed statuses: +- TODO +- IN_PROGRESS +- INTERNAL_REVIEW +- CLIENT_REVIEW +- CHANGES +- DONE + +### Shot status flow + +Derived in priority order: +- REVISIONS (any task CHANGES) +- COMPLETE (shotApprovalStatus CLIENT_APPROVED) +- CLIENT_REVIEW / READY_FOR_CLIENT (internally approved + share flag) +- IN_PROGRESS (task TODO/IN_PROGRESS) +- INTERNAL_REVIEW (tasks exist) +- WAITING (no tasks) + +### Approval process + +- Version-level approvals create Approval rows and update Version.approvalStatus +- Shot-level client actions supported via client approve endpoint and shot actions + +Reference doc in repo: +- [Shot task workflow.md](Shot%20task%20workflow.md) + +--- + +## 6. Authentication + +### External tools + +- /api/ext/* routes authenticate using API_SECRET_KEY via: + - Authorization: Bearer + - x-api-key header + +### Client review links + +- tokenized route with ReviewSession lookup +- optional password hash validation and signed unlock cookie + +### App users + +- NextAuth credentials provider +- bcrypt password hash compare +- JWT session strategy + +### Middleware behavior + +- Route classes allowed through middleware auth gate: + - /api/ext/ + - /api/client/ + - /api/display/ + - /api/files/ + - /api/uploadthing +- Per-route auth still enforced in handlers + +Sources: +- [auth.ts](auth.ts) +- [auth.config.ts](auth.config.ts) +- [middleware.ts](middleware.ts) +- [lib/review-auth.ts](lib/review-auth.ts) + +--- + +## 7. Existing Background Jobs + +### Cron jobs +- None found. + +### Queues +- None found. + +### Workers +- None found. + +### Polling services +- Display devices poll: + - /api/display/events + - /api/dashboard/stats + +### Docker containers +- vfxreview app container +- postgres container +- Source: [docker-compose.yml](docker-compose.yml) + +### Scheduled tasks +- Startup migration commands in container entrypoint: + - [entrypoint.sh](entrypoint.sh) +- AE panel Prep Delivery launches background PowerShell copy scripts on workstation (outside web app runtime) + +--- + +## 8. After Effects Integration (Implemented Features) + +Repo-level references: +- [VFXReviewConnector.md](VFXReviewConnector.md) +- [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md) + +Implemented capabilities observed/documented: + +- API calls + - episodes/shots listing and shot lookup + - shot version PATCH +- Authentication + - bearer API token in script +- Comp discovery + - shot code extraction from comp names and dropdown selection +- Render queue integration + - EXR Sequence + - REVIEW_PREVIEW (MP4) + - 4444 Tri (MOV) + - EXR review sequence queue variant +- Output path generation + - shot/version naming conventions + export root paths +- Shot lookup + - uses /api/ext/shots/lookup for metadata and naming decisions +- Upload features + - no direct upload-to-web API flow in panel docs/script (render/output handled in DCC/filesystem) +- Burn-in generation + - overlay and slate essential properties +- Review integration + - preview comp build and version increment sync + +--- + +## 9. Configuration + +### Environment variables + +Primary reference: [.env.example](.env.example) + +Observed vars in code/docs include: + +- DATABASE_URL +- NEXTAUTH_SECRET +- NEXTAUTH_URL +- NEXT_PUBLIC_APP_URL +- NEXT_PUBLIC_APP_NAME +- API_SECRET_KEY +- AUTH_SECRET +- STORAGE_PROVIDER +- LOCAL_UPLOAD_DIR +- AWS_ACCESS_KEY_ID +- AWS_SECRET_ACCESS_KEY +- AWS_REGION +- AWS_BUCKET_NAME +- R2_ACCESS_KEY_ID +- R2_SECRET_ACCESS_KEY +- R2_ACCOUNT_ID +- R2_BUCKET_NAME +- R2_PUBLIC_URL +- B2_APPLICATION_KEY_ID +- B2_APPLICATION_KEY +- B2_BUCKET_NAME +- B2_ENDPOINT +- MINIO_ENDPOINT +- MINIO_ACCESS_KEY +- MINIO_SECRET_KEY +- MINIO_BUCKET_NAME +- HETZNER_ENDPOINT +- HETZNER_ACCESS_KEY +- HETZNER_SECRET_KEY +- HETZNER_BUCKET_NAME +- UPLOADTHING_SECRET +- UPLOADTHING_APP_ID +- EMAIL_FROM +- EMAIL_SERVER_HOST +- EMAIL_SERVER_PORT +- EMAIL_SERVER_USER +- EMAIL_SERVER_PASSWORD +- SLACK_DEFAULT_WEBHOOK + +### Storage configuration + +- Provider abstraction in [lib/storage.ts](lib/storage.ts) +- Hetzner overrides in [actions/settings.ts](actions/settings.ts) and SystemConfig table + +### Render configuration + +- No server-side renderer configuration module found. +- DCC render templates are documented in [VFXReviewConnector.md](VFXReviewConnector.md). + +### ffmpeg configuration + +- None found in repository. + +### Object storage configuration + +- Implemented for AWS S3/R2/B2/MinIO + dedicated Hetzner helper path + +--- + +## 10. Existing Utility Functions (Reusable) + +### Metadata extraction + +- parseEdlCsv +- parsePictureTrackerCsv +- Source: [lib/edl-utils.ts](lib/edl-utils.ts) + +### File scanning + +- Local upload tree walker in migration route +- Nuke connector plate/render directory scanners +- Sources: + - [app/api/admin/migration/route.ts](app/api/admin/migration/route.ts) + - [VFXReviewConnector.py](VFXReviewConnector.py) + +### EXR sequence detection + +- Nuke connector helper sequence detectors and pattern conversion +- Source: [VFXReviewConnector.py](VFXReviewConnector.py) + +### MOV generation + +- No server-side MOV generation utility found. +- MOV outputs are DCC render queue outputs in external scripts/docs. + +### Checksums + +- No checksum utility found. + +### Frame counting + +- durationToFrameCount and frame math helpers +- Source: [lib/frame-utils.ts](lib/frame-utils.ts) + +### Timecode extraction/conversion + +- frameToTimecode / formatTimecode +- CSV timecode validation/parsing +- Sources: + - [lib/frame-utils.ts](lib/frame-utils.ts) + - [lib/utils.ts](lib/utils.ts) + - [lib/edl-utils.ts](lib/edl-utils.ts) + +--- + +## 11. High-Level Architecture Diagram + +```mermaid +flowchart LR + A[AE Panel / Nuke Connector\nExternal DCC tools] -->|API key auth| B[Next.js App Router APIs] + B --> C[Prisma ORM] + C --> D[(PostgreSQL)] + + B --> E[Storage Abstraction] + E --> F[(Hetzner Object Storage)] + E --> G[(S3/R2/B2/MinIO)] + B --> H[(Local uploads dir)] + + I[Internal reviewers\nNextAuth session] --> B + J[Client reviewers\nToken review sessions] --> B + + K[ESP32 display client] -->|x-display-key polling| B + + A --> L[Shared filesystem render roots\nEXR/MP4/MOV outputs] + L -->|served/linked via app metadata| B +``` + +--- + +## 12. Files Inspected (Primary) + +- [prisma/schema.prisma](prisma/schema.prisma) +- [lib/storage.ts](lib/storage.ts) +- [lib/shot-status.ts](lib/shot-status.ts) +- [lib/review-auth.ts](lib/review-auth.ts) +- [lib/edl-utils.ts](lib/edl-utils.ts) +- [lib/frame-utils.ts](lib/frame-utils.ts) +- [lib/utils.ts](lib/utils.ts) +- [auth.ts](auth.ts) +- [auth.config.ts](auth.config.ts) +- [middleware.ts](middleware.ts) +- [next.config.ts](next.config.ts) +- [.env.example](.env.example) +- [docker-compose.yml](docker-compose.yml) +- [Dockerfile](Dockerfile) +- [entrypoint.sh](entrypoint.sh) +- [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md) +- [VFXReviewConnector.md](VFXReviewConnector.md) +- [VFXReviewConnector.py](VFXReviewConnector.py) +- [# VFXReview Connector for Nuke.md](#%20VFXReview%20Connector%20for%20Nuke.md) +- API handlers under [app/api](app/api) +- Server actions under [actions](actions) + +--- + +End of current-state report. diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx new file mode 100644 index 0000000..852b778 --- /dev/null +++ b/VFXReviewConnector.jsx @@ -0,0 +1,2922 @@ +/* + VFXReview Connector + Adobe After Effects 2024+ dockable ScriptUI panel +*/ + +var TOKEN = "am3O0PWUtqMJkAqsZ+bO7lho4cxQItxgukF6FHteAx4="; +var BASE_URL = "http://localhost:3000"; +var PROJECT_CODE = "UNG_S1"; +var EXPORT_ROOT = "V:/_EXPORTS/UNG"; +var FOOTAGE_ROOT = "V:/_FOOTAGE/UNG"; +var BANNER_IMAGE_PATH = "V:/VFXReviewConnector/banner.png"; +// Optional: artist email for pipeline export attribution (Queue Export) +var ARTIST_EMAIL = "chris@twotalesanimation.com"; +// Handle frames included in plates before the shot's source TC in-point. +// Used to derive the plate's own start timecode from the API's timecodeStart. +var HANDLE_FRAMES = 8; + +(function VFXReviewConnector(thisObj) { + var uiState = { + episodeDropdown: null, + shotDropdown: null, + selectionText: null, + lastActionText: null, + pipelineStatusText: null, + urgentCheckbox: null, + vfxScopeField: null, + submissionNoteField: null + }; + + var pipelineState = { + pollShotCode: null, + pollTaskId: 0, + prefilledShotCode: null + }; + + var buildState = { + lastStartSource: "", + lastStartFrames: 0 + }; + + function parseJSON(text) { + if (!text) { + return null; + } + + try { + if (typeof JSON !== "undefined" && JSON.parse) { + return JSON.parse(text); + } + } catch (jsonError) { + } + + try { + return eval("(" + text + ")"); + } catch (evalError) { + } + + return null; + } + + function getSelectedComps() { + var comps = []; + var selection; + var i; + + if (!app.project) { + return comps; + } + + selection = app.project.selection; + for (i = 0; i < selection.length; i += 1) { + if (selection[i] instanceof CompItem) { + comps.push(selection[i]); + } + } + + return comps; + } + + function getShotData(shotCode) { + var url; + var command; + var response; + var parsed; + + try { + url = BASE_URL + "/api/ext/shots/lookup" + + "?shotCode=" + encodeURIComponent(shotCode) + + "&projectCode=" + PROJECT_CODE; + + command = "curl -s " + + "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Accept: application/json\" " + + "\"" + url + "\""; + + response = system.callSystem(command); + } catch (callError) { + return null; + } + + parsed = parseJSON(response); + if (!parsed) { + return null; + } + + return parsed; + } + + function getAPIData(url) { + var command; + var response; + var parsed; + + try { + command = "curl -s " + + "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Accept: application/json\" " + + "\"" + url + "\""; + + response = system.callSystem(command); + } catch (callError) { + return null; + } + + parsed = parseJSON(response); + if (!parsed) { + return null; + } + + return parsed; + } + + function getListFromResponse(data, keyName) { + if (!data) { + return []; + } + + if (data.length !== undefined) { + return data; + } + + if (keyName && data[keyName] && data[keyName].length !== undefined) { + return data[keyName]; + } + + if (data.data && data.data.length !== undefined) { + return data.data; + } + + if (data.results && data.results.length !== undefined) { + return data.results; + } + + if (data.items && data.items.length !== undefined) { + return data.items; + } + + return []; + } + + function padEpisodeNumber(value) { + var text = String(value); + + while (text.length < 3) { + text = "0" + text; + } + + return text; + } + + function deriveEpisodeCode(value) { + var text = String(value); + + if (/^[A-Z]{3}_\d{3}$/.test(text)) { + return text; + } + + if (/^\d+$/.test(text)) { + return "UNG_" + padEpisodeNumber(text); + } + + return text; + } + + function deriveEpisodeAPIValue(value) { + var text = String(value); + var match = text.match(/[A-Z]{3}_(\d{3})/); + + if (match && match.length > 1) { + return String(parseInt(match[1], 10)); + } + + return text; + } + + function getEpisodeValue(item) { + if (item === null || item === undefined) { + return ""; + } + + if (typeof item !== "object") { + return deriveEpisodeAPIValue(item); + } + + return deriveEpisodeAPIValue(item.episode || item.episodeNumber || item.number || item.id || item.code || item.episodeCode || item.name || ""); + } + + function getEpisodeLabel(item) { + if (item === null || item === undefined) { + return ""; + } + + if (typeof item !== "object") { + return deriveEpisodeCode(item); + } + + return String(item.episodeCode || item.code || item.name || deriveEpisodeCode(getEpisodeValue(item))); + } + + function getShotCodeValue(item) { + if (item === null || item === undefined) { + return ""; + } + + if (typeof item !== "object") { + return String(item); + } + + return String(item.shotCode || item.code || item.name || ""); + } + + function getEpisodesFromAPI() { + var url = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/episodes"; + var data = getAPIData(url); + + return getListFromResponse(data, "episodes"); + } + + function getShotsFromAPI(episodeValue) { + var url = BASE_URL + "/api/ext/projects/" + PROJECT_CODE + "/shots?episode=" + encodeURIComponent(episodeValue); + var data = getAPIData(url); + + return getListFromResponse(data, "shots"); + } + + function normalizeShotData(data) { + if (!data) { + return null; + } + + if (data.shotCode) { + return data; + } + + if (data.shot && data.shot.shotCode) { + return data.shot; + } + + if (data.data && data.data.shotCode) { + return data.data; + } + + if (data.result && data.result.shotCode) { + return data.result; + } + + if (data.results && data.results.length && data.results[0].shotCode) { + return data.results[0]; + } + + return null; + } + + function ensureFolder(path) { + var folder = new Folder(path); + var parent; + + if (folder.exists) { + return true; + } + + parent = folder.parent; + if (parent && !parent.exists) { + if (!ensureFolder(parent.fsName)) { + return false; + } + } + + try { + return folder.create(); + } catch (folderError) { + return false; + } + } + + function setCompStartFrame(comp, frameNumber) { + try { + comp.displayStartFrame = frameNumber; + return; + } catch (frameError) { + } + + try { + comp.displayStartTime = frameNumber / comp.frameRate; + } catch (timeError) { + } + } + + function deselectAllProjectItems() { + var i; + + try { + for (i = 1; i <= app.project.numItems; i += 1) { + app.project.item(i).selected = false; + } + } catch (deselectError) { + } + } + + // AE scripting exposes no direct read of a footage item's embedded start + // timecode. Recreate the UI behaviour instead: "New Comp from Selection" + // inherits the footage's start timecode from the file metadata, so build a + // throwaway comp, read the start time AE gave it, then delete it. + // + // Returns null when the probe could not run (menu command unavailable or + // disabled), so callers can fall back to the API timecode. + function probeFootageStartFrames(footageItem, fps) { + var commandId; + var projectPanelId; + var existingIds = {}; + var probeComp = null; + var frames = null; + var i; + var item; + + try { + commandId = app.findMenuCommandId("New Comp from Selection"); + if (!commandId) { + return null; + } + + for (i = 1; i <= app.project.numItems; i += 1) { + existingIds[app.project.item(i).id] = true; + } + + // executeCommand mirrors the menu bar, so the Project panel has to + // be frontmost or "New Comp from Selection" is greyed out and the + // call silently does nothing. + try { + projectPanelId = app.findMenuCommandId("Project"); + if (projectPanelId) { + app.executeCommand(projectPanelId); + } + } catch (focusError) { + } + + deselectAllProjectItems(); + footageItem.selected = true; + app.executeCommand(commandId); + + // Only ever touch a comp that did not exist before the probe — + // never app.project.activeItem, which is an existing open comp + // when the command did not run. + for (i = app.project.numItems; i >= 1; i -= 1) { + item = app.project.item(i); + if (item instanceof CompItem && !existingIds[item.id]) { + probeComp = item; + break; + } + } + + if (probeComp) { + frames = Math.round((probeComp.displayStartTime || 0) * (fps || probeComp.frameRate || 24)); + } + } catch (probeError) { + frames = null; + } + + try { + if (probeComp) { + probeComp.remove(); + } + } catch (removeError) { + } + + return frames; + } + + // The plate's own start timecode: AE metadata when we can read it, + // otherwise the API's source clip timecode less the handle frames — the + // same convention the Set Start Timecode button uses. + function getFootageStartFrames(footageItem, apiShot, fps) { + var probed = probeFootageStartFrames(footageItem, fps); + var frames; + + if (probed !== null && probed > 0) { + return { frames: probed, source: "footage metadata" }; + } + + if (apiShot && apiShot.timecodeStart) { + frames = parseTimecodeToFrames(apiShot.timecodeStart, fps) - HANDLE_FRAMES; + if (frames > 0) { + return { frames: frames, source: "API timecode " + apiShot.timecodeStart + " -" + HANDLE_FRAMES + "f" }; + } + } + + return { frames: 0, source: probed === 0 ? "footage starts at 00:00:00:00" : "no timecode found" }; + } + + function findComp(name) { + var i; + var item; + + if (!app.project) { + return null; + } + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof CompItem && item.name === name) { + return item; + } + } + + return null; + } + + function findFolder(name) { + var i; + var item; + + if (!app.project) { + return null; + } + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof FolderItem && item.name === name) { + return item; + } + } + + return null; + } + + function findEpisodeProjectFolder(episodeCode) { + var shotsRoot = findFolder("__SHOTS"); + var i; + var item; + + if (!shotsRoot) { + return findFolder(episodeCode); + } + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof FolderItem && item.name === episodeCode && item.parentFolder === shotsRoot) { + return item; + } + } + + return findFolder(episodeCode); + } + + function getEpisodeRootFolder(episodeCode) { + return new Folder(FOOTAGE_ROOT + "/" + episodeCode); + } + + function getEpisodeCodesFromDisk() { + var root = new Folder(FOOTAGE_ROOT); + var folders; + var codes = []; + var i; + + if (!root.exists) { + return codes; + } + + folders = root.getFiles(); + for (i = 0; i < folders.length; i += 1) { + if (folders[i] instanceof Folder) { + codes.push(folders[i].name); + } + } + + codes.sort(); + return codes; + } + + function addUniqueText(values, value) { + var i; + + for (i = 0; i < values.length; i += 1) { + if (values[i] === value) { + return; + } + } + + values.push(value); + } + + function getShotCodesForEpisode(episodeCode) { + var root = getEpisodeRootFolder(episodeCode); + var folders; + var shotCodes = []; + var i; + var shotCode; + + if (!root.exists) { + return shotCodes; + } + + folders = root.getFiles(); + for (i = 0; i < folders.length; i += 1) { + if (folders[i] instanceof Folder) { + shotCode = extractShotCode(folders[i].name); + if (shotCode && folders[i].name.indexOf(shotCode) === 0) { + addUniqueText(shotCodes, shotCode); + } + } + } + + shotCodes.sort(); + return shotCodes; + } + + function findSequenceFolders(shotCode, episodeCode) { + var root = getEpisodeRootFolder(episodeCode); + var folders; + var matches = []; + var i; + + if (!root.exists) { + return matches; + } + + folders = root.getFiles(); + for (i = 0; i < folders.length; i += 1) { + if (folders[i] instanceof Folder && folders[i].name.indexOf(shotCode + "_") === 0) { + matches.push(folders[i]); + } + } + + matches.sort(); + return matches; + } + + function importEXRSequence(folder) { + var files = folder.getFiles("*.exr"); + var importOptions; + var footage; + + if (files.length < 1) { + return null; + } + + files.sort(); + importOptions = new ImportOptions(files[0]); + importOptions.sequence = true; + footage = app.project.importFile(importOptions); + footage.mainSource.conformFrameRate = 24; + return footage; + } + + function addOCIOToLayer(layer) { + var ocio; + var outputProp; + + try { + ocio = layer.property("Effects").addProperty("OCIO Color Space Transform"); + ocio.enabled = false; + } catch (ocioError) { + return; + } + + try { + outputProp = ocio.property("Output Color Space"); + } catch (propNameError) { + outputProp = null; + } + if (!outputProp) { + try { + outputProp = ocio.property(2); + } catch (propIdxError) { + outputProp = null; + } + } + if (outputProp) { + try { + outputProp.setValue(93); + } catch (setValueError) { + } + } + } + + function getDateString() { + var today = new Date(); + + return today.getFullYear() + + "/" + + ("0" + (today.getMonth() + 1)).slice(-2) + + "/" + + ("0" + today.getDate()).slice(-2); + } + + function parseTimecodeToSeconds(tc, fps) { + var parts = String(tc).split(/[:;]/); + var hh; + var mm; + var ss; + var ff; + + if (parts.length < 4) { + return 0; + } + + hh = parseInt(parts[0], 10); + mm = parseInt(parts[1], 10); + ss = parseInt(parts[2], 10); + ff = parseInt(parts[3], 10); + + return hh * 3600 + mm * 60 + ss + ff / (fps || 24); + } + + function parseTimecodeToFrames(tc, fps) { + var parts = String(tc).split(/[:;]/); + var hh; + var mm; + var ss; + var ff; + + if (parts.length < 4) { + return 0; + } + + hh = parseInt(parts[0], 10); + mm = parseInt(parts[1], 10); + ss = parseInt(parts[2], 10); + ff = parseInt(parts[3], 10); + + return (hh * 3600 + mm * 60 + ss) * Math.round(fps || 24) + ff; + } + + function findOverlayLayer(comp) { + var i; + var layer; + + for (i = 1; i <= comp.numLayers; i += 1) { + layer = comp.layer(i); + + try { + if (layer.source && layer.source instanceof CompItem && layer.source.name === "UNG_VFX_OVERLAY") { + return layer; + } + } catch (layerError) { + } + } + + return null; + } + + function setEssentialProperty(layer, propName, value) { + try { + layer.property("Essential Properties").property(propName).setValue(String(value)); + return true; + } catch (propertyError) { + return false; + } + } + + function applyOutputModuleTemplate(outputModule, templateName) { + try { + outputModule.applyTemplate(templateName); + return true; + } catch (templateError) { + return false; + } + } + + function queueCompWithOutput(comp, templateName, outputPath) { + var renderQueueItem; + var outputModule; + + try { + renderQueueItem = app.project.renderQueue.items.add(comp); + outputModule = renderQueueItem.outputModule(1); + if (!applyOutputModuleTemplate(outputModule, templateName)) { + renderQueueItem.remove(); + return false; + } + outputModule.file = new File(outputPath); + return true; + } catch (queueError) { + try { + if (renderQueueItem) { + renderQueueItem.remove(); + } + } catch (removeError) { + } + return false; + } + } + + function updateStatus(selectedCount, message) { + if (uiState.selectionText) { + uiState.selectionText.text = selectedCount + " comps selected"; + } + if (uiState.lastActionText) { + uiState.lastActionText.text = message || ""; + } + } + + function refreshSelection() { + var comps = getSelectedComps(); + var message = comps.length + " comps selected"; + updateStatus(comps.length, message); + return comps; + } + + function extractShotCode(name) { + var match = String(name).match(/([A-Z]{3}_\d{3}_\d{3}_\d{3})/); + if (match && match.length > 1) { + return match[1]; + } + return null; + } + + function getShotCodeFromComp(comp) { + return extractShotCode(comp.name) || comp.name; + } + + function isPreviewComp(comp) { + return /_PREVIEW$/.test(comp.name); + } + + function getMainCompForSelection(comp) { + var shotCode = getShotCodeFromComp(comp); + + if (isPreviewComp(comp)) { + return findComp(shotCode); + } + + return comp; + } + + function getPreviewCompName(shotCode) { + return shotCode + "_PREVIEW"; + } + + function getPreviewCompForShot(shotCode) { + return findComp(getPreviewCompName(shotCode)); + } + + function getTargetComps() { + var comps = getSelectedComps(); + + if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) { + comps.push(app.project.activeItem); + } + + return comps; + } + + function disableLayerByName(comp, layerName) { + var i; + var layer; + + for (i = 1; i <= comp.numLayers; i += 1) { + try { + layer = comp.layer(i); + if (layer.name === layerName || (layer.source && layer.source instanceof CompItem && layer.source.name === layerName)) { + layer.enabled = false; + } + } catch (disableError) { + } + } + } + + function updateOverlayForComp(comp, shotCode, apiData) { + var overlayComp = findComp("UNG_VFX_OVERLAY"); + var overlayLayer; + var essentialProperties; + var overlayShotCode; + + if (!overlayComp || !apiData || !apiData.shot) { + return false; + } + + try { + overlayShotCode = apiData.shot.shotCode || shotCode; + overlayLayer = findOverlayLayer(comp); + if (!overlayLayer) { + overlayLayer = comp.layers.add(overlayComp); + overlayLayer.moveToBeginning(); + } + + overlayLayer.enabled = true; + essentialProperties = overlayLayer.property("Essential Properties"); + essentialProperties.property("DATE").setValue(getDateString()); + essentialProperties.property("SHOT NAME").setValue(overlayShotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001")); + return true; + } catch (overlayError) { + return false; + } + } + + function makeBatchResult(total) { + return { + total: total, + queued: 0, + renamed: 0, + skipped: 0, + failures: [] + }; + } + + function addFailure(result, compName, reason) { + result.skipped += 1; + result.failures.push(compName + ": " + reason); + } + + function fixCompNames() { + var comps = getSelectedComps(); + var renamed = 0; + var i; + var shotCode; + + app.beginUndoGroup("VFXReview Connector - Fix Comp Names"); + for (i = 0; i < comps.length; i += 1) { + shotCode = extractShotCode(comps[i].name); + if (shotCode && comps[i].name !== shotCode) { + comps[i].name = shotCode; + renamed += 1; + } + } + app.endUndoGroup(); + + updateStatus(comps.length, renamed + " comp(s) renamed."); + } + + function queueEXR() { + var comps = getSelectedComps(); + var queued = 0; + var i; + var comp; + var lookupCode; + var shot; + var result = makeBatchResult(comps.length); + var shotCode; + var exrOutput; + var folderPath; + var outputPath; + + app.beginUndoGroup("VFXReview Connector - Queue EXR"); + for (i = 0; i < comps.length; i += 1) { + comp = comps[i]; + lookupCode = getShotCodeFromComp(comp); + updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")"); + shot = normalizeShotData(getShotData(lookupCode)); + + if (!shot || !shot.shotCode || !shot.exrOutput) { + addFailure(result, comp.name, "API lookup failed or missing EXR output"); + continue; + } + + shotCode = shot.shotCode; + exrOutput = shot.exrOutput; + disableLayerByName(comp, "UNG_VFX_OVERLAY"); + disableLayerByName(comp, "_SHOW LUT"); + + folderPath = EXPORT_ROOT + "/" + shotCode; + if (!ensureFolder(folderPath)) { + addFailure(result, comp.name, "Could not create output folder"); + continue; + } + + outputPath = folderPath + "/" + exrOutput + ".[#####].exr"; + if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) { + queued += 1; + result.queued += 1; + } else { + addFailure(result, comp.name, "Could not queue EXR render"); + } + } + app.endUndoGroup(); + + updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped."); + } + + function addOverlay() { + var comps = getSelectedComps(); + var overlayComp = findComp("UNG_VFX_OVERLAY"); + var overlayLayer; + var added = 0; + var updated = 0; + var skipped = 0; + var i; + var shotCode; + var data; + var dateString; + var essentialProperties; + + if (comps.length < 1 && app.project && app.project.activeItem instanceof CompItem) { + comps.push(app.project.activeItem); + } + + if (comps.length < 1) { + updateStatus(0, "Please select a comp first."); + return; + } + + if (!overlayComp) { + updateStatus(comps.length, "UNG_VFX_OVERLAY not found."); + return; + } + + dateString = getDateString(); + + app.beginUndoGroup("VFXReview Connector - Add Overlay"); + for (i = 0; i < comps.length; i += 1) { + shotCode = getShotCodeFromComp(comps[i]); + updateStatus(comps.length, "Updating overlay for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + data = getShotData(shotCode); + + if (!data || !data.shot) { + skipped += 1; + continue; + } + + try { + overlayLayer = findOverlayLayer(comps[i]); + if (!overlayLayer) { + overlayLayer = comps[i].layers.add(overlayComp); + overlayLayer.moveToBeginning(); + added += 1; + } + + essentialProperties = overlayLayer.property("Essential Properties"); + essentialProperties.property("DATE").setValue(dateString); + essentialProperties.property("SHOT NAME").setValue(shotCode + "_cmp_TT_" + (data.shot.shotVersion || "v001")); + updated += 1; + } catch (overlayError) { + skipped += 1; + } + } + app.endUndoGroup(); + + updateStatus(comps.length, updated + " overlay(s) updated. " + added + " added. " + skipped + " skipped."); + } + + function buildPreviewForShot(shotComp, shotCode, apiData) { + var templateComp = findComp("UNG_EXPORT_TEMPLATE"); + var previewsFolder = findFolder("_PREVIEWS"); + var shot; + var previewComp; + var slateLayer; + var frameCount; + var essentialProperties; + var shotLayer; + + if (!templateComp || !apiData || !apiData.shot) { + return null; + } + + shot = apiData.shot; + previewComp = null; + + try { + previewComp = templateComp.duplicate(); + previewComp.name = getPreviewCompName(shotCode); + + if (previewsFolder) { + previewComp.parentFolder = previewsFolder; + } + + previewComp.layer("SHOT").replaceSource(shotComp, false); + previewComp.layer("THUMBNAIL").replaceSource(shotComp, false); + + slateLayer = previewComp.layer("NETFLIX_SLATE"); + frameCount = Math.round(shotComp.duration * shotComp.frameRate); + essentialProperties = slateLayer.property("Essential Properties"); + + essentialProperties.property(3).setValue(shotCode + "_cmp_TT_" + (shot.shotVersion || "v001")); + setEssentialProperty(slateLayer, "Date", getDateString()); + setEssentialProperty(slateLayer, "Desc", shot.description); + setEssentialProperty(slateLayer, "Notes", shot.notes); + essentialProperties.property(10).setValue(shotCode); + setEssentialProperty(slateLayer, "Episode", shot.episode); + setEssentialProperty(slateLayer, "Scene", shot.scene); + setEssentialProperty(slateLayer, "Frames", frameCount); + + shotLayer = previewComp.layer("SHOT"); + var targetOutPoint = shotLayer.inPoint + shotComp.duration; + if (targetOutPoint > previewComp.duration) { + previewComp.duration = targetOutPoint; + } + shotLayer.outPoint = targetOutPoint; + previewComp.duration = targetOutPoint; + + return previewComp; + } catch (previewError) { + try { + if (previewComp) { + previewComp.remove(); + } + } catch (removePreviewError) { + } + return null; + } + } + + function buildShotFromCode(episodeCode, shotCode) { + var existingShotComp = findComp(shotCode); + var sequenceFolders; + var footageItems = []; + var footageFolder = findFolder("_FOOTAGE_4K"); + var precompFolder = findFolder("_PRECOMPS"); + var episodeProjectFolder = findEpisodeProjectFolder(episodeCode); + var apiData; + var footage; + var footageComp; + var shotComp; + var footageLayer; + var showLut; + var lutLayer; + var maxDuration = 0; + var width = 1920; + var height = 1080; + var primaryFootage = null; + var startInfo; + var i; + + apiData = getShotData(shotCode); + if (!apiData || !apiData.shot) { + return null; + } + + if (existingShotComp) { + updateOverlayForComp(existingShotComp, shotCode, apiData); + return existingShotComp; + } + + sequenceFolders = findSequenceFolders(shotCode, episodeCode); + if (sequenceFolders.length < 1) { + return null; + } + + for (i = 0; i < sequenceFolders.length; i += 1) { + footage = importEXRSequence(sequenceFolders[i]); + if (footage) { + footage.name = sequenceFolders[i].name; + if (footageFolder) { + footage.parentFolder = footageFolder; + } + footageItems.push(footage); + + if (footage.duration > maxDuration) { + maxDuration = footage.duration; + width = footage.width; + height = footage.height; + primaryFootage = footage; + } + } + } + + if (footageItems.length < 1) { + return null; + } + if (!primaryFootage) { + primaryFootage = footageItems[0]; + } + + // Both comps start at the plate's native start timecode (as AE would + // when creating a comp from the footage), not at frame 0. + startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24); + buildState.lastStartSource = startInfo.source; + buildState.lastStartFrames = startInfo.frames; + + footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24); + setCompStartFrame(footageComp, startInfo.frames); + if (precompFolder) { + footageComp.parentFolder = precompFolder; + } + + for (i = footageItems.length - 1; i >= 0; i -= 1) { + footageLayer = footageComp.layers.add(footageItems[i]); + addOCIOToLayer(footageLayer); + } + + shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24); + setCompStartFrame(shotComp, startInfo.frames); + if (episodeProjectFolder) { + shotComp.parentFolder = episodeProjectFolder; + } + + shotComp.layers.add(footageComp); + + showLut = findComp("_SHOW LUT"); + if (showLut) { + lutLayer = shotComp.layers.add(showLut); + lutLayer.collapseTransformation = true; + } + + updateOverlayForComp(shotComp, shotCode, apiData); + return shotComp; + } + + function buildShotFromDropdown() { + var episodeCode; + var shotCode; + var shotComp; + + if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) { + updateStatus(getSelectedComps().length, "Choose an episode and shot first."); + return; + } + + episodeCode = uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text); + shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text; + + app.beginUndoGroup("VFXReview Connector - Build Shot"); + updateStatus(getSelectedComps().length, "Building shot " + shotCode); + shotComp = buildShotFromCode(episodeCode, shotCode); + app.endUndoGroup(); + + if (shotComp) { + updateStatus(getSelectedComps().length, "Shot built: " + shotCode + + " (start frame " + buildState.lastStartFrames + + (buildState.lastStartSource ? " from " + buildState.lastStartSource : "") + ")"); + } else { + updateStatus(getSelectedComps().length, "Shot build failed: " + shotCode); + } + } + + function buildPreviews() { + var comps = getTargetComps(); + var templateComp = findComp("UNG_EXPORT_TEMPLATE"); + var created = 0; + var skipped = 0; + var i; + var shotComp; + var shotCode; + var apiData; + var previewComp; + + if (comps.length < 1) { + updateStatus(0, "Please select/open a shot comp."); + return; + } + + if (!templateComp) { + updateStatus(comps.length, "UNG_EXPORT_TEMPLATE not found."); + return; + } + + app.beginUndoGroup("VFXReview Connector - Build Preview"); + for (i = 0; i < comps.length; i += 1) { + shotCode = getShotCodeFromComp(comps[i]); + shotComp = getMainCompForSelection(comps[i]); + updateStatus(comps.length, "Building preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + + if (!shotComp) { + skipped += 1; + continue; + } + + apiData = getShotData(shotCode); + if (!apiData || !apiData.shot) { + skipped += 1; + continue; + } + + previewComp = buildPreviewForShot(shotComp, shotCode, apiData); + if (previewComp) { + created += 1; + } else { + skipped += 1; + } + } + app.endUndoGroup(); + + updateStatus(comps.length, created + " preview(s) built. " + skipped + " skipped."); + } + + function queueReview(templateName, extension, label) { + var comps = getTargetComps(); + var queued = 0; + var i; + var result = makeBatchResult(comps.length); + var shotCode; + var apiData; + var mainComp; + var previewComp; + var outputPath; + + app.beginUndoGroup("VFXReview Connector - Queue " + label); + if (!ensureFolder(EXPORT_ROOT)) { + app.endUndoGroup(); + updateStatus(comps.length, "Could not create export folder."); + return; + } + + for (i = 0; i < comps.length; i += 1) { + shotCode = getShotCodeFromComp(comps[i]); + updateStatus(comps.length, "Preparing " + label + " preview for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + apiData = getShotData(shotCode); + if (!apiData || !apiData.shot) { + addFailure(result, comps[i].name, "Shot lookup failed"); + continue; + } + + mainComp = getMainCompForSelection(comps[i]); + if (!mainComp) { + addFailure(result, comps[i].name, "Main shot comp not found"); + continue; + } + + updateOverlayForComp(mainComp, shotCode, apiData); + + if (isPreviewComp(comps[i])) { + previewComp = comps[i]; + } else { + previewComp = getPreviewCompForShot(shotCode); + if (!previewComp) { + previewComp = buildPreviewForShot(mainComp, shotCode, apiData); + } + } + + if (!previewComp) { + addFailure(result, comps[i].name, "Preview comp not found or could not be created"); + continue; + } + + updateStatus(comps.length, "Queueing " + label + " for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + outputPath = EXPORT_ROOT + "/" + shotCode + "_cmp_TT_" + (apiData.shot.shotVersion || "v001") + "." + extension; + if (queueCompWithOutput(previewComp, templateName, outputPath)) { + queued += 1; + result.queued += 1; + } else { + addFailure(result, comps[i].name, "Could not queue " + label + " render"); + } + } + app.endUndoGroup(); + + updateStatus(comps.length, queued + " " + label + " renders queued. " + result.skipped + " skipped."); + } + + function queueEXRPreview() { + var comps = getSelectedComps(); + var queued = 0; + var i; + var comp; + var lookupCode; + var shot; + var result = makeBatchResult(comps.length); + var shotCode; + var folderPath; + var outputPath; + + app.beginUndoGroup("VFXReview Connector - Queue EXR Preview"); + for (i = 0; i < comps.length; i += 1) { + comp = comps[i]; + lookupCode = getShotCodeFromComp(comp); + updateStatus(comps.length, "Looking up " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")"); + shot = normalizeShotData(getShotData(lookupCode)); + + if (!shot || !shot.shotCode) { + addFailure(result, comp.name, "API lookup failed"); + continue; + } + + shotCode = shot.shotCode; + disableLayerByName(comp, "UNG_VFX_OVERLAY"); + disableLayerByName(comp, "_SHOW LUT"); + + folderPath = EXPORT_ROOT + "/" + shotCode; + if (!ensureFolder(folderPath)) { + addFailure(result, comp.name, "Could not create output folder"); + continue; + } + + outputPath = folderPath + "/" + shotCode + "_cmp_TT_" + (shot.shotVersion || "v001") + ".[#####].exr"; + if (queueCompWithOutput(comp, "EXR Sequence", outputPath)) { + queued += 1; + result.queued += 1; + } else { + addFailure(result, comp.name, "Could not queue EXR render"); + } + } + app.endUndoGroup(); + + updateStatus(comps.length, queued + " EXR renders queued. " + result.skipped + " skipped."); + } + + function queueMP4() { + queueReview("REVIEW_PREVIEW", "mp4", "MP4"); + } + + function queueMOV() { + queueReview("4444 Tri", "mov", "MOV"); + } + + function importExportedEXR() { + var comps = getTargetComps(); + var exportsProjectFolder; + var comp; + var shotCode; + var shot; + var exportFolder; + var files; + var importOptions; + var footage; + var layer; + var i; + var imported = 0; + var skipped = 0; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + app.beginUndoGroup("VFXReview Connector - Import Exported EXR"); + + for (i = 0; i < comps.length; i += 1) { + comp = comps[i]; + shotCode = getShotCodeFromComp(comp); + updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + + shot = normalizeShotData(getShotData(shotCode)); + if (!shot || !shot.shotCode || !shot.exrOutput) { + updateStatus(comps.length, "API lookup failed or missing EXR output for " + shotCode); + skipped += 1; + continue; + } + + exportFolder = new Folder(EXPORT_ROOT + "/" + shot.shotCode); + if (!exportFolder.exists) { + updateStatus(comps.length, "Export folder not found: " + exportFolder.fsName); + skipped += 1; + continue; + } + + // Prefer the current review-render version naming convention: + // shotCode_cmp_TT_vXXX.####.exr + var currentVersion = shot.shotVersion || "v001"; + var reviewPrefix = shot.shotCode + "_cmp_TT_" + currentVersion + "."; + files = exportFolder.getFiles(function (f) { + return f instanceof File && + /\.exr$/i.test(f.name) && + f.name.indexOf(reviewPrefix) === 0; + }); + + // Fallback to the broader queue EXR naming pattern if needed + if (!files || files.length < 1) { + var cmpPrefix = shot.shotCode + "_cmp_TT_"; + files = exportFolder.getFiles(function (f) { + return f instanceof File && + /\.exr$/i.test(f.name) && + f.name.indexOf(cmpPrefix) === 0; + }); + } + + if (!files || files.length < 1) { + // Last resort: any EXR in the folder + files = exportFolder.getFiles("*.exr"); + } + + if (!files || files.length < 1) { + updateStatus(comps.length, "No EXR files found in " + exportFolder.fsName); + skipped += 1; + continue; + } + + files.sort(); + try { + importOptions = new ImportOptions(files[0]); + importOptions.sequence = true; + footage = app.project.importFile(importOptions); + footage.mainSource.conformFrameRate = 24; + } catch (importErr) { + updateStatus(comps.length, "Import failed for " + shot.shotCode + ": " + importErr.message); + skipped += 1; + continue; + } + + exportsProjectFolder = findOrCreateFolder("_EXPORTS", null); + if (exportsProjectFolder) { + footage.parentFolder = exportsProjectFolder; + } + + layer = comp.layers.add(footage); + layer.moveToBeginning(); + imported += 1; + } + + app.endUndoGroup(); + + updateStatus(comps.length, imported + " EXR export(s) imported. " + skipped + " skipped."); + } + + function importRenders() { + var comps = getTargetComps(); + var comp; + var shotCode; + var rendersRoot; + var rendersProjectFolder; + var allFiles; + var subFolders; + var importedFootage = []; + var addedLayers = []; + var layerIndices = []; + var footage; + var layer; + var i; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + comp = comps[0]; + shotCode = getShotCodeFromComp(comp); + rendersRoot = new Folder("X:/shared_projects_2026/UNGO_VFX/production/renders/" + shotCode); + + if (!rendersRoot.exists) { + updateStatus(getSelectedComps().length, "Renders folder not found: " + rendersRoot.fsName); + return; + } + + rendersProjectFolder = findOrCreateFolder("_RENDERS", null); + + app.beginUndoGroup("VFXReview Connector - Import Renders"); + + // Try subfolders first (each subfolder may be a separate pass/layer) + subFolders = rendersRoot.getFiles(); + for (i = 0; i < subFolders.length; i += 1) { + if (subFolders[i] instanceof Folder) { + footage = importEXRSequence(subFolders[i]); + if (footage) { + footage.name = subFolders[i].name; + footage.mainSource.conformFrameRate = 24; + if (rendersProjectFolder) { + footage.parentFolder = rendersProjectFolder; + } + importedFootage.push(footage); + } + } + } + + // Fall back to sequences directly in the root renders folder + if (importedFootage.length < 1) { + footage = importEXRSequence(rendersRoot); + if (footage) { + footage.name = shotCode + "_render"; + footage.mainSource.conformFrameRate = 24; + if (rendersProjectFolder) { + footage.parentFolder = rendersProjectFolder; + } + importedFootage.push(footage); + } + } + + if (importedFootage.length < 1) { + app.endUndoGroup(); + updateStatus(getSelectedComps().length, "No EXR sequences found in renders folder."); + return; + } + + // Add footage layers to comp and apply Extractor effect + for (i = 0; i < importedFootage.length; i += 1) { + layer = comp.layers.add(importedFootage[i]); + try { + var extractor = layer.property("Effects").addProperty("Extractor"); + try { + extractor.property("Layers").setValue(1); + } catch (layersPropError) { + // Property name may differ — try by index (property 1 is typically "Layers") + try { + extractor.property(1).setValue(1); + } catch (layersIdxError) { + } + } + } catch (fxError) { + } + addedLayers.push(layer); + } + + // Collect current layer indices for pre-compose + for (i = 0; i < addedLayers.length; i += 1) { + layerIndices.push(addedLayers[i].index); + } + + // Pre-compose all render layers into {shotCode}_RENDER + try { + comp.layers.precompose(layerIndices, shotCode + "_RENDER", true); + updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported into " + shotCode + "_RENDER."); + } catch (precompError) { + updateStatus(getSelectedComps().length, importedFootage.length + " render(s) imported. Pre-compose failed: " + precompError.message); + } + + app.endUndoGroup(); + } + + function prepDelivery() { + var comps = getTargetComps(); + var i; + var j; + var comp; + var shotCode; + var shot; + var exrOutputBase; + var sourceFolder; + var deliveryFolderPath; + var exrFiles; + var expectedBaseName; + var result = makeBatchResult(comps.length); + var srcFile; + var srcBaseName; + var frameMatch; + var frameNum; + var destFileName; + var destPath; + var psLines; + var scriptFile; + var vbsFile; + var launched = 0; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + for (i = 0; i < comps.length; i += 1) { + comp = comps[i]; + shotCode = getShotCodeFromComp(comp); + updateStatus(comps.length, "Preparing delivery for " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + + shot = normalizeShotData(getShotData(shotCode)); + if (!shot || !shot.shotCode) { + addFailure(result, comp.name, "API lookup failed"); + continue; + } + + shotCode = shot.shotCode; + expectedBaseName = shotCode + "_cmp_TT_" + (shot.shotVersion || "v001"); + exrOutputBase = shot.exrOutput || ""; + + sourceFolder = new Folder(EXPORT_ROOT + "/" + shotCode); + if (!sourceFolder.exists) { + addFailure(result, comp.name, "Source export folder not found: " + sourceFolder.fsName); + continue; + } + + // Prefer the current review-render version naming convention: + // shotCode_cmp_TT_vXXX.####.exr + exrFiles = null; + var currentVersion = shot.shotVersion || "v001"; + var reviewPrefix = shotCode + "_cmp_TT_" + currentVersion + "."; + exrFiles = sourceFolder.getFiles(function (f) { + return f instanceof File && + /\.exr$/i.test(f.name) && + f.name.indexOf(reviewPrefix) === 0; + }); + + // Fallback to the broader queue EXR naming pattern if needed + if (!exrFiles || exrFiles.length < 1) { + var cmpPfx = shotCode + "_cmp_TT_"; + exrFiles = sourceFolder.getFiles(function (f) { + return f instanceof File && + /\.exr$/i.test(f.name) && + f.name.indexOf(cmpPfx) === 0; + }); + } + + if (!exrFiles || exrFiles.length < 1) { + // Last resort: any EXR in the folder + exrFiles = sourceFolder.getFiles("*.exr"); + } + + if (!exrFiles || exrFiles.length < 1) { + addFailure(result, comp.name, "No EXR files found in " + sourceFolder.fsName); + continue; + } + + exrFiles.sort(); + + var episodeCode = shotCode.substring(0, 7); + var deliveryDate = getDateString().replace(/\//g, ""); + deliveryFolderPath = EXPORT_ROOT + "/DELIVERY/" + episodeCode + "/" + deliveryDate + "_Delivery/" + shotCode; + if (!ensureFolder(deliveryFolderPath)) { + addFailure(result, comp.name, "Could not create delivery folder"); + continue; + } + + // Build a list of PowerShell Copy-Item commands + psLines = []; + for (j = 0; j < exrFiles.length; j += 1) { + srcFile = exrFiles[j]; + srcBaseName = srcFile.name.replace(/\.exr$/i, ""); + + if (srcBaseName.indexOf(expectedBaseName + ".") === 0) { + destFileName = srcFile.name; + } else { + // Extract frame number and rename to delivery convention + frameMatch = srcFile.name.match(/\.(\d+)\.exr$/i); + if (!frameMatch) { + continue; + } + frameNum = frameMatch[1]; + destFileName = expectedBaseName + "." + frameNum + ".exr"; + } + + destPath = (new File(deliveryFolderPath + "/" + destFileName)).fsName; + // Single-quoted PS strings are fully literal — safe for Windows paths + psLines.push("Copy-Item -LiteralPath '" + srcFile.fsName + "' -Destination '" + destPath + "'"); + } + + if (psLines.length < 1) { + addFailure(result, comp.name, "No valid EXR files to copy"); + continue; + } + + // Write PS1 script to temp folder + scriptFile = new File(Folder.temp.fsName + "/vfxdelivery_" + shotCode + ".ps1"); + try { + scriptFile.open("w"); + scriptFile.write(psLines.join("\r\n")); + // Self-delete once all transfers finish + scriptFile.write("\r\nRemove-Item -LiteralPath '" + scriptFile.fsName + "' -Force -ErrorAction SilentlyContinue"); + scriptFile.close(); + } catch (writeError) { + addFailure(result, comp.name, "Could not write delivery script: " + writeError.message); + continue; + } + + // VBScript launcher: WScript.Shell.Run with bWaitOnReturn=False creates a fully + // independent process with no inherited pipe handles, so system.callSystem returns + // immediately and AE is never blocked regardless of how long the copy takes. + vbsFile = new File(Folder.temp.fsName + "/vfxdelivery_launch_" + shotCode + ".vbs"); + try { + vbsFile.open("w"); + vbsFile.write("Set sh = CreateObject(\"WScript.Shell\")\r\n"); + vbsFile.write("sh.Run \"powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File \" & Chr(34) & \"" + scriptFile.fsName + "\" & Chr(34), 0, False\r\n"); + vbsFile.write("Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n"); + vbsFile.write("fso.DeleteFile WScript.ScriptFullName\r\n"); + vbsFile.close(); + } catch (vbsError) { + addFailure(result, comp.name, "Could not write launcher script: " + vbsError.message); + continue; + } + + system.callSystem("wscript.exe /nologo \"" + vbsFile.fsName + "\""); + launched += 1; + } + + var statusMsg; + if (launched > 0) { + statusMsg = "Delivery transfer running in background for " + launched + " shot(s)."; + } else { + statusMsg = "No shots queued for delivery."; + } + if (result.skipped > 0) { + statusMsg += " " + result.skipped + " skipped."; + } + updateStatus(comps.length, statusMsg); + } + + function pullPictureLock() { + var comps = getTargetComps(); + var comp; + var shotCode; + var episodePrefix; + var piclocksProjectFolder; + var piclockFootage; + var piclocksDir; + var files; + var foundFile; + var importOptions; + var apiData; + var shot; + var fps; + var handles; + var piclockStartTC; + var tcIn; + var tcOut; + var inOffset; + var outOffset; + var layer; + var i; + var item; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + comp = comps[0]; + shotCode = getShotCodeFromComp(comp); + episodePrefix = shotCode.substring(0, 7); // e.g. "UNG_106" + + // Fetch API data for seq timecodes + updateStatus(getSelectedComps().length, "Looking up " + shotCode + "..."); + apiData = getShotData(shotCode); + if (!apiData || !apiData.shot) { + updateStatus(getSelectedComps().length, "API lookup failed for " + shotCode); + return; + } + + shot = apiData.shot; + if (!shot.seqTimecodeStart || !shot.seqTimecodeEnd) { + updateStatus(getSelectedComps().length, "No sequence timecodes for " + shotCode + ". Import picture tracker CSV first."); + return; + } + + // Find or create _PICLOCKS project folder + piclocksProjectFolder = findOrCreateFolder("_PICLOCKS", null); + piclockFootage = null; + + // Search only inside the _PICLOCKS project folder for matching footage + if (piclocksProjectFolder) { + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof FootageItem && + item.parentFolder === piclocksProjectFolder && + item.name.substring(0, 7) === episodePrefix) { + piclockFootage = item; + break; + } + } + } + + // Not in project — import from disk + if (!piclockFootage) { + piclocksDir = new Folder(FOOTAGE_ROOT + "/_PICLOCKS"); + if (!piclocksDir.exists) { + updateStatus(getSelectedComps().length, "PICLOCKS folder not found: " + piclocksDir.fsName); + return; + } + + files = piclocksDir.getFiles(episodePrefix + "_*"); + foundFile = null; + if (files && files.length > 0) { + for (i = 0; i < files.length; i += 1) { + if (files[i] instanceof File) { + foundFile = files[i]; + break; + } + } + } + + if (!foundFile) { + updateStatus(getSelectedComps().length, "No piclock file matching " + episodePrefix + "_* found in " + piclocksDir.fsName); + return; + } + + try { + importOptions = new ImportOptions(foundFile); + piclockFootage = app.project.importFile(importOptions); + if (piclocksProjectFolder) { + piclockFootage.parentFolder = piclocksProjectFolder; + } + } catch (importError) { + updateStatus(getSelectedComps().length, "Import failed: " + importError.message); + return; + } + } + + // Calculate layer in/out offsets from seqTimecodes + handles + fps = comp.frameRate || 24; + handles = 8; + + piclockStartTC = 3590; + try { + if (piclockFootage.mainSource && typeof piclockFootage.mainSource.startTimecode === "number") { + piclockStartTC = piclockFootage.mainSource.startTimecode; + } + } catch (tcReadError) { + } + + tcIn = parseTimecodeToSeconds(shot.seqTimecodeStart, fps); + tcOut = parseTimecodeToSeconds(shot.seqTimecodeEnd, fps); + inOffset = tcIn - piclockStartTC - (handles / fps); + outOffset = tcOut - piclockStartTC + (handles / fps); + + if (inOffset < 0) { + inOffset = 0; + } + if (outOffset > piclockFootage.duration) { + outOffset = piclockFootage.duration; + } + + app.beginUndoGroup("VFXReview Connector - Pull Picture Lock"); + + layer = comp.layers.add(piclockFootage); + var scale = + Math.max( + comp.width / piclockFootage.width, + comp.height / piclockFootage.height + ) * 100; + + layer.property("Scale") + .setValue([ + scale, + scale + ]); + layer.moveToBeginning(); + + // Offset startTime so the correct footage frame plays at comp time 0 + layer.startTime = -inOffset; + layer.inPoint = 0; + layer.outPoint = outOffset - inOffset; + + app.endUndoGroup(); + + updateStatus( + getSelectedComps().length, + "Piclock added: " + shot.seqTimecodeStart + " \u2192 " + shot.seqTimecodeEnd + " (+" + handles + "fr handles)." + ); + } + + function applyOCIOColorSpace() { + var comps = getTargetComps(); + var applied = 0; + var skipped = 0; + var i; + var j; + var comp; + var layer; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + app.beginUndoGroup("VFXReview Connector - Apply OCIO"); + for (i = 0; i < comps.length; i += 1) { + comp = comps[i]; + for (j = 1; j <= comp.numLayers; j += 1) { + try { + layer = comp.layer(j); + // Only apply to footage layers (not comps, solids, etc.) + if (layer.source && layer.source instanceof FootageItem && layer.source.mainSource instanceof FileSource) { + addOCIOToLayer(layer); + applied += 1; + } + } catch (layerError) { + skipped += 1; + } + } + } + app.endUndoGroup(); + + updateStatus(comps.length, "OCIO applied to " + applied + " layer(s). " + skipped + " skipped."); + } + + function findOrCreateFolder(name, parentFolder) { + var i; + var item; + + if (!app.project) { + return null; + } + + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof FolderItem && item.name === name) { + if (!parentFolder || item.parentFolder === parentFolder) { + return item; + } + } + } + + var newFolder = app.project.items.addFolder(name); + if (parentFolder) { + newFolder.parentFolder = parentFolder; + } + return newFolder; + } + + function initialiseWorkspace() { + var topFolderNames = ["_PREVIEWS", "_PRECOMPS", "_PICLOCKS", "_FOOTAGE_4K", "_STOCK", "_RENDERS", "_UTILITIES"]; + var templatePath = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep"; + var shotsFolder; + var epNum; + var epCode; + var templateFile; + var importOptions; + var importStatus; + var i; + + if (!app.project) { + updateStatus(0, "No project open."); + return; + } + + app.beginUndoGroup("VFXReview Connector - Initialise Workspace"); + + // Create __SHOTS with UNG_101 – UNG_117 subfolders + shotsFolder = findOrCreateFolder("__SHOTS", null); + for (epNum = 101; epNum <= 117; epNum += 1) { + epCode = "UNG_" + epNum; + findOrCreateFolder(epCode, shotsFolder); + } + + // Create remaining top-level folders + for (i = 0; i < topFolderNames.length; i += 1) { + findOrCreateFolder(topFolderNames[i], null); + } + + // Import AEP template + importStatus = ""; + templateFile = new File(templatePath); + if (templateFile.exists) { + try { + importOptions = new ImportOptions(templateFile); + importOptions.importAs = ImportAsType.PROJECT; + app.project.importFile(importOptions); + importStatus = " Template imported."; + } catch (importError) { + importStatus = " Template import failed: " + importError.message; + } + } else { + importStatus = " Template file not found."; + } + + app.endUndoGroup(); + + // ── Color settings (outside undo group — project-level settings) ────── + var colorStatus = ""; + + // 32-bit depth + try { + app.project.bitsPerChannel = 32; + colorStatus += " 32-bit depth set."; + } catch (bpcError) { + colorStatus += " Could not set 32-bit depth."; + } + + // OCIO color management — AE 2022+ scripting API + try { + app.project.colorSettings.enabled = true; + colorStatus += " Color management enabled."; + } catch (csError) { + colorStatus += " Color management API unavailable (set manually)."; + } + + try { + // workingSpace index for OCIO ACES 1.2 varies by installation; + // attempt to match by name first, fall back to direct assignment. + var cs = app.project.colorSettings; + var found = false; + var wi; + if (cs.getWorkingSpaceList) { + var spaces = cs.getWorkingSpaceList(); + for (wi = 0; wi < spaces.length; wi += 1) { + if (String(spaces[wi]).indexOf("ACES") !== -1 && String(spaces[wi]).indexOf("1.2") !== -1) { + cs.workingSpace = spaces[wi]; + found = true; + break; + } + } + } + if (!found && cs.workingSpace !== undefined) { + cs.workingSpace = "ACES - ACES2065-1"; + } + colorStatus += " OCIO ACES 1.2 applied."; + } catch (ocioError) { + colorStatus += " OCIO config must be set manually in Project Settings."; + } + + updateStatus(getSelectedComps().length, "Workspace initialised." + importStatus + colorStatus); + } + + function clearDropdown(dropdown) { + while (dropdown.items.length > 0) { + dropdown.remove(dropdown.items[0]); + } + } + + function populateShotDropdown() { + var episodeValue; + var shots; + var i; + var shotCode; + var item; + + if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown) { + return; + } + + episodeValue = uiState.episodeDropdown.selection.episodeValue || uiState.episodeDropdown.selection.text; + shots = getShotsFromAPI(episodeValue); + + clearDropdown(uiState.shotDropdown); + for (i = 0; i < shots.length; i += 1) { + shotCode = getShotCodeValue(shots[i]); + if (shotCode) { + item = uiState.shotDropdown.add("item", shotCode); + item.shotCode = shotCode; + } + } + + if (uiState.shotDropdown.items.length > 0) { + uiState.shotDropdown.selection = 0; + updateStatus(getSelectedComps().length, uiState.shotDropdown.items.length + " shot(s) found for " + uiState.episodeDropdown.selection.text); + } else { + updateStatus(getSelectedComps().length, "No shots found for " + uiState.episodeDropdown.selection.text); + } + } + + function populateEpisodeDropdown() { + var episodes = getEpisodesFromAPI(); + var i; + var label; + var episodeValue; + var item; + + if (!uiState.episodeDropdown || !uiState.shotDropdown) { + return; + } + + clearDropdown(uiState.episodeDropdown); + clearDropdown(uiState.shotDropdown); + + for (i = 0; i < episodes.length; i += 1) { + label = getEpisodeLabel(episodes[i]); + episodeValue = getEpisodeValue(episodes[i]); + if (label && episodeValue) { + item = uiState.episodeDropdown.add("item", label); + item.episodeValue = episodeValue; + item.episodeCode = deriveEpisodeCode(label); + if (!/^[A-Z]{3}_\d{3}$/.test(item.episodeCode)) { + item.episodeCode = deriveEpisodeCode(episodeValue); + } + } + } + + if (uiState.episodeDropdown.items.length > 0) { + uiState.episodeDropdown.selection = 0; + populateShotDropdown(); + } else { + updateStatus(getSelectedComps().length, "No episodes found from API."); + } + } + + // ── Shot Version Increment ──────────────────────────────────────────────── + + function incrementVersionString(ver) { + var match = String(ver).match(/^v(\d+)$/); + var num; + var str; + + if (!match) { + return "v002"; + } + + num = parseInt(match[1], 10) + 1; + str = String(num); + while (str.length < 3) { + str = "0" + str; + } + return "v" + str; + } + + function patchShotVersion(shotId, newVersion) { + var url = BASE_URL + "/api/ext/shots/" + shotId; + // Inner double-quotes escaped for Windows cmd.exe + var body = "{\\\"shotVersion\\\":\\\"" + newVersion + "\\\"}"; + var command; + var response; + + try { + command = "curl -s -X PATCH " + + "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Content-Type: application/json\" " + + "-H \"Accept: application/json\" " + + "-d \"" + body + "\" " + + "\"" + url + "\""; + + response = system.callSystem(command); + } catch (patchError) { + return null; + } + + return parseJSON(response); + } + + function incrementShotVersions() { + var comps = getTargetComps(); + var i; + var shotCode; + var data; + var shot; + var newVersion; + var patchResult; + var versionName; + var previewComp; + var slateLayer; + var essentialProperties; + var updated = 0; + var skipped = 0; + var lastVersion = ""; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + app.beginUndoGroup("VFXReview Connector - Increment Shot Version"); + for (i = 0; i < comps.length; i += 1) { + shotCode = getShotCodeFromComp(comps[i]); + updateStatus(comps.length, "Incrementing " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + + data = getShotData(shotCode); + if (!data || !data.shot) { + skipped += 1; + continue; + } + + shot = data.shot; + newVersion = incrementVersionString(shot.shotVersion || "v001"); + + patchResult = patchShotVersion(shot.id, newVersion); + if (!patchResult || !patchResult.success) { + skipped += 1; + continue; + } + + data.shot.shotVersion = newVersion; + versionName = shotCode + "_cmp_TT_" + newVersion; + + // 1. Update SHOT NAME on UNG_VFX_OVERLAY inside the selected comp + updateOverlayForComp(comps[i], shotCode, data); + + // 2. Update {shotCode}_PREVIEW slate VersionName if the comp exists + previewComp = findComp(getPreviewCompName(shotCode)); + if (previewComp) { + try { + slateLayer = previewComp.layer("NETFLIX_SLATE"); + essentialProperties = slateLayer.property("Essential Properties"); + try { + essentialProperties.property("VersionName").setValue(versionName); + } catch (byNameError) { + essentialProperties.property(3).setValue(versionName); + } + } catch (slateError) { + } + } + + lastVersion = newVersion; + updated += 1; + } + app.endUndoGroup(); + + if (updated > 0) { + updateStatus(comps.length, updated + " shot(s) → " + lastVersion + ". " + skipped + " skipped."); + } else { + updateStatus(comps.length, "No shots incremented. " + skipped + " failed."); + } + } + + function setStartFrameFromTimecode() { + var comps = getTargetComps(); + var i; + var shotCode; + var data; + var shot; + var fps; + var frameNumber; + var updated = 0; + var skipped = 0; + + if (comps.length < 1) { + updateStatus(0, "Please select or open a comp first."); + return; + } + + app.beginUndoGroup("VFXReview Connector - Set Start Timecode"); + for (i = 0; i < comps.length; i += 1) { + shotCode = getShotCodeFromComp(comps[i]); + updateStatus(comps.length, "Looking up " + shotCode + " (" + (i + 1) + " of " + comps.length + ")"); + data = getShotData(shotCode); + + if (!data || !data.shot || !data.shot.timecodeStart) { + skipped += 1; + continue; + } + + shot = data.shot; + fps = shot.fps || comps[i].frameRate || 24; + frameNumber = parseTimecodeToFrames(shot.timecodeStart, fps) - HANDLE_FRAMES; + setCompStartFrame(comps[i], frameNumber); + updated += 1; + } + app.endUndoGroup(); + + updateStatus(comps.length, updated + " comp(s) start frame set. " + skipped + " skipped."); + } + + // ── Render Pipeline (Queue Export → server render queue) ───────────────── + // Additive: the manual Queue EXR / MP4 / MOV / Prep Delivery buttons above + // stay as escape hatches. Queue Export submits a Render Manifest to + // POST /api/ext/exports; the SERVER increments the shot version and + // decides output paths, so no local render or Increment Version click is + // needed on this path. + + function jsonEscapeString(value) { + var text = String(value); + var out = ""; + var i; + var ch; + var code; + + for (i = 0; i < text.length; i += 1) { + ch = text.charAt(i); + code = text.charCodeAt(i); + if (ch === "\"") { + out += "\\\""; + } else if (ch === "\\") { + out += "\\\\"; + } else if (ch === "\n") { + out += "\\n"; + } else if (ch === "\r") { + out += "\\r"; + } else if (ch === "\t") { + out += "\\t"; + } else if (code < 32) { + out += "\\u" + ("000" + code.toString(16)).slice(-4); + } else { + out += ch; + } + } + return out; + } + + function jsonStringify(value) { + var parts; + var key; + var i; + + if (value === null || value === undefined) { + return "null"; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "string") { + return "\"" + jsonEscapeString(value) + "\""; + } + if (value instanceof Array) { + parts = []; + for (i = 0; i < value.length; i += 1) { + parts.push(jsonStringify(value[i])); + } + return "[" + parts.join(",") + "]"; + } + parts = []; + for (key in value) { + if (value.hasOwnProperty(key) && value[key] !== undefined) { + parts.push("\"" + jsonEscapeString(key) + "\":" + jsonStringify(value[key])); + } + } + return "{" + parts.join(",") + "}"; + } + + // POST JSON via a temp file (-d @file) — avoids cmd.exe quote-escaping + // problems for large bodies. Returns { status: httpCode, data: parsed }. + function postJSON(url, bodyObject) { + var bodyFile; + var command; + var response = null; + var markerIndex; + var statusCode = 0; + var jsonText; + + bodyFile = new File(Folder.temp.fsName + "/vfxr_post_body.json"); + try { + bodyFile.encoding = "UTF-8"; + if (!bodyFile.open("w")) { + return { status: 0, data: null }; + } + bodyFile.write(jsonStringify(bodyObject)); + bodyFile.close(); + + command = "curl -s -X POST " + + "-H \"Authorization: Bearer " + TOKEN + "\" " + + "-H \"Content-Type: application/json\" " + + "-H \"Accept: application/json\" " + + "-d \"@" + bodyFile.fsName + "\" " + + "-w \"__HTTP__:%{http_code}\" " + + "\"" + url + "\""; + + response = system.callSystem(command); + } catch (postError) { + response = null; + } + try { + bodyFile.remove(); + } catch (removeError) { + } + + if (!response) { + return { status: 0, data: null }; + } + + markerIndex = response.lastIndexOf("__HTTP__:"); + if (markerIndex >= 0) { + statusCode = parseInt(response.substring(markerIndex + 9), 10) || 0; + jsonText = response.substring(0, markerIndex); + } else { + jsonText = response; + } + + return { status: statusCode, data: parseJSON(jsonText) }; + } + + function getLatestExportData(shotCode) { + var url = BASE_URL + "/api/ext/exports/latest" + + "?shotCode=" + encodeURIComponent(shotCode) + + "&projectCode=" + PROJECT_CODE; + return getAPIData(url); + } + + function isActiveExportStatus(status) { + return status === "QUEUED" || status === "RENDERING" || + status === "VALIDATING" || status === "GENERATING_PREVIEW"; + } + + function formatEtaShort(seconds) { + var mins; + var secs; + + if (!seconds || seconds <= 0) { + return ""; + } + mins = Math.floor(seconds / 60); + secs = Math.round(seconds % 60); + if (secs < 10) { + secs = "0" + secs; + } + return mins + ":" + secs; + } + + function formatExportStatus(shotCode, exportInfo) { + var text; + var job; + var pct; + var eta; + + if (!exportInfo) { + return shotCode + " — no exports yet"; + } + + text = shotCode + " " + (exportInfo.versionString || "") + " — " + + String(exportInfo.status || "").replace(/_/g, " "); + job = exportInfo.renderJob; + + if (exportInfo.status === "RENDERING" && job) { + pct = Math.round((job.progress || 0) * 100); + text += " " + pct + "%"; + eta = formatEtaShort(job.etaSeconds); + if (eta) { + text += " (ETA " + eta; + if (job.machineName) { + text += " on " + job.machineName; + } + text += ")"; + } else if (job.machineName) { + text += " (" + job.machineName + ")"; + } + } else if (job && job.errorMessage && /FAILED/.test(String(exportInfo.status))) { + text += ": " + String(job.errorMessage).substring(0, 60); + } + return text; + } + + function setPipelineStatus(message) { + if (uiState.pipelineStatusText) { + uiState.pipelineStatusText.text = message || ""; + } + } + + function stopExportPolling() { + if (pipelineState.pollTaskId) { + try { + app.cancelTask(pipelineState.pollTaskId); + } catch (cancelError) { + } + pipelineState.pollTaskId = 0; + } + pipelineState.pollShotCode = null; + } + + function pollExportTick() { + var shotCode = pipelineState.pollShotCode; + var data; + var exportInfo; + + if (!shotCode) { + stopExportPolling(); + return; + } + + try { + data = getLatestExportData(shotCode); + if (data && data.export !== undefined) { + exportInfo = data.export; + setPipelineStatus(formatExportStatus(shotCode, exportInfo)); + if (!exportInfo || !isActiveExportStatus(exportInfo.status)) { + stopExportPolling(); + } + } + } catch (pollError) { + } + } + + // While an export is active the panel polls every 15 s (spec §12.1); the + // task cancels itself once the export reaches a non-active state. + function startExportPolling(shotCode) { + stopExportPolling(); + pipelineState.pollShotCode = shotCode; + try { + pipelineState.pollTaskId = app.scheduleTask("__vfxrExportPollTick()", 15000, true); + } catch (scheduleError) { + pipelineState.pollTaskId = 0; + } + } + + function getStatusShotCode() { + var comps = getTargetComps(); + var mainComp; + var code; + + if (comps.length > 0) { + mainComp = getMainCompForSelection(comps[0]) || comps[0]; + code = extractShotCode(mainComp.name); + if (code) { + return code; + } + } + if (uiState.shotDropdown && uiState.shotDropdown.selection) { + return String(uiState.shotDropdown.selection.text || "") || null; + } + return null; + } + + // Slate fields carry forward: pull the previous submission's values when the + // selected shot changes, but never clobber edits the artist has just typed + // for the same shot. + function prefillSlateFields(shotCode, exportInfo) { + if (pipelineState.prefilledShotCode === shotCode) { + return; + } + if (uiState.vfxScopeField) { + uiState.vfxScopeField.text = (exportInfo && exportInfo.vfxScope) ? exportInfo.vfxScope : ""; + } + if (uiState.submissionNoteField) { + uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : ""; + } + pipelineState.prefilledShotCode = shotCode; + } + + function refreshExportStatus() { + var shotCode = getStatusShotCode(); + var data; + + if (!shotCode) { + setPipelineStatus("Select a comp or shot first"); + return; + } + setPipelineStatus("Checking " + shotCode + "..."); + data = getLatestExportData(shotCode); + if (!data || data.export === undefined) { + setPipelineStatus(shotCode + " — status check failed (server unreachable?)"); + return; + } + prefillSlateFields(shotCode, data.export); + setPipelineStatus(formatExportStatus(shotCode, data.export)); + if (data.export && isActiveExportStatus(data.export.status)) { + startExportPolling(shotCode); + } + } + + function buildRenderManifest(comp, shot) { + var frameStart; + var frameEnd; + var frames; + var aepPath = app.project.file.fsName.replace(/\\/g, "/"); + + // Prefer the shot record's authoritative range (the server cross-checks + // it) — but only when it looks real: shots imported without ranges + // carry 0/0, which must never reach aerender. Fall back to the comp. + if (shot.frameStart !== null && shot.frameStart !== undefined && + shot.frameEnd !== null && shot.frameEnd !== undefined && + Number(shot.frameEnd) >= Number(shot.frameStart) && + !(Number(shot.frameStart) === 0 && Number(shot.frameEnd) === 0)) { + frameStart = shot.frameStart; + frameEnd = shot.frameEnd; + } else { + frameStart = Math.round(comp.displayStartTime * comp.frameRate); + frames = Math.round(comp.duration * comp.frameRate); + frameEnd = frameStart + frames - 1; + } + + return { + manifestVersion: 1, + projectCode: PROJECT_CODE, + shotCode: shot.shotCode, + shotId: shot.id, + aepPath: aepPath, + compName: comp.name, + rendererType: "aerender", + outputDir: "auto", + outputPattern: "auto", + outputModuleTemplate: "EXR Sequence", + frameStart: frameStart, + frameEnd: frameEnd, + fps: Math.round(comp.frameRate * 1000) / 1000, + width: comp.width, + height: comp.height + }; + } + + function submitExport(manifest, urgent, force) { + var body = { + manifest: manifest, + force: force ? true : false + }; + + if (ARTIST_EMAIL) { + body.submittedByEmail = ARTIST_EMAIL; + } + if (urgent) { + body.priority = 20; + } + // Sent every time so edits stick; omitting them would make the server + // inherit the previous submission's values instead. + if (uiState.vfxScopeField) { + body.vfxScope = uiState.vfxScopeField.text; + } + if (uiState.submissionNoteField) { + body.submissionNote = uiState.submissionNoteField.text; + } + return postJSON(BASE_URL + "/api/ext/exports", body); + } + + function queueExport() { + var comps = getTargetComps(); + var result = makeBatchResult(comps.length); + var urgent = uiState.urgentCheckbox ? uiState.urgentCheckbox.value : false; + var i; + var comp; + var lookupCode; + var data; + var manifest; + var response; + var errorText; + var lastQueued = null; + var lastShotCode = null; + + if (comps.length < 1) { + updateStatus(0, "Select or open a comp first."); + return; + } + if (!app.project || !app.project.file) { + updateStatus(comps.length, "Save the project first — the render farm opens the AEP from disk."); + return; + } + + // The worker renders the saved file; unsaved changes would silently + // render stale comps. + try { + app.project.save(); + } catch (saveError) { + updateStatus(comps.length, "Could not save the project: " + saveError); + return; + } + + for (i = 0; i < comps.length; i += 1) { + comp = getMainCompForSelection(comps[i]); + if (!comp) { + addFailure(result, comps[i].name, "Main comp not found for preview comp"); + continue; + } + lookupCode = getShotCodeFromComp(comp); + updateStatus(comps.length, "Queuing " + lookupCode + " (" + (i + 1) + " of " + comps.length + ")..."); + + data = getShotData(lookupCode); + if (!data || !data.shot || !data.shot.id) { + addFailure(result, comp.name, "API lookup failed"); + continue; + } + + manifest = buildRenderManifest(comp, data.shot); + response = submitExport(manifest, urgent, false); + + if (response.status === 409) { + errorText = (response.data && response.data.error) ? + response.data.error : "An active export already exists for this shot."; + if (confirm(lookupCode + ":\n" + errorText + + "\n\nForce supersede the active export and queue anyway?")) { + response = submitExport(manifest, urgent, true); + } else { + addFailure(result, comp.name, "Skipped (active export kept)"); + continue; + } + } + + if (response.status === 201 && response.data && response.data.export) { + result.queued += 1; + lastQueued = response.data.export; + lastShotCode = response.data.export.shotCode || lookupCode; + } else { + errorText = (response.data && response.data.error) ? + response.data.error : ("HTTP " + response.status); + addFailure(result, comp.name, errorText); + } + } + + if (lastQueued) { + updateStatus(comps.length, "Queued " + lastQueued.versionString + + (urgent ? " (urgent)" : "") + " on render farm. " + + result.queued + " queued, " + result.skipped + " skipped."); + setPipelineStatus(formatExportStatus(lastShotCode, lastQueued)); + startExportPolling(lastShotCode); + } else { + updateStatus(comps.length, "No exports queued. " + + (result.failures.length > 0 ? result.failures[0] : "")); + } + } + + function retryLatestExport() { + var shotCode = getStatusShotCode(); + var data; + var exportInfo; + var response; + + if (!shotCode) { + setPipelineStatus("Select a comp or shot first"); + return; + } + data = getLatestExportData(shotCode); + exportInfo = data ? data.export : null; + if (!exportInfo) { + setPipelineStatus(shotCode + " — no export to retry"); + return; + } + if (!/FAILED$/.test(String(exportInfo.status))) { + setPipelineStatus(formatExportStatus(shotCode, exportInfo) + " — nothing to retry"); + return; + } + + response = postJSON(BASE_URL + "/api/ext/exports/" + exportInfo.id + "/retry", + { note: "Retry from AE panel" }); + if (response.status === 200) { + setPipelineStatus(shotCode + " " + exportInfo.versionString + " — requeued"); + startExportPolling(shotCode); + } else { + setPipelineStatus("Retry failed: " + + ((response.data && response.data.error) ? response.data.error : "HTTP " + response.status)); + } + } + + function buildUI(thisObjRef) { + var win = (thisObjRef instanceof Panel) ? thisObjRef : new Window("palette", "VFXReview Connector", undefined, { resizeable: true }); + var bannerFile; + var bannerImage; + var title; + var shotBuilderPanel; + var shotBuilderGroup; + var shotBuilderButton; + var pipelinePanel; + var slateScopeGroup; + var slateScopeLabel; + var slateNoteGroup; + var slateNoteLabel; + var pipelineRow1; + var pipelineRow2; + var queueExportButton; + var refreshExportButton; + var retryExportButton; + var buttonsPanel; + var btnRow1; + var btnRow2; + var btnRow3; + var btnRow4; + var btnRow4b; + var btnRow4c; + var prepDeliveryButton; + var refreshButton; + var fixNamesButton; + var overlayButton; + var previewButton; + var exrButton; + var exrPreviewButton; + var importExrButton; + var mp4Button; + var movButton; + var btnRow5; + var importRendersButton; + var btnRow6; + var pictureLockButton; + var btnRow7; + var incrementVersionButton; + var btnRow6b; + var setStartTCButton; + var colorSpacePanel; + var ocioButton; + var workspacePanel; + var initWorkspaceButton; + var statusPanel; + + win.orientation = "column"; + win.alignChildren = ["fill", "top"]; + win.spacing = 8; + win.margins = 12; + + bannerFile = new File(BANNER_IMAGE_PATH); + if (bannerFile.exists) { + try { + bannerImage = win.add("image", undefined, bannerFile); + bannerImage.alignment = ["fit", "top"]; + bannerImage.size = [360, 80]; + } catch (bannerError) { + } + } + + title = win.add("statictext", undefined, "VFXReview Connector"); + title.alignment = ["fill", "top"]; + + shotBuilderPanel = win.add("panel", undefined, "Shot Builder"); + shotBuilderPanel.orientation = "column"; + shotBuilderPanel.alignChildren = ["fill", "top"]; + shotBuilderPanel.margins = 10; + + shotBuilderGroup = shotBuilderPanel.add("group"); + shotBuilderGroup.orientation = "row"; + shotBuilderGroup.alignChildren = ["fill", "center"]; + shotBuilderGroup.spacing = 6; + + uiState.episodeDropdown = shotBuilderGroup.add("dropdownlist", undefined, []); + uiState.episodeDropdown.preferredSize = [110, 24]; + uiState.shotDropdown = shotBuilderGroup.add("dropdownlist", undefined, []); + uiState.shotDropdown.preferredSize = [150, 24]; + shotBuilderButton = shotBuilderGroup.add("button", undefined, "Build Shot"); + + pipelinePanel = win.add("panel", undefined, "Render Pipeline"); + pipelinePanel.orientation = "column"; + pipelinePanel.alignChildren = ["fill", "top"]; + pipelinePanel.margins = 8; + pipelinePanel.spacing = 4; + + uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked"); + + slateScopeGroup = pipelinePanel.add("group"); + slateScopeGroup.orientation = "row"; + slateScopeGroup.alignChildren = ["fill", "center"]; + slateScopeGroup.spacing = 6; + slateScopeLabel = slateScopeGroup.add("statictext", undefined, "VFX Scope:"); + slateScopeLabel.preferredSize = [90, 20]; + uiState.vfxScopeField = slateScopeGroup.add("edittext", undefined, ""); + uiState.vfxScopeField.preferredSize = [220, 22]; + + slateNoteGroup = pipelinePanel.add("group"); + slateNoteGroup.orientation = "row"; + slateNoteGroup.alignChildren = ["fill", "top"]; + slateNoteGroup.spacing = 6; + slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Submission Note:"); + slateNoteLabel.preferredSize = [90, 20]; + uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: true }); + uiState.submissionNoteField.preferredSize = [220, 48]; + + pipelineRow1 = pipelinePanel.add("group"); + pipelineRow1.orientation = "row"; + pipelineRow1.alignChildren = ["fill", "center"]; + pipelineRow1.spacing = 6; + queueExportButton = pipelineRow1.add("button", undefined, "Queue Export"); + uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent"); + + pipelineRow2 = pipelinePanel.add("group"); + pipelineRow2.orientation = "row"; + pipelineRow2.alignChildren = ["fill", "center"]; + pipelineRow2.spacing = 6; + refreshExportButton = pipelineRow2.add("button", undefined, "Refresh Status"); + retryExportButton = pipelineRow2.add("button", undefined, "Retry Export"); + + buttonsPanel = win.add("panel", undefined, "Actions"); + buttonsPanel.orientation = "column"; + buttonsPanel.alignChildren = ["fill", "top"]; + buttonsPanel.margins = 8; + buttonsPanel.spacing = 4; + + btnRow1 = buttonsPanel.add("group"); + btnRow1.orientation = "row"; + btnRow1.alignChildren = ["fill", "center"]; + btnRow1.spacing = 6; + refreshButton = btnRow1.add("button", undefined, "Refresh"); + fixNamesButton = btnRow1.add("button", undefined, "Fix Comp Names"); + + btnRow2 = buttonsPanel.add("group"); + btnRow2.orientation = "row"; + btnRow2.alignChildren = ["fill", "center"]; + btnRow2.spacing = 6; + overlayButton = btnRow2.add("button", undefined, "Add Overlay"); + previewButton = btnRow2.add("button", undefined, "Build Preview"); + + btnRow3 = buttonsPanel.add("group"); + btnRow3.orientation = "row"; + btnRow3.alignChildren = ["fill", "center"]; + btnRow3.spacing = 6; + mp4Button = btnRow3.add("button", undefined, "Queue MP4"); + movButton = btnRow3.add("button", undefined, "Queue MOV"); + + btnRow4 = buttonsPanel.add("group"); + btnRow4.orientation = "row"; + btnRow4.alignChildren = ["fill", "center"]; + btnRow4.spacing = 6; + exrButton = btnRow4.add("button", undefined, "Queue EXR"); + exrPreviewButton = btnRow4.add("button", undefined, "Queue EXR (Review)"); + + btnRow4b = buttonsPanel.add("group"); + btnRow4b.orientation = "row"; + btnRow4b.alignChildren = ["fill", "center"]; + btnRow4b.spacing = 6; + importExrButton = btnRow4b.add("button", undefined, "Import EXR"); + + btnRow4c = buttonsPanel.add("group"); + btnRow4c.orientation = "row"; + btnRow4c.alignChildren = ["fill", "center"]; + btnRow4c.spacing = 6; + prepDeliveryButton = btnRow4c.add("button", undefined, "Prep Delivery"); + + btnRow5 = buttonsPanel.add("group"); + btnRow5.orientation = "row"; + btnRow5.alignChildren = ["fill", "center"]; + btnRow5.spacing = 6; + importRendersButton = btnRow5.add("button", undefined, "Import Renders"); + + btnRow6 = buttonsPanel.add("group"); + btnRow6.orientation = "row"; + btnRow6.alignChildren = ["fill", "center"]; + btnRow6.spacing = 6; + pictureLockButton = btnRow6.add("button", undefined, "Pull Picture Lock"); + + btnRow6b = buttonsPanel.add("group"); + btnRow6b.orientation = "row"; + btnRow6b.alignChildren = ["fill", "center"]; + btnRow6b.spacing = 6; + setStartTCButton = btnRow6b.add("button", undefined, "Set Start Timecode"); + + btnRow7 = buttonsPanel.add("group"); + btnRow7.orientation = "row"; + btnRow7.alignChildren = ["fill", "center"]; + btnRow7.spacing = 6; + incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version"); + + workspacePanel = win.add("panel", undefined, "Workspace"); + workspacePanel.orientation = "column"; + workspacePanel.alignChildren = ["fill", "top"]; + workspacePanel.margins = 8; + workspacePanel.spacing = 4; + initWorkspaceButton = workspacePanel.add("button", undefined, "Initialise Workspace"); + + colorSpacePanel = win.add("panel", undefined, "Color Space"); + colorSpacePanel.orientation = "column"; + colorSpacePanel.alignChildren = ["fill", "top"]; + colorSpacePanel.margins = 8; + colorSpacePanel.spacing = 4; + ocioButton = colorSpacePanel.add("button", undefined, "Add OCIO to Footage Layers"); + + statusPanel = win.add("panel", undefined, "Status"); + statusPanel.orientation = "column"; + statusPanel.alignChildren = ["fill", "top"]; + statusPanel.margins = 10; + + uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected"); + uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready"); + + uiState.episodeDropdown.onChange = populateShotDropdown; + shotBuilderButton.onClick = buildShotFromDropdown; + queueExportButton.onClick = queueExport; + refreshExportButton.onClick = refreshExportStatus; + retryExportButton.onClick = retryLatestExport; + refreshButton.onClick = refreshSelection; + fixNamesButton.onClick = fixCompNames; + overlayButton.onClick = addOverlay; + previewButton.onClick = buildPreviews; + exrButton.onClick = queueEXR; + exrPreviewButton.onClick = queueEXRPreview; + importExrButton.onClick = importExportedEXR; + prepDeliveryButton.onClick = prepDelivery; + mp4Button.onClick = queueMP4; + movButton.onClick = queueMOV; + ocioButton.onClick = applyOCIOColorSpace; + initWorkspaceButton.onClick = initialiseWorkspace; + importRendersButton.onClick = importRenders; + pictureLockButton.onClick = pullPictureLock; + setStartTCButton.onClick = setStartFrameFromTimecode; + incrementVersionButton.onClick = incrementShotVersions; + + win.layout.layout(true); + win.layout.resize(); + win.onResizing = win.onResize = function () { + this.layout.resize(); + }; + + return win; + } + + // app.scheduleTask evaluates in global scope — expose the poll tick there + $.global.__vfxrExportPollTick = pollExportTick; + + var panel = buildUI(thisObj); + refreshSelection(); + populateEpisodeDropdown(); + + if (panel instanceof Window) { + panel.center(); + panel.show(); + } +}(this)); diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx new file mode 100644 index 0000000..9732105 --- /dev/null +++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; +import { + EXPORT_STATUS_STYLES, + FAILED_STATUSES, + ACTIVE_STATUSES, + statusLabel, + formatEta, +} from "./status-colors"; + +interface QueueExport { + id: string; + shotCode: string; + episode: string | null; + projectName: string; + projectCode: string; + versionString: string; + status: string; + statusChangedAt: string; + createdAt: string; + outputDir: string; + job: { + id: string; + attempt: number; + status: string; + progress: number; + currentFrame: number | null; + totalFrames: number | null; + etaSeconds: number | null; + priority: number; + machineName: string | null; + errorMessage: string | null; + } | null; +} + +interface Props { + projects: { id: string; name: string; code: string }[]; +} + +export function PipelineQueueClient({ projects }: Props) { + const { data: session } = useSession(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [projectId, setProjectId] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? ""); + + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ["pipeline-queue", projectId, statusFilter], + queryFn: async () => { + const params = new URLSearchParams({ limit: "200" }); + if (projectId !== "all") params.set("projectId", projectId); + if (statusFilter !== "all") params.set("status", statusFilter); + const res = await fetch(`/api/render/queue?${params}`); + if (!res.ok) throw new Error("Failed to load queue"); + return res.json() as Promise<{ exports: QueueExport[]; pagination: { total: number } }>; + }, + refetchInterval: 5000, + }); + + const exports = data?.exports ?? []; + const counts = { + queued: exports.filter((e) => e.status === "QUEUED").length, + rendering: exports.filter((e) => e.status === "RENDERING").length, + failed: exports.filter((e) => FAILED_STATUSES.includes(e.status)).length, + readyForQc: exports.filter((e) => e.status === "READY_FOR_QC").length, + readyForDelivery: exports.filter((e) => e.status === "READY_FOR_DELIVERY").length, + }; + + async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") { + const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" }); + } else { + toast({ title: `Export ${verb === "mark-done" ? "marked done" : verb === "retry" ? "requeued" : "cancelled"}` }); + } + queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] }); + } + + const cards: { label: string; value: number; className?: string }[] = [ + { label: "Queued", value: counts.queued }, + { label: "Rendering", value: counts.rendering, className: "text-blue-400" }, + { label: "Failed", value: counts.failed, className: "text-red-400" }, + { label: "Ready for QC", value: counts.readyForQc, className: "text-amber-400" }, + { label: "Ready for Delivery", value: counts.readyForDelivery, className: "text-green-400" }, + ]; + + return ( +
+
+
+

Render Queue

+

+ Exports queued from the AE panel — live status, retries and history +

+
+
+ + + + +
+
+ + {/* Summary cards */} +
+ {cards.map((c) => ( +
+
{c.value}
+
{c.label}
+
+ ))} +
+ + {/* Filters */} +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + + {isLoading ? ( + + + + ) : exports.length === 0 ? ( + + + + ) : ( + exports.map((e) => ( + + + + + + + + + + + )) + )} + +
ShotVersionStatusProgressMachineAttemptQueuedActions
+ Loading… +
+ No exports yet — queue one from the After Effects panel +
+ + {e.shotCode} + +
+ {e.projectCode} + {e.episode ? ` · ep ${e.episode}` : ""} +
+
{e.versionString} + + {statusLabel(e.status)} + + + {e.status === "RENDERING" && e.job ? ( +
+ +
+ {e.job.currentFrame != null && e.job.totalFrames != null + ? `frame ${e.job.currentFrame}/${e.job.totalFrames} · ` + : ""} + ETA {formatEta(e.job.etaSeconds)} +
+
+ ) : ( + + )} +
{e.job?.machineName ?? "—"} + {e.job ? `${e.job.attempt}` : "—"} + + {new Date(e.createdAt).toLocaleString()} + +
+ {FAILED_STATUSES.includes(e.status) && ( + + )} + {ACTIVE_STATUSES.includes(e.status) && ( + + )} + {isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && ( + + )} +
+
+
+
+ ); +} diff --git a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx new file mode 100644 index 0000000..c0f6635 --- /dev/null +++ b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx @@ -0,0 +1,256 @@ +"use client"; + +import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, RotateCcw, XCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; +import { + EXPORT_STATUS_STYLES, + FAILED_STATUSES, + ACTIVE_STATUSES, + statusLabel, +} from "../../status-colors"; + +interface ExportDetail { + export: { + id: string; + status: string; + versionString: string; + statusChangedAt: string; + createdAt: string; + aepPath: string; + compName: string; + outputDir: string; + outputPattern: string; + frameStart: number; + frameEnd: number; + fps: number; + width: number; + height: number; + colorspace: string | null; + exrFileCount: number | null; + exrTotalBytes: number | null; + vfxScope: string | null; + submissionNote: string | null; + submittedByName: string | null; + shot: { + id: string; + shotCode: string; + episode: string | null; + project: { id: string; name: string; code: string }; + }; + renderJobs: { + id: string; + attempt: number; + status: string; + priority: number; + progress: number; + currentFrame: number | null; + totalFrames: number | null; + errorMessage: string | null; + logTail: string | null; + exitCode: number | null; + claimedAt: string | null; + startedAt: string | null; + finishedAt: string | null; + machine: { id: string; name: string } | null; + }[]; + events: { + id: string; + fromStatus: string | null; + toStatus: string; + actorType: string; + actorId: string | null; + note: string | null; + createdAt: string; + }[]; + }; +} + +export function ExportDetailClient({ exportId }: { exportId: string }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["pipeline-export", exportId], + queryFn: async () => { + const res = await fetch(`/api/render/exports/${exportId}`); + if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load export"); + return res.json() as Promise; + }, + refetchInterval: 5000, + }); + + async function action(verb: "retry" | "cancel") { + const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" }); + } + queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] }); + } + + if (isLoading) return
Loading…
; + if (error || !data) { + return ( +
+ {(error as Error)?.message ?? "Export not found"} +
+ ); + } + + const e = data.export; + const manifestRows: [string, string][] = [ + ["Project", `${e.shot.project.name} (${e.shot.project.code})`], + ["Shot", e.shot.shotCode], + ["Comp", e.compName], + ["AEP", e.aepPath], + ["Output dir", e.outputDir], + ["Pattern", e.outputPattern], + ["Frames", `${e.frameStart}–${e.frameEnd} @ ${e.fps} fps`], + ["Resolution", `${e.width}×${e.height}`], + ["Colorspace", e.colorspace ?? "—"], + ["EXR files", e.exrFileCount != null ? String(e.exrFileCount) : "—"], + ["Submitted by", e.submittedByName ?? "—"], + ]; + + return ( +
+
+
+ + + +
+

+ {e.shot.shotCode} · {e.versionString} +

+ + {statusLabel(e.status)} + +
+
+
+ {FAILED_STATUSES.includes(e.status) && ( + + )} + {ACTIVE_STATUSES.includes(e.status) && ( + + )} +
+
+ + {/* Manifest */} +
+
Manifest
+
+ {manifestRows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ + {/* Per-submission slate fields */} + {(e.vfxScope || e.submissionNote) && ( +
+
+ Submission +
+
+ {e.vfxScope && ( +
+
VFX Scope
+
{e.vfxScope}
+
+ )} + {e.submissionNote && ( +
+
Submission Note
+
{e.submissionNote}
+
+ )} +
+
+ )} + + {/* Render attempts */} +
+
+ Render attempts +
+
+ {e.renderJobs.map((j) => ( +
+
+ Attempt {j.attempt} + {j.status} + {j.machine?.name ?? "unclaimed"} + {j.exitCode != null && exit {j.exitCode}} + {j.startedAt && ( + + started {new Date(j.startedAt).toLocaleString()} + + )} + {j.finishedAt && ( + + finished {new Date(j.finishedAt).toLocaleString()} + + )} +
+ {j.errorMessage &&
{j.errorMessage}
} + {j.logTail && ( +
+ + Log tail + +
+                    {j.logTail}
+                  
+
+ )} +
+ ))} + {e.renderJobs.length === 0 && ( +
No attempts yet
+ )} +
+
+ + {/* Event timeline */} +
+
Timeline
+
    + {e.events.map((ev) => ( +
  1. + + {new Date(ev.createdAt).toLocaleString()} + + + {ev.fromStatus ? `${statusLabel(ev.fromStatus)} → ` : ""} + {statusLabel(ev.toStatus)} + · {ev.actorType.toLowerCase()} + {ev.note && {ev.note}} + +
  2. + ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/pipeline/exports/[exportId]/page.tsx b/app/(dashboard)/pipeline/exports/[exportId]/page.tsx new file mode 100644 index 0000000..d4bb2a5 --- /dev/null +++ b/app/(dashboard)/pipeline/exports/[exportId]/page.tsx @@ -0,0 +1,17 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { ExportDetailClient } from "./ExportDetailClient"; + +export const dynamic = "force-dynamic"; + +export default async function ExportDetailPage({ + params, +}: { + params: Promise<{ exportId: string }>; +}) { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (session.user.role === "CLIENT") redirect("/dashboard"); + const { exportId } = await params; + return ; +} diff --git a/app/(dashboard)/pipeline/machines/MachinesClient.tsx b/app/(dashboard)/pipeline/machines/MachinesClient.tsx new file mode 100644 index 0000000..92cc4fc --- /dev/null +++ b/app/(dashboard)/pipeline/machines/MachinesClient.tsx @@ -0,0 +1,206 @@ +"use client"; + +import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { ArrowLeft, Zap, Power, Cpu, HardDrive, MemoryStick } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; + +interface MachineRow { + id: string; + name: string; + hostname: string; + enabled: boolean; + status: "ONLINE" | "OFFLINE" | "DISABLED"; + lastSeenAt: string | null; + workerVersion: string | null; + aeVersion: string | null; + availability: { mode?: string } | null; + renderNowUntil: string | null; + latestHeartbeat: { + createdAt: string; + cpuPercent: number | null; + memPercent: number | null; + diskFreeGb: number | null; + } | null; + currentJob: { + id: string; + exportId: string | null; + shotCode: string | null; + versionString: string | null; + progress: number; + etaSeconds: number | null; + } | null; +} + +const STATUS_STYLES: Record = { + ONLINE: "bg-green-500/15 text-green-400 border-green-500/30", + OFFLINE: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", + DISABLED: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +export function MachinesClient() { + const { data: session } = useSession(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? ""); + + const { data, isLoading } = useQuery({ + queryKey: ["pipeline-machines"], + queryFn: async () => { + const res = await fetch("/api/machines"); + if (!res.ok) throw new Error("Failed to load machines"); + return res.json() as Promise<{ machines: MachineRow[] }>; + }, + refetchInterval: 10000, + }); + + async function patch(machineId: string, body: Record, okMsg: string) { + const res = await fetch(`/api/machines/${machineId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const resBody = await res.json().catch(() => ({})); + if (!res.ok) { + toast({ title: "Update failed", description: resBody.error ?? res.statusText, variant: "destructive" }); + } else { + toast({ title: okMsg }); + } + queryClient.invalidateQueries({ queryKey: ["pipeline-machines"] }); + } + + const machines = data?.machines ?? []; + + return ( +
+
+ + + +
+

Render Machines

+

+ Worker status, render windows and the Render Now override +

+
+
+ + {isLoading ? ( +
Loading…
+ ) : machines.length === 0 ? ( +
+ No machines registered yet — install the RenderWorker service on a workstation and it will + appear here after its first registration. +
+ ) : ( +
+ {machines.map((m) => { + const renderNowActive = m.renderNowUntil && new Date(m.renderNowUntil) > new Date(); + return ( +
+
+
+
{m.name}
+
{m.hostname}
+
+ + {m.status} + +
+ +
+
Worker {m.workerVersion ?? "?"} · AE {m.aeVersion ?? "?"}
+
+ Availability: {m.availability?.mode ?? "ALWAYS"} + {renderNowActive && ( + + {" "}· Render Now until {new Date(m.renderNowUntil!).toLocaleTimeString()} + + )} +
+ {m.lastSeenAt &&
Last seen {new Date(m.lastSeenAt).toLocaleString()}
} +
+ + {m.latestHeartbeat && ( +
+ + + {m.latestHeartbeat.cpuPercent != null ? `${Math.round(m.latestHeartbeat.cpuPercent)}%` : "—"} + + + + {m.latestHeartbeat.memPercent != null ? `${Math.round(m.latestHeartbeat.memPercent)}%` : "—"} + + + + {m.latestHeartbeat.diskFreeGb != null ? `${Math.round(m.latestHeartbeat.diskFreeGb)} GB free` : "—"} + +
+ )} + + {m.currentJob ? ( +
+
+ Rendering{" "} + {m.currentJob.exportId ? ( + + {m.currentJob.shotCode} {m.currentJob.versionString} + + ) : ( + "job" + )} +
+ +
+ ) : ( +
Idle
+ )} + +
+ + {isAdmin && ( + + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/(dashboard)/pipeline/machines/page.tsx b/app/(dashboard)/pipeline/machines/page.tsx new file mode 100644 index 0000000..4c9f3cd --- /dev/null +++ b/app/(dashboard)/pipeline/machines/page.tsx @@ -0,0 +1,12 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { MachinesClient } from "./MachinesClient"; + +export const dynamic = "force-dynamic"; + +export default async function MachinesPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (session.user.role === "CLIENT") redirect("/dashboard"); + return ; +} diff --git a/app/(dashboard)/pipeline/page.tsx b/app/(dashboard)/pipeline/page.tsx new file mode 100644 index 0000000..4ea24e6 --- /dev/null +++ b/app/(dashboard)/pipeline/page.tsx @@ -0,0 +1,20 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { db } from "@/lib/db"; +import { PipelineQueueClient } from "./PipelineQueueClient"; + +export const dynamic = "force-dynamic"; + +export default async function PipelinePage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (session.user.role === "CLIENT") redirect("/dashboard"); + + const projects = await db.project.findMany({ + where: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + select: { id: true, name: true, code: true }, + orderBy: { name: "asc" }, + }); + + return ; +} diff --git a/app/(dashboard)/pipeline/status-colors.ts b/app/(dashboard)/pipeline/status-colors.ts new file mode 100644 index 0000000..f1e7bec --- /dev/null +++ b/app/(dashboard)/pipeline/status-colors.ts @@ -0,0 +1,33 @@ +/** Shared status→style maps for the pipeline pages. */ + +export const EXPORT_STATUS_STYLES: Record = { + QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30", + RENDERING: "bg-blue-500/15 text-blue-400 border-blue-500/30", + RENDER_FAILED: "bg-red-500/15 text-red-400 border-red-500/30", + VALIDATING: "bg-sky-500/15 text-sky-400 border-sky-500/30", + VALIDATION_FAILED: "bg-red-500/15 text-red-400 border-red-500/30", + GENERATING_PREVIEW: "bg-indigo-500/15 text-indigo-400 border-indigo-500/30", + PREVIEW_FAILED: "bg-red-500/15 text-red-400 border-red-500/30", + READY_FOR_QC: "bg-amber-500/15 text-amber-400 border-amber-500/30", + QC_FAILED: "bg-red-500/15 text-red-400 border-red-500/30", + READY_FOR_DELIVERY: "bg-green-500/15 text-green-400 border-green-500/30", + PACKAGED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", + SUPERSEDED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30", + ARCHIVED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30", + CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30", +}; + +export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"]; +export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"]; + +export function statusLabel(status: string): string { + return status.replaceAll("_", " "); +} + +export function formatEta(seconds: number | null | undefined): string { + if (seconds == null || seconds <= 0) return "—"; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 9f4b32c..2688ed6 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -34,6 +34,7 @@ import { import { Input } from "@/components/ui/input"; import type { ShotWithDetails } from "@/types"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; +import { ShotExportsTab } from "@/components/shots/ShotExportsTab"; import { FootageViewer } from "@/components/shots/FootageViewer"; import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog"; import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots"; @@ -89,7 +90,7 @@ export default function ShotDetailPage() { const [isDuplicating, setIsDuplicating] = useState(false); const [isActioning, setIsActioning] = useState(false); const [highResDialogOpen, setHighResDialogOpen] = useState(false); - const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks"); + const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "exports" | "settings">("tasks"); const [editingVersion, setEditingVersion] = useState(false); const [versionInput, setVersionInput] = useState(""); const [savingVersion, setSavingVersion] = useState(false); @@ -543,6 +544,18 @@ export default function ShotDetailPage() {