Files
vfxreview/prisma/schema.prisma
T
twotalesanimation feb1c7b3e9 feat(render-pipeline): validation, QC and delivery builder
Completes RenderPipeline2 phases 3-5. Additive throughout: with
validation.enabled=false the pipeline behaves exactly as before.

Validation (§8) runs on the worker straight after a render, while it
still holds the job and the files are local. Everything is read from the
EXR header — one 64 KB read per sampled frame, no pixel decode — so the
engine needs no oiiotool. The render job deliberately stays open through
the stage, so a worker that dies there is reaped and retried instead of
stranding the export in VALIDATING.

Timecode is the primary check and is anchored on the plate, never the
API: measured on the live show, Shot.timecodeStart is the editorial
in-point and sits one handle-length (8f) ahead of the plate, so checking
against it would pass the defective exports and fail the correct ones.
Reference precedence is manifest timing plate -> plate found on disk ->
API value, and the API rank is WARN-only. The panel now resolves plate
provenance by walking the comp's own footage layers, so the worker never
guesses which element carries the comp's timing.

Verified against real studio renders via the new --validate mode: it
catches both defect classes seen in shipped work — the +8 handle offset
and frame-derived timecode (00:00:41:17 = frame 1001 at 24fps).

QC (§10) is one service behind two front doors, API-key for the panel
and session for the web. PASS means ready to upload and nothing else;
FAIL sets the comp task to CHANGES. The panel gains a QC section that
imports the real EXR sequence at full res rather than the H.264.

Delivery (§11) rides the existing queue as a DELIVERY_BUILD job: hard
links with a copy fallback, verification by re-hashing delivered bytes
against the validation-time digest, manifest.json and the CSV report.
fsutil hardlink list is unavailable over SMB, so link identity is never
queried — reading the bytes back covers links and copies alike.

Also fixes ApiClient.ProgressAsync throwing on transport failure, which
would have aborted work in flight during a brief server outage; progress
reporting is best-effort by contract.

Removes the stale RenderWorker/ rule from the root .gitignore. The
worker's source is tracked like the rest of the repo and its own
.gitignore already covers bin/, obj/ and publish/; the rule silently
excluded new files while the existing 20 stayed tracked.

Not yet applied: migration 20260813000000 was hand-written because the
dev database was unavailable — run prisma migrate deploy and verify
before relying on it. None of this has run against a live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:49:51 +02:00

1093 lines
32 KiB
Plaintext

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─────────────────────────────────────────────
// ENUMS
// ─────────────────────────────────────────────
enum Role {
ADMIN
PRODUCER
SUPERVISOR
ARTIST
CLIENT
}
enum ProjectStatus {
ACTIVE
ON_HOLD
COMPLETED
ARCHIVED
}
enum ShotStatus {
WAITING
IN_PROGRESS
INTERNAL_REVIEW
READY_FOR_CLIENT
CLIENT_REVIEW
REVISIONS
COMPLETE
}
enum ShotApprovalStatus {
PENDING
INTERNALLY_APPROVED
CLIENT_APPROVED
}
enum ShotPriority {
LOW
NORMAL
HIGH
URGENT
}
enum ApprovalStatus {
PENDING_REVIEW
APPROVED
REJECTED
NEEDS_CHANGES
}
enum ReviewStatus {
PENDING
INTERNAL_APPROVED
CLIENT_APPROVED
NEEDS_CHANGES
FINAL_APPROVED
}
enum NotificationType {
VERSION_UPLOADED
FEEDBACK_ADDED
SHOT_APPROVED
SHOT_REJECTED
COMMENT_REPLY
MENTION
REVISION_REQUESTED
TASK_ASSIGNED
TASK_OVERDUE
TASK_APPROVED
TASK_CHANGES_REQUESTED
TASK_READY_FOR_REVIEW
}
enum TaskStatus {
TODO
IN_PROGRESS
INTERNAL_REVIEW
CLIENT_REVIEW
CHANGES
DONE
}
enum TaskType {
TRACK
ROTO
KEY
COMP
FX
LIGHTING
RENDER
ANIMATION
MODEL
TEXTURE
RIG
LOOKDEV
GENERAL
}
enum ProjectType {
STANDARD
EPISODIC
}
// ─────────────────────────────────────────────
// RENDER PIPELINE ENUMS (RenderPipeline2 §5.1)
// ─────────────────────────────────────────────
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
}
enum TakeQuality {
HERO
GOOD
PRINT
NO_GOOD
FALSE_START
}
enum AttachmentFileType {
IMAGE
VIDEO
HDRI
LIDAR
LENS_GRID
PDF
OTHER
}
enum AttachmentCategory {
SLATE
CAMERA_POSITION
WIDE_REFERENCE
LENS
LIGHTING
HDRI
TRACKING
TEXTURE
WITNESS
MISCELLANEOUS
}
// ─────────────────────────────────────────────
// AUTH MODELS (NextAuth v5 compatible)
// ─────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
passwordHash String?
role Role @default(ARTIST)
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// NextAuth relations
accounts Account[]
sessions Session[]
// App relations
producedProjects Project[] @relation("ProducerProjects")
supervisedProjects Project[] @relation("SupervisorProjects")
assignedShots Shot[] @relation("ArtistShots")
uploadedVersions Version[]
comments Comment[]
commentReplies CommentReply[]
annotations Annotation[]
approvals Approval[]
notifications Notification[]
clientAccess ClientAccess[]
assignedTasks Task[] @relation("TaskArtist")
createdTasks Task[] @relation("TaskCreator")
sharedVersions Version[] @relation("VersionSharedBy")
ledAssets Asset[] @relation("AssetLead")
createdShootDays ShootDay[] @relation("ShootDayCreator")
createdTakes Take[] @relation("TakeCreator")
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
// ─────────────────────────────────────────────
// CORE DOMAIN MODELS
// ─────────────────────────────────────────────
model Client {
id String @id @default(cuid())
company String
contactPerson String
email String @unique
phone String?
notes String? @db.Text
logoUrl String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projects Project[]
clientAccess ClientAccess[]
@@map("clients")
}
/// Links a USER with CLIENT role to the client record they represent
model ClientAccess {
id String @id @default(cuid())
userId String
clientId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@unique([userId, clientId])
@@map("client_access")
}
model Project {
id String @id @default(cuid())
name String
code String @unique
showId String @default("")
projectType ProjectType @default(STANDARD)
description String? @db.Text
status ProjectStatus @default(ACTIVE)
dueDate DateTime?
startDate DateTime?
clientId String?
producerId String?
supervisorId String?
slackWebhook String?
slackChannel String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client? @relation(fields: [clientId], references: [id])
producer User? @relation("ProducerProjects", fields: [producerId], references: [id])
supervisor User? @relation("SupervisorProjects", fields: [supervisorId], references: [id])
shots Shot[]
assets Asset[]
tasks Task[]
shotGroups ShotGroup[]
reviewSessions ReviewSession[]
episodeDueDates EpisodeDueDate[]
shootDays ShootDay[]
/// Per-production delivery naming/layout templates (RenderPipeline2 §11.2);
/// falls back to SystemConfig defaults when null
deliveryConfig Json?
deliveryPackages DeliveryPackage[]
@@map("projects")
}
model EpisodeDueDate {
id String @id @default(cuid())
projectId String
episode String
dueDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([projectId, episode])
@@map("episode_due_dates")
}
model Shot {
id String @id @default(cuid())
shotCode String
scene String @default("")
episode String?
shotNumber Int @default(0)
sequence String?
description String? @db.Text
notes String? @db.Text
status ShotStatus @default(WAITING)
priority ShotPriority @default(NORMAL)
artistId String?
projectId String
frameStart Int?
frameEnd Int?
fps Float @default(24)
dueDate DateTime?
thumbnailUrl String?
originalFootageUrl String?
originalFootageKey String?
shotGroupId String?
shotApprovalStatus ShotApprovalStatus @default(PENDING)
sharedWithClient Boolean @default(false)
// EDL / pull CSV fields
sourceClip String?
timecodeStart String?
timecodeEnd String?
clipDuration String?
exrOutput String?
// Sequence / picture lock timecodes
seqTimecodeStart String?
seqTimecodeEnd String?
// High-res deliverable (stored in Hetzner Object Storage)
highResKey String?
highResFilename String?
// Shot-level version tracking (e.g. v001, v002)
shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation
isKeyShot Boolean @default(false)
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
slateType String?
slateDescription String? @db.Text
slateVfxScope String? @db.Text
slateSubmissionNote String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
artist User? @relation("ArtistShots", fields: [artistId], references: [id])
shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
versions Version[]
tasks Task[]
footagePlates FootagePlate[]
references ShotReference[]
loggedTakes Take[] @relation("TakeToShot")
exports Export[]
@@unique([projectId, shotCode])
@@map("shots")
}
model FootagePlate {
id String @id @default(cuid())
shotId String
label String @default("")
fileUrl String
fileKey String @default("")
fileName String @default("")
fileSize BigInt?
sortOrder Int @default(0)
createdAt DateTime @default(now())
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
@@map("footage_plates")
}
model ShotReference {
id String @id @default(cuid())
shotId String
label String?
fileUrl String
fileKey String @default("")
fileName String @default("")
fileSize BigInt?
sortOrder Int @default(0)
createdAt DateTime @default(now())
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
@@map("shot_references")
}
model ShotGroup {
id String @id @default(cuid())
name String
projectId String
sortOrder Int @default(0)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
shots Shot[]
@@unique([projectId, name])
@@map("shot_groups")
}
model Version {
id String @id @default(cuid())
versionNumber Int
shotId String?
taskId String?
artistId String?
fileUrl String
fileName String
fileSize BigInt?
mimeType String?
thumbnailUrl String?
posterUrl String?
proxyUrl String?
fps Float @default(24)
duration Float?
frameCount Int?
width Int?
height Int?
notes String? @db.Text
approvalStatus ApprovalStatus @default(PENDING_REVIEW)
reviewStatus ReviewStatus @default(PENDING)
isLatest Boolean @default(true)
isClientVisible Boolean @default(false)
sharedAt DateTime?
sharedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade)
artist User? @relation(fields: [artistId], references: [id])
sharedBy User? @relation("VersionSharedBy", fields: [sharedById], references: [id])
comments Comment[]
annotations Annotation[]
approvals Approval[]
@@map("versions")
}
model Comment {
id String @id @default(cuid())
versionId String
authorId String?
frameNumber Int
timestamp Float
text String @db.Text
isResolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
version Version @relation(fields: [versionId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
replies CommentReply[]
annotations Annotation[]
@@map("comments")
}
model CommentReply {
id String @id @default(cuid())
commentId String
authorId String?
text String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@map("comment_replies")
}
/// Stores canvas drawing data as JSON (normalized 0-1 coordinates)
model Annotation {
id String @id @default(cuid())
versionId String
commentId String?
authorId String?
frameNumber Int
drawingData Json // Array of AnnotationShape objects
color String @default("#ef4444")
isVisible Boolean @default(true)
createdAt DateTime @default(now())
version Version @relation(fields: [versionId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull)
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@map("annotations")
}
model Approval {
id String @id @default(cuid())
versionId String
userId String?
status ApprovalStatus
notes String? @db.Text
createdAt DateTime @default(now())
version Version @relation(fields: [versionId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@map("approvals")
}
model Notification {
id String @id @default(cuid())
userId String
type NotificationType
title String
message String @db.Text
data Json? // Extra context (versionId, shotCode, etc.)
isRead Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("notifications")
}
model Asset {
id String @id @default(cuid())
projectId String
assetCode String
name String
description String? @db.Text
status ShotStatus @default(WAITING)
priority ShotPriority @default(NORMAL)
leadId String?
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
lead User? @relation("AssetLead", fields: [leadId], references: [id])
tasks Task[]
@@unique([projectId, assetCode])
@@map("assets")
}
model Task {
id String @id @default(cuid())
title String
description String? @db.Text
type TaskType @default(GENERAL)
status TaskStatus @default(TODO)
priority ShotPriority @default(NORMAL)
dueDate DateTime?
estimatedHours Float?
sortOrder Int @default(0)
shotId String?
assetId String?
assignedArtistId String?
createdById String?
projectId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scheduledStartDate DateTime?
scheduledEndDate DateTime?
scheduleNotes String? @db.Text
shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)
assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id])
createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
versions Version[]
@@index([scheduledStartDate])
@@index([scheduledEndDate])
@@map("tasks")
}
/// Secure tokenized review link for clients
model ReviewSession {
id String @id @default(cuid())
projectId String
token String @unique @default(cuid())
label String?
email String?
passwordHash String?
expiresAt DateTime
isActive Boolean @default(true)
accessCount Int @default(0)
allowedEpisodes String[] @default([])
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("review_sessions")
}
/// Key-value store for system-wide configuration (e.g. storage credentials)
model SystemConfig {
key String @id
value String @db.Text
updatedAt DateTime @updatedAt
@@map("system_config")
}
/// Physical display event log — polled by ESP32 dot-matrix display
model DisplayEvent {
id Int @id @default(autoincrement())
type String // APPROVED | CHANGES | COMMENT | REJECTED
shotCode String
by String // actor name (reviewer / commenter)
createdAt DateTime @default(now())
@@index([createdAt])
@@map("display_events")
}
// ─────────────────────────────────────────────
// SHOOT LOG MODELS
// ─────────────────────────────────────────────
model ShootDay {
id String @id @default(cuid())
projectId String
date DateTime
unit String @default("A")
label String?
notes String? @db.Text
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull)
setups Setup[]
@@map("shoot_days")
}
model Setup {
id String @id @default(cuid())
shootDayId String
name String
description String? @db.Text
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shootDay ShootDay @relation(fields: [shootDayId], references: [id], onDelete: Cascade)
takes Take[]
@@map("setups")
}
model Take {
id String @id @default(cuid())
setupId String
takeNumber Int
// Optional link back to a pipeline shot
pipelineShotId String?
// General
scene String?
shotLabel String?
unitLabel String?
// Camera
cameraLetter String?
clipName String?
roll String?
cameraModel String?
resolution String?
codec String?
fps Float?
shutter String?
iso Int?
whiteBalance Int?
colourSpace String?
// Lens
lensSet String?
lens String?
tStop String?
filters String?
isAnamorphic Boolean @default(false)
// Tracking
hasHdri Boolean @default(false)
hasChromeBall Boolean @default(false)
hasGreyBall Boolean @default(false)
hasMacbeth Boolean @default(false)
hasCleanPlate Boolean @default(false)
hasSurvey Boolean @default(false)
hasLidar Boolean @default(false)
hasWitnessCamera Boolean @default(false)
hasLensGrid Boolean @default(false)
hasTexturePhotos Boolean @default(false)
hasPhotogrammetry Boolean @default(false)
// Environment
weather String?
sunDirection String?
artificialLights String? @db.Text
// Supervisor
supervisorNotes String? @db.Text
continuityNotes String? @db.Text
vfxRequirements String? @db.Text
quality TakeQuality @default(PRINT)
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
setup Setup @relation(fields: [setupId], references: [id], onDelete: Cascade)
pipelineShot Shot? @relation("TakeToShot", fields: [pipelineShotId], references: [id], onDelete: SetNull)
createdBy User? @relation("TakeCreator", fields: [createdById], references: [id], onDelete: SetNull)
attachments TakeAttachment[]
@@unique([setupId, takeNumber])
@@map("takes")
}
model TakeAttachment {
id String @id @default(cuid())
takeId String
fileUrl String
fileKey String @default("")
fileName String
fileSize BigInt?
fileType AttachmentFileType @default(IMAGE)
category AttachmentCategory @default(MISCELLANEOUS)
caption String?
sortOrder Int @default(0)
uploadedById String?
createdAt DateTime @default(now())
take Take @relation(fields: [takeId], references: [id], onDelete: Cascade)
uploadedBy User? @relation("TakeAttachmentUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
@@map("take_attachments")
}
// ─────────────────────────────────────────────
// RENDER PIPELINE MODELS (RenderPipeline2 §5)
// Phases 1 + 2: Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat.
// ValidationResult / QCReview / DeliveryPackage / DeliveryItem arrive in
// later phases as additive migrations.
// ─────────────────────────────────────────────
/// One row per "Queue Export" click — carries the §4 state machine.
model Export {
id String @id @default(cuid())
shotId String
shot Shot @relation(fields: [shotId], references: [id])
projectId String
taskId String? // comp task the preview Version attaches to
versionId String? // preview Version created at READY_FOR_QC (Phase 4)
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 (§9)
previewMovKey String? // web H.264 transcode in object storage
thumbnailKey String?
metadataKey String? // metadata JSON in object storage
exrFileCount Int?
exrTotalBytes BigInt?
checksum String? // sequence-level digest (xxHash of per-file hashes)
// Slate fields authored per submission (not per shot): they change from one
// delivery to the next, so each Export carries its own and new exports
// default to the previous export's values.
vfxScope String? @db.Text
submissionNote String? @db.Text
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[]
events ExportEvent[]
validations ValidationResult[]
qcReviews QCReview[]
deliveryItems DeliveryItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([shotId, versionNumber])
@@index([status])
@@index([projectId, status])
@@map("exports")
}
/// One execution attempt of work by a worker. Also used (type DELIVERY_BUILD)
/// for delivery package builds — one queue/claim/lease/heartbeat mechanism.
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 (Phase 5)
attempt Int @default(1)
maxAttempts Int @default(3)
status RenderJobStatus @default(QUEUED)
priority Int @default(50) // lower = sooner; <= 20 is urgent
manifest Json // full Render Manifest snapshot (§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? @db.Text // 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])
@@map("render_jobs")
}
/// Append-only audit log of Export state transitions (§5.9)
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])
@@map("export_events")
}
/// A render-capable machine (artist workstation or future render node) (§5.6)
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: {...} }
availability Json? // §7.10: { mode, windows, allowUrgentAnytime } — null = ALWAYS
renderNowUntil DateTime? // manual "Render Now" override; claims allowed until this time
apiKeyHash String? // optional per-machine key (dormant; §15 — shared key for now)
renderJobs RenderJob[]
heartbeats WorkerHeartbeat[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("machines")
}
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])
@@map("worker_heartbeats")
}
/// One row per check per validation run, so the UI shows exactly what failed
/// and history survives retries (§5.4).
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 // "timecode_plate_match", "missing_frames", ...
status ValidationStatus
expected String?
actual String?
message String?
details Json? // e.g. list of missing frame numbers, or the timecode reference used
createdAt DateTime @default(now())
@@index([exportId, renderJobId])
@@map("validation_results")
}
/// Human technical QC on an Export (§5.5). Multiple rows allowed — the latest
/// is authoritative and drives T13/T14.
model QCReview {
id String @id @default(cuid())
exportId String
export Export @relation(fields: [exportId], references: [id])
result QCResult
notes String? @db.Text
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])
@@map("qc_reviews")
}
/// A dated delivery folder built from READY_FOR_DELIVERY exports (§5.7).
/// Never deleted — a mistaken package is CANCELLED or superseded.
model DeliveryPackage {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id])
episode String // "101" — empty string on standard (non-episodic) shows
name String // "260805_Delivery"
packageNumber Int // sequential per project → "Package #15"
status DeliveryStatus @default(DRAFT)
rootPath String? // final path on the delivery volume
configSnapshot Json // naming template + options at build time (immutable record)
manifest Json? // built manifest: files, sizes, checksums
reportKey String? // delivery report in 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])
@@index([status])
@@map("delivery_packages")
}
/// Version lock: the exact Export delivered. shotCode/versionString are
/// denormalised so a later shot rename or re-render never mutates history.
model DeliveryItem {
id String @id @default(cuid())
packageId String
package DeliveryPackage @relation(fields: [packageId], references: [id], onDelete: Cascade)
exportId String
export Export @relation(fields: [exportId], references: [id])
shotId String
shotCode String
versionString String
exrPath String? // path inside the package
movPath String?
fileCount Int?
totalBytes BigInt?
checksum String?
linkMode String? // "hardlink" | "copy" — what the builder actually did
status String @default("PENDING") // PENDING | COPIED | VERIFIED | FAILED
errorMessage String?
@@unique([packageId, exportId])
@@index([exportId])
@@map("delivery_items")
}
model SketchTemplate {
id String @id @default(cuid())
name String
fileUrl String
fileKey String @default("")
fileName String @default("")
fileSize BigInt?
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@map("sketch_templates")
}