RenderPipeline2
Deploy / deploy (push) Failing after 1m52s

This commit is contained in:
twotalesanimation
2026-08-01 15:44:26 +02:00
parent 0d0f3e1a33
commit 9a49cdc6a3
11 changed files with 2626 additions and 1 deletions
+1380
View File
File diff suppressed because it is too large Load Diff
@@ -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 <key>
- 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.
@@ -89,7 +89,7 @@ export default function ShotDetailPage() {
const [isDuplicating, setIsDuplicating] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false); const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false); const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings" | "exports">("tasks");
const [editingVersion, setEditingVersion] = useState(false); const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState(""); const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false); const [savingVersion, setSavingVersion] = useState(false);
@@ -531,6 +531,18 @@ export default function ShotDetailPage() {
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
Reviews Reviews
</button> </button>
<button
onClick={() => setActiveTab("exports")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "exports"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<ListTodo className="h-4 w-4" />
Exports
</button>
<button <button
onClick={() => setActiveTab("footage")} onClick={() => setActiveTab("footage")}
className={cn( className={cn(
@@ -658,6 +670,16 @@ export default function ShotDetailPage() {
/> />
)} )}
{activeTab === "exports" && (
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">Export history</h3>
<span className="text-xs text-muted-foreground">Queue API-backed entries</span>
</div>
<p className="text-sm text-muted-foreground">Exports submitted for this shot will appear here once they are queued through the new pipeline API.</p>
</div>
)}
{activeTab === "settings" && canManage && ( {activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} /> <ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)} )}
+53
View File
@@ -0,0 +1,53 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { listExportsForQueue } from "@/lib/render-pipeline/exports";
export const metadata = { title: "Render Queue — VFX Review" };
export default async function RenderQueuePage() {
const session = await auth();
if (!session?.user) redirect("/login");
const exports = await listExportsForQueue();
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Render Queue</h1>
<p className="text-sm text-muted-foreground">Queued and active exports from the pipeline.</p>
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-zinc-900/80 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left">Shot</th>
<th className="px-4 py-3 text-left">Version</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Created</th>
</tr>
</thead>
<tbody>
{exports.map((item) => (
<tr key={item.id} className="border-t border-border/60">
<td className="px-4 py-3 font-mono">{item.shot?.shotCode ?? item.shotId}</td>
<td className="px-4 py-3">{item.versionString}</td>
<td className="px-4 py-3">{item.status}</td>
<td className="px-4 py-3">{new Date(item.createdAt).toLocaleString()}</td>
</tr>
))}
{exports.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
No exports queued yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getLatestExportForShot } from "@/lib/render-pipeline/exports";
function isAuthorized(req: NextRequest): boolean {
const apiKey = process.env.API_SECRET_KEY;
if (!apiKey) return false;
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey;
return (req.headers.get("x-api-key") ?? "") === apiKey;
}
export async function GET(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const shotId = searchParams.get("shotId");
if (!shotId) {
return NextResponse.json({ error: "shotId query param is required" }, { status: 400 });
}
const exportRecord = await getLatestExportForShot(shotId);
return NextResponse.json({ export: exportRecord });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createRenderExport, listExportsForQueue } from "@/lib/render-pipeline/exports";
function isAuthorized(req: NextRequest): boolean {
const apiKey = process.env.API_SECRET_KEY;
if (!apiKey) return false;
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey;
return (req.headers.get("x-api-key") ?? "") === apiKey;
}
const createExportSchema = z.object({
shotId: z.string().min(1),
projectId: z.string().min(1),
manifest: z.unknown(),
submittedById: z.string().optional().nullable(),
submittedByName: z.string().optional().nullable(),
taskId: z.string().optional().nullable(),
});
export async function GET(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exports = await listExportsForQueue();
return NextResponse.json({ exports });
}
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const parsed = createExportSchema.parse(body);
const result = await createRenderExport(parsed);
return NextResponse.json(result, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: "Validation error", details: error.errors }, { status: 422 });
}
return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to queue export" }, { status: 400 });
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { listExportsForQueue } from "@/lib/render-pipeline/exports";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exports = await listExportsForQueue();
return NextResponse.json({ exports });
}
+1
View File
@@ -31,6 +31,7 @@ const navItems = [
{ href: '/projects', label: 'Projects', icon: FolderOpen }, { href: '/projects', label: 'Projects', icon: FolderOpen },
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true }, { href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true }, { href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
{ href: '/render-queue', label: 'Render Queue', icon: ListTodo, hideForClient: true },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true }, { href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
{ href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true }, { href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true },
{ href: '/storyboard', label: 'Storyboard', icon: LayoutGrid, supervisorOnly: true }, { href: '/storyboard', label: 'Storyboard', icon: LayoutGrid, supervisorOnly: true },
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildVersionString, deriveOutputBaseName, validateExportManifest } from "./exports";
describe("render export helpers", () => {
it("formats version strings with zero-padding", () => {
expect(buildVersionString(4)).toBe("v004");
expect(buildVersionString(42)).toBe("v042");
});
it("derives the output base name from a frame pattern", () => {
expect(deriveOutputBaseName("UNG_106_010_020_cmp_TT_v004.[####].exr")).toBe(
"UNG_106_010_020_cmp_TT_v004"
);
});
it("rejects invalid manifests", () => {
expect(() =>
validateExportManifest({
shotCode: "UNG_106_010_020",
projectCode: "UNG_S1",
aepPath: "",
compName: "",
frameStart: 1001,
frameEnd: 1000,
fps: 24,
})
).toThrow();
});
});
+175
View File
@@ -0,0 +1,175 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { Prisma } from "@prisma/client";
const exportManifestSchema = z.object({
shotCode: z.string().min(1),
projectCode: z.string().min(1),
aepPath: z.string().min(1),
compName: z.string().min(1),
rendererType: z.string().default("aerender"),
outputDir: z.string().optional(),
outputPattern: z.string().optional(),
frameStart: z.number().int().positive(),
frameEnd: z.number().int().positive(),
fps: z.number().positive(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
colorspace: z.string().optional(),
});
export function validateExportManifest(input: unknown) {
return exportManifestSchema.parse(input);
}
export function buildVersionString(versionNumber: number) {
return `v${versionNumber.toString().padStart(3, "0")}`;
}
export function deriveOutputBaseName(outputPattern: string) {
const match = outputPattern.match(/^(.*)\.(?:\[#+\]|\{#+\})\.([A-Za-z0-9]+)$/);
return match ? match[1] : outputPattern;
}
export async function createRenderExport(input: {
shotId: string;
projectId: string;
manifest: unknown;
submittedById?: string | null;
submittedByName?: string | null;
taskId?: string | null;
}) {
const manifest = validateExportManifest(input.manifest);
const shot = await db.shot.findUnique({
where: { id: input.shotId },
select: { id: true, shotVersion: true, exrOutput: true, projectId: true },
});
if (!shot) {
throw new Error("Shot not found");
}
if (shot.projectId !== input.projectId) {
throw new Error("Shot does not belong to the supplied project");
}
const versionNumber = parseInt((shot.shotVersion ?? "v001").replace(/^v/i, ""), 10) + 1;
const versionString = buildVersionString(versionNumber);
const outputPattern = manifest.outputPattern ?? `${manifest.compName}_TT_${versionString}.[####].exr`;
const outputDir = manifest.outputDir ?? `${shot.exrOutput ?? manifest.compName}_${versionString}`;
const result = await db.$transaction(async (tx) => {
const activeExports = await tx.export.findMany({
where: {
shotId: input.shotId,
status: { in: ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW", "READY_FOR_QC", "READY_FOR_DELIVERY"] },
},
select: { id: true },
});
const exportRecord = await tx.export.create({
data: {
shotId: input.shotId,
projectId: input.projectId,
taskId: input.taskId ?? undefined,
versionNumber,
versionString,
status: "QUEUED",
aepPath: manifest.aepPath,
compName: manifest.compName,
rendererType: manifest.rendererType,
outputDir,
outputPattern,
frameStart: manifest.frameStart,
frameEnd: manifest.frameEnd,
fps: manifest.fps,
width: manifest.width ?? 1920,
height: manifest.height ?? 1080,
colorspace: manifest.colorspace,
submittedById: input.submittedById ?? undefined,
submittedByName: input.submittedByName ?? undefined,
},
});
await tx.exportEvent.createMany({
data: [
{
exportId: exportRecord.id,
toStatus: "QUEUED",
actorType: "USER",
actorId: input.submittedById ?? undefined,
note: "Queued from panel",
},
],
});
await tx.renderJob.create({
data: {
type: "AE_RENDER",
exportId: exportRecord.id,
attempt: 1,
maxAttempts: 3,
status: "QUEUED",
manifest: manifest as Prisma.JsonObject,
priority: 50,
},
});
await tx.shot.update({
where: { id: input.shotId },
data: {
shotVersion: versionString,
exrOutput: deriveOutputBaseName(outputPattern),
},
});
if (activeExports.length > 0) {
await tx.export.updateMany({
where: { id: { in: activeExports.map((item) => item.id) } },
data: {
status: "SUPERSEDED",
supersededById: exportRecord.id,
statusChangedAt: new Date(),
},
});
await tx.exportEvent.createMany({
data: activeExports.map((item) => ({
exportId: item.id,
fromStatus: "QUEUED",
toStatus: "SUPERSEDED",
actorType: "SYSTEM",
note: "Superseded by newer export",
})),
});
}
return { exportRecord, versionString };
});
return result;
}
export async function listExportsForQueue() {
return db.export.findMany({
where: { status: { notIn: ["SUPERSEDED", "ARCHIVED", "CANCELLED", "DELIVERED"] } },
orderBy: [{ createdAt: "desc" }],
include: {
shot: { select: { shotCode: true, shotVersion: true, project: { select: { code: true } } } },
renderJobs: { orderBy: [{ createdAt: "desc" }], take: 1 },
},
});
}
export async function getLatestExportForShot(shotId: string) {
return db.export.findFirst({
where: { shotId },
orderBy: [{ createdAt: "desc" }],
include: {
shot: { select: { shotCode: true, project: { select: { code: true } } } },
renderJobs: { orderBy: [{ createdAt: "desc" }], take: 1 },
validations: true,
},
});
}
+126
View File
@@ -144,6 +144,40 @@ enum AttachmentCategory {
MISCELLANEOUS MISCELLANEOUS
} }
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
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// AUTH MODELS (NextAuth v5 compatible) // AUTH MODELS (NextAuth v5 compatible)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@@ -271,6 +305,7 @@ model Project {
projectType ProjectType @default(STANDARD) projectType ProjectType @default(STANDARD)
description String? @db.Text description String? @db.Text
status ProjectStatus @default(ACTIVE) status ProjectStatus @default(ACTIVE)
deliveryConfig Json?
dueDate DateTime? dueDate DateTime?
startDate DateTime? startDate DateTime?
clientId String? clientId String?
@@ -356,6 +391,7 @@ model Shot {
shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id]) shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
versions Version[] versions Version[]
tasks Task[] tasks Task[]
exports Export[]
footagePlates FootagePlate[] footagePlates FootagePlate[]
references ShotReference[] references ShotReference[]
loggedTakes Take[] @relation("TakeToShot") loggedTakes Take[] @relation("TakeToShot")
@@ -410,6 +446,96 @@ model ShotGroup {
@@map("shot_groups") @@map("shot_groups")
} }
model Export {
id String @id @default(cuid())
shotId String
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
projectId String
taskId String?
versionId String?
versionNumber Int
versionString String
status ExportStatus @default(QUEUED)
statusChangedAt DateTime @default(now())
aepPath String
compName String
rendererType String
outputDir String
outputPattern String
frameStart Int
frameEnd Int
fps Float
width Int
height Int
colorspace String?
deliveryMovPath String?
previewMovKey String?
thumbnailKey String?
metadataKey String?
exrFileCount Int?
exrTotalBytes BigInt?
checksum String?
submittedById String?
submittedByName String?
supersededById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
renderJobs RenderJob[]
events ExportEvent[]
@@unique([shotId, versionNumber])
@@index([status])
@@index([projectId, status])
@@map("exports")
}
model RenderJob {
id String @id @default(cuid())
type RenderJobType @default(AE_RENDER)
exportId String?
export Export? @relation(fields: [exportId], references: [id], onDelete: Cascade)
deliveryId String?
attempt Int @default(1)
maxAttempts Int @default(3)
status RenderJobStatus @default(QUEUED)
priority Int @default(50)
manifest Json
machineId String?
claimedAt DateTime?
leaseExpiresAt DateTime?
startedAt DateTime?
finishedAt DateTime?
progress Float @default(0)
currentFrame Int?
totalFrames Int?
etaSeconds Int?
exitCode Int?
errorMessage String?
logTail String?
logFileKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, priority, createdAt])
@@map("render_jobs")
}
model ExportEvent {
id String @id @default(cuid())
exportId String
export Export @relation(fields: [exportId], references: [id], onDelete: Cascade)
fromStatus String?
toStatus String
actorType String
actorId String?
note String?
createdAt DateTime @default(now())
@@index([exportId, createdAt])
@@map("export_events")
}
model Version { model Version {
id String @id @default(cuid()) id String @id @default(cuid())
versionNumber Int versionNumber Int