Files
vfxreview/prisma/schema.prisma
T
twotalesanimation b151e8cc52
Deploy / deploy (push) Successful in 7m25s
feat(pipeline): per-shot client naming alias for slates, overlays, and delivery
Clients sometimes ship footage under a naming convention that doesn't
match our own shot codes. Adds an optional Shot.clientCode, editable on
the Slate tab: when set, it replaces shotCode only in what the client
actually sees — slate identity, overlay burn-in, and delivered EXR/MOV
file and folder names in Netflix packages — with our own version string
flatly appended (no "_cmp_TT_" studio jargon mixed in). Internal render
paths, EXR filenames on the SAN, comp names, and DB lookups are
untouched. Shots without a client code render identically to before.

Delivery packages denormalize clientCode at build time, same as the
existing shotCode/versionString snapshot, so a later shot edit can't
retroactively change already-built delivery history. The worker now
renames delivered EXR frames and the delivery MOV using the existing
frame-number parser (SequencePattern) so only the base name changes —
frame numbers and padding survive exactly. This is the one piece of the
change that could plausibly ship the wrong bytes, so it's covered by
new unit tests (renaming, padding, MOV rename, checksum-survives-rename,
and a graceful fallback for an unparseable frame number) rather than
resting on manual verification alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 00:38:12 +02:00

1155 lines
35 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
/// A standalone Blender render: a queue entry and nothing more. It has no
/// Export, takes no shot version, and is not validated, QC'd or delivered —
/// it is "Ctrl+F12, but on the farm".
BLENDER_RENDER
}
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?
/// Per-production AE workspace: the show's slate/overlay template AEP, its
/// renders root, and any comp names that depart from the {showId}_* naming
/// convention. Read by both the panel and the headless preview build, which
/// is why it lives here rather than in per-machine panel settings.
pipelineConfig 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
/// Client-supplied naming alias, used only for slates, overlay burn-ins,
/// and delivered file/folder names — never for internal paths, comp names
/// or DB lookups, which always use shotCode. Null = use shotCode as today.
clientCode 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)
/// Full-duration (handles included), ungraded MP4 of just the picture-lock
/// plate — a diagnostic reference to check back against, not a reviewable
/// deliverable, so it lives here rather than as a Version.
piclockRefKey String?
// 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
/// Render the EXRs but build no preview, inheriting the previous export's
/// review media instead. For an export whose framing differs from the review
/// framing — unscaled or un-punched-in plates — the earlier MOV is the
/// correct preview, and rebuilding it from these EXRs would be wrong.
skipPreview Boolean @default(false)
/// Skip picture-lock trimming and the picture-lock reference render for this
/// export — set when picture lock is missing or wrong for the shot, so the
/// preview build falls back to the untrimmed full render range instead of
/// trimming against bad data.
noPictureLock Boolean @default(false)
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)
/// Set for jobs that belong to a project but not to a shot — a standalone
/// Blender render. Lets the queue be filtered by production without
/// inventing an Export for work that has no shot.
projectId String?
/// Human label for such a job, since there is no shot code to show.
label String?
submittedBy String? // email/name of whoever queued it
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])
@@index([projectId, 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")
}
/// What version of each studio tool is running where. Written by the tool
/// itself on startup, so it reflects what is *deployed* rather than what
/// someone remembered to write down. One row per (tool, machine).
model ToolVersion {
id String @id @default(cuid())
tool String // "AE_PANEL" | "RENDER_WORKER"
machineName String
version String // "2.1.0"
buildRef String? // git sha / build stamp, when the tool can report one
hostUser String? // artist email, or the worker's service account
hostApp String? // "After Effects 24.6" / OS build
details Json?
firstSeenAt DateTime @default(now())
lastSeenAt DateTime @default(now())
@@unique([tool, machineName])
@@index([tool])
@@map("tool_versions")
}
/// 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
/// Snapshot of Shot.clientCode at package build time — same immutability
/// rule as shotCode/versionString: a later shot edit must not retroactively
/// change already-built delivery history.
clientCode 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")
}