+1380
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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user