61 Commits

Author SHA1 Message Date
twotalesanimation 59af7959e6 fix(connector): request the full QC queue instead of the default 50
Deploy / deploy (push) Successful in 6m41s
loadQCQueue called /api/ext/qc/queue with no limit, so it always got the
endpoint's default page of 50. The query orders oldest-first, so a project
with more than 50 exports in READY_FOR_QC showed only the oldest 50 with
nothing to say the list was short.

Ask for 200 — the server's hard ceiling in getQCQueue — and say so in the
status line when the count comes back at the cap, so a truncated queue is
visible rather than silent.

The diagnostics call in checkServerPathReachable stays on the default: it
only reads entries[0].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:24:21 +02:00
twotalesanimation a6ee1917e7 fix(connector): cancel the export poll when the panel closes or reloads
Deploy / deploy (push) Successful in 6m46s
app.scheduleTask outlives the panel instance that armed it. The repeating task
id was held in the pipelineState closure, so closing or reloading the panel took
the id with it and left nothing able to cancel the task: it kept firing every
15 s for the rest of the AE session, and every tick landing while a modal dialog
was open raised "Cannot run a script while a modal dialog is waiting for
response."

The id now lives in global scope beside the tick function that scheduleTask
already evaluates there, so the next panel instance can still reach it.
getPollTaskId and setPollTaskId wrap the access and treat anything that is not a
positive number as no task. Panel startup cancels whatever a previous instance
left armed before pointing the tick at itself, rather than letting the old task
fire into a fresh, empty state whose shot code went with the closure that armed
it, and panel.onClose stops the poll on the way out.

The export is unaffected either way: it runs on the farm, and the header picks
its status back up from the server on the next Refresh. Section 12.1 of
RenderPipeline2.md records that.

PANEL_VERSION goes to 2.6.1 per the documented rule, so Pipeline → Machines does
not show two workstations on one version behaving differently.

This change was authored in the working tree, not by Claude; it is committed
here alongside the status and client-portal work at the author's request, and
the message was written from reading the diff. It has not been exercised in a
live After Effects session as part of this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:18:08 +02:00
twotalesanimation 28e8c47907 feat(client-portal): let a reviewer edit and delete their own notes
Notes left through a review link were write-once. A client who mistyped a
timecode or changed their mind had no way to correct it and had to add a second
note contradicting the first, which the studio then had to reconcile by hand.

PATCH and DELETE on comment/[commentId] cover both. Only the reviewer's own
notes are touchable: the studio's comments and those of every other review
session are read-only through a link, whatever the holder sends. Ownership is
re-checked server-side on every write, so the canEdit flag now returned by the
versions route only decides whether to offer the controls and grants nothing.

Refusals are 404 rather than 403 throughout, since a link holder has no business
learning which comment ids exist outside their own notes.

Only the text is editable. The frame a note is pinned to is what the studio
works from, so moving it would silently repoint feedback someone may already
have actioned; a note about a different frame is a new note. Deleting a drawing
note also removes its strokes, mirroring deleteComment in actions/comments.ts,
and the page reloads the player afterwards so the frame does not keep showing
marks that no longer exist. Delete goes through a confirmation that shows the
note and says plainly that it disappears for the studio too.

Worth knowing before this reaches a show: everyone holding the same review link
is the same author. A ReviewSession with no email resolves to a token-derived
guest user, so one link sent to three people gives all three one identity and
any of them can now edit or delete the others' notes. That model is not new,
which is why their comments already showed as one name, but edit and delete is
the first feature where it grants real power rather than muddling attribution.
Per-reviewer links with email set are the fix if that is not wanted, and that is
a change to how links are issued rather than to this code. The scope is
documented on reviewClientEmail so the next reader meets it before it surprises
them.

getOrCreateClientUser was copy-pasted into the comment and approve routes with
the fallback-email expression spelled out in three places. It is one set of
helpers in review-auth.ts now, including findReviewUser, a read-only variant so
that merely opening a review link does not conjure a User row just to answer
"is this theirs?", and ownsComment, the single rule shared by the route that
offers the controls and the route that enforces them.

Nine tests cover that rule directly: internal users' notes stay read-only, a
session that has never posted owns nothing, and an authorless comment — one
whose User was deleted, since Comment.authorId is SetNull — cannot be claimed by
anyone. The routes themselves are not covered, so exercise edit and delete
against a real review link before handing one to a client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:40 +02:00
twotalesanimation 94792279c5 fix(status): reset review state on a new version, and derive shot status everywhere
Requesting changes on a shot and then re-rendering it through the render queue
left every layer of the app still showing the old verdict. Three independent
causes, all on the pipeline path.

finalizePreview inherited the superseded version's approvalStatus
unconditionally. The carry-over exists so a re-export of a signed-off shot does
not reappear as awaiting review, but it applied equally to NEEDS_CHANGES and
REJECTED, so the render queued to address a note arrived already wearing that
note and nothing registered that the work had been redone. carryApproval now
carries only APPROVED.

The pipeline never moved the task. One left on CHANGES by a review or a QC fail
stayed there through every re-render, with no way out but a manual kanban drag.
A genuinely new version now moves it to INTERNAL_REVIEW, as actions/versions.ts
and batch-upload already did. A replaced version is excluded: regeneratePreview
re-delivers the same version after a slate fix and is not a new submission.

The pipeline never called recalcShotStatus at all, so shot boards went stale the
moment a render changed a task. finalizePreview and the QC fail path both
recalculate now; the latter already documented the REVISIONS derivation it was
relying on and never triggering.

Shot.status is derived from tasks and approval flags, but six call sites wrote
it directly with the status they expected: internallyApproveShot,
shareWithClient, unshareFromClient, clientApproveShot, unapproveShot and the
client approve route. Rule 1 of the derivation is that any CHANGES task outranks
an approval, so a shot could be pushed past open rework and then snap back to
REVISIONS on the next unrelated recalc, which is close to unreportable as a bug.
They now set the approval inputs and let recalcShotStatus derive.
unshareFromClient additionally hardcoded READY_FOR_CLIENT even for a
client-approved shot. updateShotStatus keeps its direct write as the deliberate
manual override, now documented as transient for the same reason.

markShotForRevisions replaces the duplicated change-request logic. Both copies
marked all non-DONE tasks CHANGES, which matches nothing on the shot this is
most often called for: one out for client review precisely because all its tasks
are finished. The request then derived to INTERNAL_REVIEW and had no visible
effect anywhere. When nothing is picked up it now reopens the task behind the
shot's latest version, which is the deliverable the reviewer was looking at.

A client's shot-level change request also marks the versions and writes Approval
rows now. The dashboard counters, the version badges and the portal's own
filters all read Version.approvalStatus, so a shot-level verdict was invisible
everywhere those look.

clearStaleShotApproval covers the reverse case: uploading a new version onto an
approved shot moved the task to INTERNAL_REVIEW and changed nothing visible,
because the derivation short-circuits on shotApprovalStatus. The shot kept
reading COMPLETE while carrying work nobody had looked at. This is the one
user-visible behaviour change here, so watch the boards on a live show: an
approved shot now drops back to INTERNAL_REVIEW on a new upload. sharedWithClient
is deliberately left alone, since revoking client access stays a separate act.

submitApproval and unapproveVersion are transactional now. The verdict, the
version, the task and the derived shot status are one decision, and a failure
partway through left the three layers disagreeing with no way to tell which one
was right.

KanbanCard compared approvalStatus against "CHANGES_REQUESTED", which is not a
member of the enum, so the changes-requested icon never rendered at all.

Thirteen tests cover carryApproval, markShotForRevisions including the all-DONE
regression, and clearStaleShotApproval. What is not covered is the pipeline
transaction itself, which needs a database: queue a render against a shot in
REVISIONS and confirm the task and shot both move before trusting this on a
show.

Version.reviewStatus is still written nowhere while being published through
ext/shots/lookup and EXT_API_REFERENCE.md as a permanent "PENDING". Left alone
deliberately: removing a field from that contract could break the connector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:04 +02:00
twotalesanimation 93137cdcd2 fix(connector): anchor built comps on the plate's own EXR timecode
Build Shot produced comps starting at 00:00:00:00 because the panel never
actually read the EXR timecode. getFootageStartFrames ranked a fragile "New
Comp from Selection" probe first: it toggles the Project panel via the Window
menu to get focus, and where that does not land the menu item is greyed out,
executeCommand silently does nothing, and the probe returns null. A shot with
no timecodeStart then fell through the API rank to zero.

The worker already reads EXR headers properly in ExrHeader.cs, and its
validation ranks the plate's own timeCode above the API value, which stores the
editorial in-point and sits a handle-length ahead of the plate by design. Shots
were therefore being built from rank 3 and validated against rank 1. That is a
timecode desync waiting to reach a delivery, not just a cosmetic start frame.

readExrTimecode ports the worker's reader to ExtendScript and becomes rank 1:
one short header read, walking `name\0 type\0 int32 size data` for timeCode and
falling back to timeCodeString where a writer omitted the packed attribute.
decodePackedTimecode mirrors Timecode.FromPacked, decoding the SMPTE 12M BCD a
byte at a time so the arithmetic never touches the sign bit. The menu probe is
kept as rank 2 and now runs only when the plate carries no timecode at all, so
the Project-panel toggle is off the normal path.

setCompStartFrame was the second failure. Assigning displayStartFrame on a
version of AE that does not have the property is ignored silently rather than
throwing, so the early return skipped the displayStartTime fallback entirely.
It now reads the value back, falls through when it did not take, and returns
whether anything stuck; buildShotFromCode logs when nothing does. A start frame
that cannot be set is now visible in the diagnostics report instead of a comp
quietly sitting at zero.

A plate whose timecode is present but zero still falls through to the API rank,
keeping the existing frames > 0 convention so the safety net survives. If a
genuine 00:00:00:00 plate should win outright that is a deliberate follow-up.

The parser is covered against synthetic headers, including the 0x18585712
example documented in ExrHeader.cs, drop-frame, truncated and non-EXR input.
What is not yet exercised in a live After Effects session is mainSource.file
resolving to the sequence's first frame and displayStartFrame taking on a given
AE build, so build one shot and check its start against the plate before
relying on this. Picture-lock automation was reverted for a timecode desync of
the same shape.

PANEL_VERSION goes to 2.6.0 per the documented rule, so Pipeline → Machines
does not show two workstations on one version behaving differently. The
startSource comment in the manifest schema enumerated only the two older
values; the queue-time provenance check keys on /\bAPI\b/ and so already reads
"EXR timecode …" as trustworthy rather than suspect.

The same silent-failure setCompStartFrame still exists in the farm-side
vfxr_build_preview.jsx, which is fed the right value from the manifest but can
drop it the same way. Left for its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:53:49 +02:00
twotalesanimation 6678721fb1 fix(pipeline): create the comp task an export needs when a shot has none
Deploy / deploy (push) Successful in 6m48s
A shot with no COMP task rendered and previewed fine and then showed
nothing in the app. createExport set Export.taskId from the shot's first
COMP task or null, and finalizePreview registered the review Version with
that same null taskId — so the MP4 was uploaded and stored, but the
Version hung off no task and none of the review flows had anywhere to
draw it. From the artist's side the export completed and produced
nothing.

E1 now resolves the shot's comp task inside the version-allocation
transaction and creates one when there isn't any, titled after the
preview file the export will produce: {shotCode}_cmp_TT_{version}, the
same convention the manual batch upload's create-task path already uses.

renderOutputBase() is exported and buildPreviewManifest takes its `base`
from it, so the task title and the MOV/MP4 name come from one function
rather than two copies of the same template. The title therefore already
carries the version, which makes finalizePreview's rename-to-the-
delivered-version a no-op on that first export rather than an immediate
rename of a task created moments earlier.

The lookup and the create sit inside the transaction so the P2002
version-collision retry rolls a losing attempt's task back with
everything else, instead of leaving a duplicate behind.

recalcShotStatus runs only when a task was actually created: a shot with
no tasks derives WAITING and now has one. Approval-driven statuses still
take precedence, so an already-approved shot stays COMPLETE.

Both the queue-time ExportEvent note and the E1 response record what
happened, so a task appearing under a shot on its own is traceable to the
export that made it.

Exports already in flight with a null taskId keep their orphaned
versions; backfilling those is a separate migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:09:01 +02:00
twotalesanimation bac525ddd0 feat(shot-status): shot version beside the name, and a scaled-in-edit flag
Deploy / deploy (push) Successful in 7m28s
Two small board additions.

The shot version now shows next to the shot code. It is Shot.shotVersion —
the value the AE panel burns into overlays and queueExport renders under,
and the one the shot detail page edits — not a count of uploaded review
Versions, so the board agrees with what the artist has in the panel.

Shot.isScaled records that a shot is scaled in the edit: the plate is not
used at its native size, which changes how the shot is comped and
reviewed. That makes it a standing property of the shot rather than
something buried in the notes, so it is flagged the way isKeyShot is — a
cyan pill next to the shot name, and a Scaled toggle column beside Key.
toggleScaledShot() (actions/shots.ts) mirrors toggleKeyShot, same roles.

KeyShotToggle's switch markup would have been duplicated verbatim for the
second flag, so both now sit on a shared FlagToggle taking a colour tone
and a setter. The key-shot switch behaves exactly as before, optimistic
update and rollback included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:57:05 +02:00
twotalesanimation 7f066c1372 feat(pipeline): MOV review delivery packages from selected shots
Deploy / deploy (push) Successful in 7m28s
A formal review is shared as a flat, dated folder of the preview MOVs —
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review, then _02, _03
for further reviews the same day. It reuses the delivery package's
machinery end to end: DeliveryPackage/DeliveryItem, the DELIVERY_BUILD
job, the same claim path and the same hardlink-or-copy builder, with
DeliveryPackage.kind telling the two apart.

Three things separate a review from a delivery:

A review never advances an Export. Reviews happen throughout a shot's
life, long before QC passes it, and the same version is routinely shown
twice — so nothing is marked PACKAGED, and markDelivered() refuses a
review package outright (the button is hidden too).

Selection is by shot, not by export. Shot Status → Bulk Actions → Build
MOV Review Package; the server resolves each shot to its newest export
that actually has a deliveryMovPath, which is not always its newest
export — a shot whose latest version is still rendering belongs in the
review as the version that can be watched. Shots with no MOV at all are
listed in the confirm dialog with the reason rather than silently
shrinking the folder.

The folder is exactly the MOVs. No CSV, no manifest.json written beside
them; the manifest still returns to the server, so the record of what was
shared is kept without cluttering the share. Names follow the same
client-naming rule as a delivery, so a shot reads identically in a review
folder and in the delivery that follows it.

Verification (DeliveryStage.BuildReviewItem) is proportional as it is for
EXRs, adapted to a single file with no validation-time digest: a hard
link is the source and needs nothing, a copy is checked against its
source by length always and by full hash under "always-hash". A missing
MOV fails the item here — in an EXR delivery the MOV is a viewing copy
beside the real deliverable, but in a review it is the deliverable, and a
review folder quietly one shot short is worse than one that says so.

Worker bumped to 1.6.0 for the new manifest field (DeliveryManifest.Kind).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:21 +02:00
twotalesanimation ed9f9b13c1 feat(client-portal): show shots and task titles in the client's own naming
Deploy / deploy (push) Successful in 6m8s
Shot.clientCode already drove slates, overlay burn-ins and delivered file
names, but the client portal — the one place the client actually reads — was
still showing our internal codes.

Task titles were the larger leak. They follow the house convention
"UNG_106_010_020_cmp_TT_v004", so the internal code sat embedded in the
string the client reads and copies, in three places on the shot list and
again in the player header. Swapping only the heading would have left it on
display directly underneath.

lib/shot-code.ts resolves both: clientShotCode picks the alias when set, and
clientTaskTitle swaps the code out of a title by substring, leaving a
hand-edited title that does not carry the code untouched rather than guessing
at it.

Applied at the API boundary rather than at each render site, so the shot
list, search, playlist labels, alt text, player header and every
copy-to-clipboard button inherit it and nothing downstream has to remember
which code it is holding. The internal code is no longer sent to the browser
on these routes at all.

Slack notifications deliberately keep the internal code: those go to our
artists, who need the code that matches the comp and the SAN path.

The two existing "clientCode || shotCode" expressions in preview.ts and
deliveries.ts now use the same helper so slates, deliveries and the portal
cannot drift apart. Shots with no clientCode are unchanged throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:16:53 +02:00
twotalesanimation 1fad6a739d fix(worker): drain ffmpeg's pipes so thumbnail generation stops deadlocking
Deploy / deploy (push) Successful in 9m16s
ffmpeg was started with stdout and stderr redirected and neither pipe ever
read. Windows gives an anonymous pipe a ~4 KB buffer, which ffmpeg's banner
and stream summary exceed on their own, so it blocked forever on the write,
never exited, and the two-minute cap fired:

    TaskCanceledException at Process.WaitForExitAsync
    Preview complete for job ... in 221s (mov + mp4)

The exception was swallowed as "non-fatal", so every preview silently
produced no thumbnail while paying 120s for the privilege. Nothing
downstream was wrong — there was simply never a thumbnail to promote.

Both pipes are now drained concurrently before the wait. A timeout kills the
process tree rather than leaking an ffmpeg per job, and attempts report a
three-way outcome so a hung or unstartable ffmpeg is not retried — without
that, the slate fallback would have doubled the wasted time to four minutes.

Failures are no longer silent: an unset or missing ffmpegPath, the seek that
was chosen, and ffmpeg's own exit code, command and stderr are all logged.

finalizePreview likewise records every promotion outcome on the export
timeline, not just success. Silence there was indistinguishable from a broken
worker, a project whose flag never saved, and the feature working as
configured — which is what made this take so long to find.

Adds scripts/diagnose-thumbnails.mjs, which walks the chain (ffmpeg →
Export.thumbnailKey → project flag → Shot.generatedThumbnailUrl → what the UI
resolves) and names the first broken link.

Worker 1.5.1 -> 1.5.2; republish required for the fix to take effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:48:29 +02:00
twotalesanimation 9cd7b0df6e chore: retrigger deploy
Deploy / deploy (push) Successful in 5s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:23:42 +02:00
twotalesanimation caaa333f02 feat(pipeline): render-derived shot thumbnails, and bulk shot delete
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together:

Generated shot thumbnails — a shot can now carry a thumbnail promoted
from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom)
alongside the existing brief-supplied thumbnailUrl, which always takes
display precedence (lib/shot-thumbnail.ts) so a promoted frame can only
ever fill a gap, never overwrite the brief. Shows with
pipelineConfig.autoShotThumbnail promote automatically on every
finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/
clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for
everyone else. Worker-side seek logic for picking the promoted frame is
covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs.

Bulk shot delete — the shot-status page's selection bar gets a "Bulk
Actions" dropdown (alongside the existing Change Due Date) with Delete
Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather
than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT
by design, so a shot with renders or deliveries on record is protected
and gets skipped and reported by name rather than aborting the whole
selection or being silently destroyed along with its history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:06:55 +02:00
twotalesanimation b151e8cc52 feat(pipeline): per-shot client naming alias for slates, overlays, and delivery
Deploy / deploy (push) Successful in 7m25s
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
twotalesanimation 26a4719c2a fix(connector): strip reference LUT/overlay before Queue Export renders EXRs
Build Shot adds the show LUT and overlay burn-in into the artist's main
comp, enabled by default, for reference while compositing. The legacy
manual "Queue EXR" button has always disabled both before rendering;
Queue Export (the pipeline's one-click path) never got that same step,
so EXRs queued through it could ship with the LUT graded in and the
burn-in baked on. Queue Export now disables them the same way, right
before the project save the render worker reads from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:47:42 +02:00
twotalesanimation a18dfc6203 revert(pipeline): back out picture-lock trimming from the preview build
Deploy / deploy (push) Successful in 6m19s
Production fallout after enabling it: the trimmed EXR footage started
after the head handle as intended, but the overlay's burn-in timecode
(hardcoded to start at frame 1001) did not shift with it, so the
delivered burn-in no longer matched the footage. The picture-lock
reference render also never worked correctly in practice. Reverts the
AE panel checkbox, the preview-build script's trim/reference logic, and
the server/worker plumbing around it back to their pre-feature state
(82dfaee) — untrimmed previews, no picture-lock reference, overlay
timecode correct again.

The noPictureLock/piclockRefKey schema columns and their migration are
deliberately left in place: they're already applied in production, and
dropping an applied migration's file risks the next `prisma migrate
deploy`. They sit unused and harmless until this is revisited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:44:45 +02:00
twotalesanimation 67d8639861 fix(pipeline): trim picture-lock handles at the footage comp, not the template's SHOT layer
Deploy / deploy (push) Successful in 6m17s
Manipulating startTime/outPoint on previewComp's SHOT layer touched a
comp duplicated from the artist-authored template, whose slate/shot
timing relationship the build script doesn't own -- and it dropped the
slate frame in production. The trim now happens one level down, on the
private single-layer footage precomp this script fully controls;
previewComp's own sizing logic is untouched from before this feature
existed, since it already generalizes correctly to any shotComp
duration.

Also threads the picture-lock skip reason through to the export's
timeline, so a shot missing sequence timecodes or a matching picture
lock file is visible without digging into worker-machine logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:22:49 +02:00
twotalesanimation 86962eb4bd feat(pipeline): automate picture-lock trimming in the preview build
Deploy / deploy (push) Successful in 10m37s
Previews used to ship the full padded (handle-included) render to
clients, with an artist manually pulling picture lock and hand-trimming
the comp to match. The automated preview stage now trims the client
comp to the picture-lock edit itself (a fixed handle count off each
end, per the studio's existing convention) and separately builds an
ungraded, full-duration picture-lock reference MP4 as a diagnostic
sidecar to check back against. A new "No Picture Lock" panel checkbox
is the single override for shots where picture lock is missing or
wrong, falling back to today's untrimmed behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:59:05 +02:00
twotalesanimation 82dfaee56f fix(delivery): stop the 422 retry loop on failed delivery items
Deploy / deploy (push) Successful in 6m37s
A FAILED delivery item never sets fileCount/totalBytes, and the worker's
Dictionary-based JSON payload serializes those as explicit nulls (the
WhenWritingNull option only suppresses nulls on POCO properties, not
dictionary values). The server's schema allowed the fields to be omitted
but not null, so the report was permanently rejected with 422 - and the
worker only ever treated 409/404 as terminal, so it retried the same
malformed payload forever across restarts.
2026-08-21 12:07:30 +02:00
twotalesanimation df219d7932 chore: retrigger deploy
Deploy / deploy (push) Successful in 6m9s
Empty commit to re-fire the Gitea Actions push trigger after a failed
deploy run; no source changes.
2026-08-17 16:05:36 +02:00
twotalesanimation cf55a53c06 fix(client): remove version notes from the client shot-list page
Deploy / deploy (push) Failing after 2s
Removes the three spots on the client review shot-list page that showed
a version's notes under the task title (flat task list, shot-grouped
list, asset list) — one of the three was already commented out by hand,
uncommitted, as a stopgap for the same issue.

The API route backing this page no longer selects notes at all, so it
stops being sent to the client rather than only going unrendered — the
same treatment as removing the pipeline's internal-only notes text in
53fc8dc.

The individual review player (app/client/[token]/review/[versionId]/)
has its own separate notes field and fetch and is untouched: this was
scoped to the shot-list overview only, per what was asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:57:58 +02:00
twotalesanimation 53fc8dc65b fix(pipeline): re-exports keep a shot's approval, and carry no client-facing text
Deploy / deploy (push) Successful in 6m1s
A shot is routinely re-exported for delivery after it has already been
client-approved — most often an unscaled/un-punched-in pass. Every
pipeline-created Version defaulted to approvalStatus PENDING_REVIEW,
which is exactly what the dashboard counts, the shot page badge and the
playlist read as "awaiting review" — so a re-export made an approved
shot look like new unreviewed work to the whole team, with nothing
actually different about its approval.

approvalStatus is now inherited from the task's previous latest version
rather than left at the create default. A task with no prior version
(its first export) still gets PENDING_REVIEW, which is correct.

Separately, every pipeline version was stamped with notes: "Pipeline
render vXXX (internal only)" — and notes is the artist's message to
reviewers, rendered verbatim in the client portal and the review player.
Nothing enforces that a pipeline version stays unshared once created (see
the gap noted in RenderPipeline2.md), so that internal-sounding string
could reach a client if the shot were re-shared after a re-export. notes
is left unset; what a version is and where it came from belongs on the
Export and its timeline, which is where a human would actually go
looking.

RenderPipeline2.md §9 documents both, plus the share-flow gap that let
notes reach a client in the first place: shareWithClient promotes
whichever Version is isLatest with no awareness that a pipeline version
is meant to stay internal permanently. Not fixed here — closing it needs
a way to identify a pipeline-origin version at the share call sites, and
Export.versionId is currently a one-way scalar with no inverse relation
on Version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:52:49 +02:00
twotalesanimation ed8a225106 fix(blender): read carriage-return progress, and let any output prove liveness
Deploy / deploy (push) Successful in 6m5s
Blender renders failed with "produced no output for 600s" on every
attempt while rendering perfectly well.

Two causes, both mine. BeginOutputReadLine raises an event per newline,
and Blender writes its render status with a carriage return so it
overwrites one line in place — a frame taking longer than the stall
timeout emits no newline for its whole duration. stdout is now read as a
character stream with CR and LF both ending a line.

The watchdog was also stricter than its own message: it only accepted a
recognised Fra:/Saved: line as a sign of life, so any output we were not
parsing counted as silence. Any output now proves Blender is alive, which
is what "produced no output" is asking; finished frames still drive
progress, since a single frame emits hundreds of status updates.

Also passes -noaudio: a render node has no sound device, and initialising
audio under a session-less service account is a known way to hang.

Covered by tests over a realistic stdout chunk where progress is
CR-separated and only the saved lines end in LF — newline-only splitting
yields one unusable blob and no progress at all.

Worker 1.5.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:44:49 +02:00
twotalesanimation a18aad4373 fix(blender): hold a reference to the project enum items
Deploy / deploy (push) Successful in 6m6s
The project dropdown rendered garbled labels. EnumProperty does not copy
the strings a callback returns — Python has to keep a reference or
Blender reads freed memory, which shows as mojibake rather than as a
crash. The callback built fresh tuples on every redraw, so each set was
collected the moment it returned.

The items are now built once when the project list changes and held at
module level; the callback returns that list and allocates nothing, which
also makes a per-redraw callback free rather than merely correct.

Add-on 1.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:24:46 +02:00
twotalesanimation 3733c0c4fa fix(blender): send a real User-Agent so Cloudflare stops blocking the add-on
Deploy / deploy (push) Successful in 5m59s
Submitting failed with a 403 before the request reached the server:
Cloudflare error 1010, a browser-signature ban. Python's urllib
identifies itself as "Python-urllib/3.x", which is exactly the scripted
signature that rule targets. The AE panel was unaffected because curl
sends curl/8.x, and the worker because .NET sends its own.

Verified against the live zone, same request and token, only the agent
changed: Python-urllib/3.11 gets 403 while VFXReviewBlender/1.0.1 gets
200 — as do curl and a browser agent.

Naming the tool is also the honest thing: the studio's logs should say
what is calling them, and the version travels with it.

A Cloudflare block is also no longer reported to the artist as a wall of
JSON about browser signatures — it says the firewall stopped the request
before it reached the server, and to ask the pipeline TD.

Add-on 1.0.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:11:52 +02:00
twotalesanimation 70cf45ead4 feat(pipeline): standalone Blender renders on the same queue
Deploy / deploy (push) Successful in 7m2s
Blender work is not a shot submission: no Export, no version, no
validation, QC or delivery. It is "Ctrl+F12, but on the farm", so it is
modelled as what it is — a queue entry and nothing more.

That turned out to need very little. Claim, progress, fail and
complete-render already tolerated a job with no Export behind them, so a
Blender job reuses the whole lifecycle; RenderJob gains projectId, label
and submittedBy so a job with no shot code still has something to show.

Capability gating is new and was already missing: the claim now filters
job types by the machine's reported tools. Without it a workstation
without Blender would claim Blender work and fail it repeatedly, burning
the attempts while a machine that could have rendered it sat idle. The
worker asks for Blender jobs unconditionally and the server refuses them
— trusting the worker to self-select would have left the same hole.

The runner treats a clean exit with no saved frames as a failure:
Blender exits 0 when a frame range is empty or an output path is
unwritable, and a job that silently rendered nothing is the worst
outcome available.

Standalone jobs auto-requeue on a transient failure like any other work;
delivery builds are deliberately excluded, since a half-written package
wants a human first.

Adds the Blender add-on (BlenderPanel/) and its docs. The add-on refuses
to submit an unsaved file — the farm opens the .blend from disk, so
unsaved changes would render the previous state without warning. Its
payload is pinned by a contract test, since add-on and server only ever
meet over JSON.

Worker 1.5.0. Adds migration 20260815000000_blender_render_jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:53:19 +02:00
twotalesanimation bada122f42 feat(pipeline): per-project AE templates, and a workspace that fits any show
Deploy / deploy (push) Successful in 7m4s
Initialise Workspace built UNG_101..UNG_117 from a hardcoded range and
imported one hardcoded AEP, and fourteen findComp calls named UNG's comps
literally. None of it worked for a second show.

Comp names now follow a convention derived from the show id —
{showId}_VFX_OVERLAY, {showId}_EXPORT_TEMPLATE, _SHOW LUT — which
reproduces UNG's existing names exactly, so nothing changes for the show
it was written for. A project whose template departs from the pattern can
override any of the three.

Paths cannot be derived: UNG's show id is UNG while its folder is
UNGO_VFX. Those live on Project.pipelineConfig and are edited under
Project → Settings → Pipeline. They are resolved server-side and handed
to the panel with the project list, and read by buildPreviewManifest for
the headless build, so the panel and the farm cannot disagree about which
template — or which show LUT — a shot was built with. That mattered more
than the folder names: a preview rendered through another show's LUT is
quietly the wrong colour rather than obviously broken.

Episode folders come from the project's episode list, falling back to the
folders under its footage root for a show whose episodes are not entered
yet.

Panel 2.5.0. Adds migration 20260814010000_project_pipeline_config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:24:40 +02:00
twotalesanimation 483db65eb3 fix(connector): build shots at the shot's frame rate, not a hardcoded 24
Deploy / deploy (push) Successful in 6m23s
Shot.fps existed, the lookup API returned it, and one button honoured it
— Set Start Timecode — while the shot builder ignored it entirely. Comps
were created at 24 and every plate conformed to 24.

DH2 is a 25 fps show. Its plates carry framesPerSecond 25/1, so comps
built at 24 conform every plate to the wrong speed and compute every
frame-from-timecode sum against the wrong rate.

The builder now takes the rate from the shot record, which is the show's
delivery rate and therefore the authority; a plate's own rate is whatever
happens to be on disk. Import Renders follows the comp it imports into,
and Import EXR follows the shot.

Validation gains plate_fps, comparing the render against the timing
plate. timecode_fps only compares the render to its manifest and both
come from the comp, so a shot built at the wrong rate agrees with itself
and passes; the plate is the independent witness. It is a WARN, never a
FAIL: sampling DH2 found one plate at 50 fps against a 25 fps show, a
deliberate over-crank, so a whole-number multiple is reported as exactly
that and anything else as a probable rate mistake.

Note this does not fix the data: all 175 DH2 shots still carry fps 24
from the create-time default and need correcting before their comps are
rebuilt.

Panel 2.4.0, worker 1.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:10:10 +02:00
twotalesanimation 41ee826700 fix(pipeline): keep an urgent export urgent through a validation override
Deploy / deploy (push) Successful in 6m11s
Overriding a failed validation queued the preview at priority 50
regardless. It took its rank from the export's last PREVIEW_ONLY job —
but validation runs before the preview stage, so a VALIDATION_FAILED
export has never had one, and the missing row fell through to a
hardcoded normal priority. An urgent shot lost its urgency at exactly
the moment someone was standing over it waiting.

Every preview now derives its rank from the export's AE_RENDER priority
rather than from a previous preview. That fixes the missing-row case and
also removes a slow drift: a preview's priority is already boosted, so
re-deriving from it moved an export a step up the queue on every retry.

createPreviewJob takes the render priority and the attempt number, so the
first build, a retry, a regenerate and an override all rank identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:19:19 +02:00
twotalesanimation c9b0f25bb2 fix(pipeline): let a finished render's preview outrank pending renders
Deploy / deploy (push) Successful in 6m11s
Urgent already propagated — a preview inherits its render's priority, so
an urgent export stays ahead all the way to QC. The complaint was the
other half: at equal priority previews always ran last.

The queue orders by priority then age, and a preview job is only created
when its render finishes, so it is always newer than the renders queued
beside it. Ten shots ran as ten renders and then ten previews, and
nothing was reviewable until the end of the batch.

render.previewPriorityBoost (default 1) subtracts from the render's
priority when the preview is created, so a shot finishes end to end
before the next render starts. The boost never promotes a job across the
urgent threshold: a normal preview must not inherit permission to run
outside the render window that its render never had. Set it to 0 for the
old behaviour.

Worth knowing separately: a worker will not claim preview jobs at all
while an artist has AE open on that machine, which during the day is the
larger reason previews appear to wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:11:49 +02:00
twotalesanimation 4642ecb11f feat(pipeline): skip the preview build and inherit the previous one
Deploy / deploy (push) Successful in 7m8s
Some exports are deliberately framed differently from their review —
unscaled or un-punched-in EXRs — and a preview rebuilt from those frames
would be the wrong picture. The previous export's MOV is the correct one.

skipPreview renders the EXRs and goes straight to READY_FOR_QC. Available
at Queue Export (a "No preview" checkbox in the panel, E1 field) and as an
option when overriding a validation failure.

The inheritance is as important as the skip: deliveryMovPath, the review
MP4, the thumbnail and the Version are carried over from the newest
earlier export of that shot which has them. Without it the delivery
builder — which takes its MOV from deliveryMovPath — would package EXRs
with no MOV at all. Where there is nothing to inherit, the export still
reaches QC and the timeline says it has no review media.

QC is untouched by any of this: a skipped preview still needs a human
pass before the export can be delivered.

Panel 2.3.0. Adds migration 20260814000000_export_skip_preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:28:14 +02:00
twotalesanimation e66e4868ea feat(validation): manual override for a failed run
Deploy / deploy (push) Successful in 6m13s
Checks fail on renders that are genuinely fine — a plate the engine
cannot reach, an expectation that does not apply to this shot — and a
VALIDATION_FAILED export was a dead end, blocking delivery on a shot a
human can see is correct.

Override validation on the Export detail page resumes the pipeline where
a pass would have handed off: preview, then READY_FOR_QC. It never skips
QC and never delivers directly, so the frames are still looked at — the
override waives the automated check, not the human one.

The failed ValidationResult rows are left untouched rather than rewritten,
so the QC queue keeps listing them as warnings and whoever QCs the shot
sees what was waved through. The reason is required and recorded with the
checks it covered on the export's timeline, and the validation panel
keeps an "overridden" badge long after the event scrolls away.

Restricted to admin/producer/supervisor: waving a failed check through is
a supervisor's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:19:45 +02:00
twotalesanimation af810258e6 perf(delivery): stop re-reading hard links to verify them
Deploy / deploy (push) Successful in 6m25s
A 25-shot episode took about an hour to package when the linking itself
is instant. All of it was verification: every delivered file was read
back and hashed, and at ~100 MB/s over SMB one 2.8 GB shot costs 30 s.

Re-hashing a hard link cannot disagree with validation. The entry is a
second name for the same data, so it re-reads the exact bytes that were
already hashed — half an hour per episode to prove something that cannot
be false. Verification is now proportional to what was actually done:
copies are read back in full, links carry over the validation-time digest.

Linking has one gap hashing covered incidentally: a source changed after
it passed QC, which a link would carry faithfully. Source timestamps are
now checked against the export's validation time, which costs a stat per
file rather than a full read.

"verifyMode": "always-hash" restores unconditional read-back for a
production that requires it.

Worker 1.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:29:04 +02:00
twotalesanimation 991abe072c fix(validation): anchor the timecode check on a delivered plate
Deploy / deploy (push) Successful in 6m12s
UNG_110_030_010 v002 failed with a 461614-frame offset on a render whose
timecode was correct. Two causes, both in how the plate reference was
recorded.

The comp held three same-length elements and the timing plate was picked
by duration alone, so a PRERENDER won on layer order. Using a pre-render
as the timecode reference is circular anyway: it was rendered from this
comp, so it can only ever agree with it — including when both are wrong.
Only footage under the Footage Root is now eligible; among those, longest
still wins.

The offset itself came from the plate's frameStart. AE writes renders as
"..._PRERENDER_01001.exr" while delivered plates use "....01001.exr", and
the parser only handled the dot form, so the number fell back to the
comp's frame range — 462615 against a render numbered from 1001. Both
separators now parse, and an unparseable name omits the field rather than
filling it with a number from the wrong coordinate system.

Verified against the real manifest: the BG01 plate is now chosen over
both pre-renders, and all three filenames parse to 1001.

Panel 2.2.1, worker 1.2.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 08:56:18 +02:00
twotalesanimation 1163bd6455 feat(preview): burn the production name into the slate and the overlay
Deploy / deploy (push) Successful in 5m57s
The preview manifest now carries the show name, taken from the project's
name rather than its code — a slate is read by a human, so "Ungovernable"
beats "UNG_S1".

Both the worker's headless build and the panel's own preview set it, so a
farm-generated preview and an artist-built one still match. The panel
writes it whenever it touches an overlay, not only when building a
preview, so existing comps pick it up on the next Add Overlay.

The slate's other properties are title case ("Date", "Scene") while the
overlay's are upper ("DATE", "SHOT NAME"), so both spellings of SHOW are
attempted; a template exposing neither reports its actual property names
as a build warning rather than failing silently, matching how the VFX
Scope and Submission Note fields already behave.

Panel 2.2.0, worker 1.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:42:37 +02:00
twotalesanimation 37f73a2bd7 fix(validation): separate output file numbering from comp frame numbers
Deploy / deploy (push) Successful in 6m13s
A render carries frame numbers in two unrelated systems. The comp is
anchored to its plate's timecode, so its timeline runs 1247792-1247896;
the output module names files from 1001. Validation compared the file
numbers against the comp range and failed every correctly-rendered shot
by ~1.2 million frames — first_frame, last_frame and missing_frames all
reporting a defect that was not there.

The manifest now carries outputFrameStart (the output module's starting
number, house convention 1001) alongside the comp range. Count and
contiguity are measured on the output numbering; first/last compare
against outputFrameStart and are SKIPPED on older manifests, where the
count and gap checks still hold on their own.

The same conflation broke the API timecode fallback: it anchored on the
comp's frameStart while the render's first frame is 1001, producing a
+1246791 frame "offset". Shot.timecodeStart is the edit in-point and the
plate carries handleFrames of head handle, so the reference is the API
value less handles, anchored on the render's own first frame.

Verified on the shot that reported it: UNG_107_051_010 v004 now passes
count, contiguity and a plate-anchored timecode match. Regression tests
cover both coordinate systems, including that a genuine +8 handle offset
still fails.

Panel 2.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:26:39 +02:00
twotalesanimation 4929db45a2 fix(connector): try every matching path mapping, not just the first
Deploy / deploy (push) Successful in 6m5s
A studio whose render output root changed carries both conventions in the
database permanently — changing render.outputRoot only affects new
exports, so old rows keep the path they were queued with. On UNG that is
//SAN/renders/{projectCode}/... for older exports and //SAN/{showId}/...
for current ones, living side by side under one Export Root.

applyPathMappings returned only the first prefix match, so one rewrite
had to serve both and could not. It now returns every match and
resolveServerPath tests each for existence, which is what the candidate
ordering was there for.

Verified against the live drive: with "//SAN/=V:/_EXPORTS/UNG/" the
legacy export resolves via the mapping and current ones via the Export
Root re-base; with both rewrites mapped, all three resolve directly.

Panel 2.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:20:14 +02:00
twotalesanimation c4a85bac2b docs: delivery packages workflow guide
Deploy / deploy (push) Successful in 5m59s
RenderPipeline2.md §11 carries the design and the normative rules, but
there was nothing telling someone how to actually build and ship a
package. DELIVERY_WORKFLOW.md covers the path from "QC passed" to "client
has it": eligibility, building, what lands on disk, verification and its
failure modes, marking delivered, and configuration.

It explains hard links properly — a second name for the same data, not a
copy or a shortcut — since that is the part most likely to worry someone
looking at a package that appeared instantly and used no disk.

Also documents the DELIVERY_BUILD stage in the worker README, which
previously mentioned deliveries only in passing, and adds a
documentation index to the root README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:47:54 +02:00
twotalesanimation f4a2fee492 fix(connector): resolve server render paths on the workstation
Deploy / deploy (push) Successful in 6m4s
QC's Open Render used the server's outputDir verbatim. The server
addresses renders from its own root (render.outputRoot, e.g. //SAN),
which the render worker translates via its own pathMappings because it
runs as a service account — the panel had no equivalent, so Open Render
failed on any machine that could not resolve that root.

Resolution now tries, in order: an optional Path Mappings setting, the
server path as given, then re-basing on Export Root. The last one is why
this usually needs no configuration at all: both sides lay renders out as
{root}/{showId}/{episode}/{shot}/{version}, so the tail can simply be
re-attached. A failure names every candidate it tried rather than
reporting one path that was never going to work.

Diagnostics gained the same check against a real folder from the QC
queue, since "Export Root exists" and "this machine can reach a render
the server wrote" are different questions and only the second one breaks
QC.

Verified against the live V: drive: a //SAN path, a backslash variant and
an explicitly-mapped path all resolve to the real render folder, while an
unknown root and a different show correctly resolve to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:39:03 +02:00
twotalesanimation ce7b73dd38 chore(worker): stop tracking published packages
Deploy / deploy (push) Successful in 7m8s
The 29 MB VFXReviewWorker_<date>.zip was swept into the previous commit
by the .gitignore change that started tracking worker source. It is a
build artifact: reproducible from any commit via scripts/publish.ps1,
and the version stamp (1.1.0+a4f8226) already traces a deployed exe back
to its source, so the zip carries no information the repo lacks — while
costing ~29 MB of permanent history per publish.

Untracked and ignored. The file stays on disk for copying to
workstations; only git stops following it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:10:08 +02:00
twotalesanimation ca9a004d56 fix(worker): package the preview script reliably on an incremental publish
The .NET 10 SDK cleans the publish directory, and an incremental build
then skips copying content into it — so a second publish produced a
package with the exe but no scripts\vfxr_build_preview.jsx. The exe loads
that script from its own folder at runtime, so the package would have
installed cleanly and failed every preview job.

The Content item now sets CopyToPublishDirectory explicitly
(CopyToOutputDirectory only governs the build folder), and publish.ps1
copies the script itself rather than trusting the SDK, keeping the
existing verification as the backstop that caught this.

Verified by publishing twice in a row: the incremental second run, which
previously failed, now produces a complete package and zip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:19:47 +02:00
twotalesanimation a4f8226f3f feat(pipeline): report deployed tool versions from the tools themselves
"What is running where" was answerable only by asking someone. Each tool
now reports its own version on startup into a ToolVersion registry, shown
on Pipeline → Machines.

The worker's version moves out of config.json and into the assembly:
a hand-maintained string on each machine is exactly the thing that goes
stale, and a machine reporting a version it is not running is worse than
no version at all. publish.ps1 stamps the git SHA via SourceRevisionId,
so a deployed exe traces back to a commit (1.1.0+feb1c7b).

The panel gains PANEL_VERSION, shown in its title and every diagnostics
report, and pings on load.

Staleness is derived rather than declared: the newest version seen for a
tool is the benchmark, so updating one machine is what flags the rest.
There is no expected-version list to keep in sync — which would have been
one more thing to forget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:13:44 +02:00
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
twotalesanimation 7f2f5e1005 feat(connector): resolve footage per project type, anchor parsing on showId
Build Shot only ever worked on UNG. Two assumptions were baked in: that a shot's
plates sit flat in a folder named after the episode, and that a shot code is
three letters followed by three 3-digit tokens. DH2_VFX breaks both -- it groups
a shot's plates under a {shotCode}/ folder, has no episode level, and its showId
contains a digit and an underscore.

- findSequenceFolders prefers {group}/{shotCode}/ and falls back to UNG's flat
  {group}/{shotCode}_* scan, so both layouts work with no migration -- moving
  delivered footage would break the links in every AE project pointing at it. An
  existing-but-empty shot folder returns nothing rather than falling through, so
  stray plate folders sitting beside a shot cannot be read as its footage.
  Underscore-prefixed folders are never shots or plates.
- getFootageGroupFolder replaces getEpisodeRootFolder: the {showId}_{episode}
  folder for episodic shows, the show root otherwise. A scene filters the shot
  list, it is not a directory -- which is why Build Shot on DH2 was looking in
  V:/_FOOTAGE/DH2/027.
- Shot codes are now parsed by stripping the known showId prefix
  ({showId}_{episode}_{scene}_{shot} for episodic, {showId}_{scene}_{shot} for
  standard) rather than matching /[A-Z]{3}_.../ or counting underscores.
  extractShotCode keeps the old pattern as a fallback so a comp from a different
  show than the selected one still reads. Same anchoring in deriveEpisodeCode
  (no more hardcoded "UNG_"), deriveEpisodeAPIValue, isEpisodeCode and the
  getShotExportDir episode fallback.
- Two positional cuts of the same class, found on the way: the DELIVERY path and
  Pull Picture Lock's episode-prefix match both used shotCode.substring(0, 7).
  Both now go through getEpisodePrefix(). Output is identical for UNG and DH2
  (DH2_VFX happens to be 7 characters); the one behaviour change is that
  standard shows no longer get a constant group level under DELIVERY/, and DH2
  has no delivery history to move.

Verified against the live V:/_FOOTAGE tree by running the real functions under a
Folder stub: UNG_102_019_010 resolves its BG01/FG01 plates via the flat scan, and
DH2_VFX_027_010 and DH2_VFX_066_380 resolve theirs via the grouped layout. Not
yet exercised in After Effects.

Initialise Workspace is untouched and still hardcoded to UNG -- next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:15:42 +02:00
twotalesanimation 0ce41123c5 feat(render-pipeline): regenerate previews without re-rendering EXRs
Deploy / deploy (push) Successful in 5m58s
A slate or burn-in mistake is usually caught after a preview has already
succeeded, and there was no way back: retryPreview required PREVIEW_FAILED, and
READY_FOR_QC only led on to QC_FAILED/READY_FOR_DELIVERY/SUPERSEDED. The only
recourse was a whole new export, re-rendering every EXR and burning a version
number to fix a typo.

- transitions: READY_FOR_QC and QC_FAILED can now go back to GENERATING_PREVIEW.
- regeneratePreview() rebuilds the manifest from scratch rather than patching
  anything, so shot code, episode/scene, frame count, slate date and burn-ins
  are all re-derived exactly as on a first-time preview. Validated EXRs are
  reused and the version number is untouched -- this re-delivers the same
  version, it does not make a new one.
- vfxScope and submissionNote are frozen onto the Export at submission time, so
  regenerating refreshes that snapshot from the shot's slate fields (explicit
  override wins, same precedence as createExport). Slate text edited on the web
  app's Slate tab is therefore picked up.
- finalizePreview replaces the media on the export's existing Version instead of
  stacking a second row, so the version number stays put and reviewer notes and
  annotations survive the reslate. First-time previews are unaffected.
- POST /api/ext/exports/{id}/regenerate-preview exposes it to the panel.
- Panel: Regenerate Previews button, gated on the same statuses as the server,
  with a confirm covering what is and is not reused.

Also fixes the slate description source: the Slate tab authors
shot.slateDescription (falling back to shot.description) but the preview
manifest only ever read shot.description, so edits made there never reached the
rendered slate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:02:23 +02:00
twotalesanimation 783e82eaf5 feat(render-pipeline): sync task title to preview version, fill slate ShotName
finalizePreview now retitles the export's task so its version suffix matches the
preview that was just uploaded (UNG_102_019_010_cmp_TT_v004 -> _v005), which is
the rename the manual batch upload already does on rename-and-upload. The new
nextTaskTitle helper mirrors nextExrOutputBase so the task title, EXR base and
preview filename all derive the suffix the same way. It uses the export's
versionString rather than versionNumber, since the DB number is bumped to stay
monotonic per task while the filename follows the shot's shotVersion.

The rename only fires when a Version was actually created, runs in the same
transaction, and is recorded in the ExportEvent note; the finalize response now
returns the task id, title and whether it changed. Task status is deliberately
untouched (pipeline renders must not resurface in the review flow, S10.0).

The headless preview build was addressing the slate's identity fields by index
only, so ShotName was never filled on the farm. setSlateIdentity writes both the
named property and the index -- the name-with-index-fallback pattern the panel
already uses for VersionName -- and warns with the template's actual property
names when neither resolves, instead of failing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:30:47 +02:00
twotalesanimation c4abda2d63 feat(connector): per-shot/version export folders, scene filter
Panel export paths now mirror the server's layout
({exportRoot}/{showId}/[{episode} if episodic]/{shotCode}/{version}) via a new
getShotExportDir helper, so farm and local renders land in the same folder.
Folder creation moves into the per-shot loop so one unwritable path no longer
aborts the whole queue.

Also adds an optional `scene` query param to the ext shots endpoint.
2026-08-12 10:29:51 +02:00
twotalesanimation 7dcb9303e5 updated export paths
Deploy / deploy (push) Successful in 6m32s
2026-08-07 12:07:16 +02:00
twotalesanimation 7caf41e17c Slates
Deploy / deploy (push) Successful in 6m17s
2026-08-07 07:34:10 +02:00
twotalesanimation 2f2e286adb Merge develop: render queue feature
Deploy / deploy (push) Failing after 18m31s
2026-08-06 15:56:37 +02:00
twotalesanimation c9deb437c5 Merge branch 'develop' of https://git.twotalesvfx.com/pinto/vfxreview into develop 2026-08-06 15:55:51 +02:00
twotalesanimation a3be3489de feat(worker): one-command packaging and --check install verification
Adds scripts/publish.ps1, which produces a self-contained single-file exe plus
the headless AE build script, optionally zipped for copying to a render machine.

Adds `VFXReviewWorker.exe --check`: resolves every configured path, confirms
aerender/AfterFX/the build script exist, verifies each pathMappings target is
reachable from that machine, and pings the server to confirm the API key is
accepted — exiting non-zero if anything would stop the worker running. Catches
a wrong path at install time rather than in the logs later.

Verified from the published binary: the single-file exe resolves its scripts
folder correctly, so the preview build script ships alongside the exe rather
than embedded and can be patched without a rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:46:53 +02:00
twotalesanimation cc89415a29 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:46:53 +02:00
twotalesanimation 6b15bae62a Bulk Thumbnails upload
Deploy / deploy (push) Successful in 2m50s
2026-08-06 12:59:28 +02:00
twotalesanimation 7b36329769 Hide Archived Productions
Deploy / deploy (push) Successful in 2m43s
2026-08-06 10:42:49 +02:00
twotalesanimation d2c91e7b65 Shot status grouping
Deploy / deploy (push) Successful in 2m44s
2026-08-06 10:07:59 +02:00
twotalesanimation 852081b4d6 CSV Group updates
Deploy / deploy (push) Successful in 2m43s
2026-08-06 09:21:48 +02:00
twotalesanimation 98120f3ca8 CSV Group updates
Deploy / deploy (push) Successful in 2m45s
2026-08-06 09:13:16 +02:00
twotalesanimation 5f3c89119a Further CSV Import method added
Deploy / deploy (push) Successful in 2m42s
2026-08-06 08:36:26 +02:00
twotalesanimation c727795a78 Add ChunkLoadError boundary to force reload on stale deployment
Deploy / deploy (push) Successful in 7m58s
2026-08-06 08:12:16 +02:00
twotalesanimation 43c717bb85 feat(worker): one-command packaging and --check install verification
Deploy / deploy (push) Failing after 15m27s
Adds scripts/publish.ps1, which produces a self-contained single-file exe plus
the headless AE build script, optionally zipped for copying to a render machine.

Adds `VFXReviewWorker.exe --check`: resolves every configured path, confirms
aerender/AfterFX/the build script exist, verifies each pathMappings target is
reachable from that machine, and pings the server to confirm the API key is
accepted — exiting non-zero if anything would stop the worker running. Catches
a wrong path at install time rather than in the logs later.

Verified from the published binary: the single-file exe resolves its scripts
folder correctly, so the preview build script ships alongside the exe rather
than embedded and can be patched without a rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 15:17:35 +02:00
twotalesanimation ae58dc0366 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:34:34 +02:00
192 changed files with 28183 additions and 351 deletions
+3
View File
@@ -31,3 +31,6 @@ yarn-error.log*
.vercel
*.tsbuildinfo
next-env.d.ts
# RenderWorker build output is covered by RenderWorker/.gitignore (bin/, obj/,
# publish/) — the worker's source is tracked like the rest of the repo.
+96
View File
@@ -0,0 +1,96 @@
# VFXReview — Blender Submit
A Blender add-on that queues the open `.blend` on the studio render farm instead
of rendering it locally. Ctrl+F12, but on the farm.
---
## What a Blender job is (and is not)
Deliberately the simplest thing in the pipeline: **a queue entry and nothing
more**. It has no Export, takes no shot version, and is never validated, QC'd or
delivered. The `.blend` is expected to be ready to render — output path, format
and frame range already set — and the farm just runs it.
That is the whole difference from an After Effects export, which is a shot
submission and carries the full lifecycle behind it.
---
## Install
1. **Edit → Preferences → Add-ons → Install…** and pick `vfxreview_submit.py`.
2. Enable **Render: VFXReview — Submit Render**.
3. Expand its preferences and set:
| Setting | Value |
|---|---|
| **Server URL** | `https://review.twotalesvfx.com` |
| **API Key** | matches `API_SECRET_KEY` on the server (same key the AE panel uses) |
| **Artist Email** | recorded against the job so the queue shows who submitted it |
The panel appears under **Properties → Output → VFXReview**.
---
## Submitting
1. **Refresh Projects** (the circular arrow) and pick the project.
2. Optionally set a **Label** — defaults to the `.blend` filename, and is what
the render queue shows, since there is no shot code.
3. **Urgent** makes the job claimable outside the render window, ahead of normal
work.
4. **Submit to Render Farm**.
The panel shows the frame range, engine and output path it will send, read from
the file as saved. Nothing is changed on submission.
**The file must be saved first.** The farm opens the `.blend` from disk, so
unsaved changes would render the previous state without warning — the add-on
refuses to submit a dirty file rather than let that happen.
Track the job on the web app under **Pipeline → Blender renders**: live
progress, current frame, ETA and the machine running it, with cancel and requeue.
---
## Which machines can render it
Only workers with `blenderPath` set in their `config.json` and the executable
present. The worker reports Blender at registration and the server hands
`BLENDER_RENDER` jobs only to machines that have it — a machine without Blender
can never claim work it could only fail.
Verify on a worker with:
```powershell
.\VFXReviewWorker.exe --check
```
Blender is listed as optional there: a machine without it stays perfectly valid
for After Effects work.
---
## Notes
**Paths must resolve on the render machine.** The `.blend` path and its output
path are submitted as the artist sees them; the worker applies its own
`pathMappings` before opening the file. A path on a drive letter the render
account cannot see is the most common reason a job fails immediately.
**Renders as saved.** The add-on sends the scene's own settings. The manifest
supports overrides (samples, resolution %, device, engine) for future use, but
nothing in the UI sets them today — one place for render settings is the point.
**Exit code 0 with no frames is treated as a failure.** Blender exits cleanly
when a frame range is empty or an output path is unwritable, so the worker
checks that frames were actually saved before reporting success.
**The add-on sends its own User-Agent** (`VFXReviewBlender/<version>`). Python's
`urllib` otherwise identifies itself as `Python-urllib/3.x`, which Cloudflare
blocks outright as a scripted signature — a 403 with error 1010 before the
request ever reaches the server. Verified against the live zone: `Python-urllib`
gets 403, the add-on's own agent gets 200. If a future Cloudflare rule blocks it
anyway, the add-on says so in plain language rather than showing the raw JSON,
and the fix is a WAF allow rule on the agent or on `/api/ext/*`.
+289
View File
@@ -0,0 +1,289 @@
bl_info = {
"name": "VFXReview — Submit Render",
"author": "Two Tales",
"version": (1, 0, 2),
"blender": (3, 6, 0),
"location": "Properties > Output > VFXReview",
"description": "Queue this .blend on the studio render farm instead of rendering locally",
"category": "Render",
}
import json
import os
import urllib.error
import urllib.request
import bpy
from bpy.props import BoolProperty, EnumProperty, StringProperty
from bpy.types import AddonPreferences, Operator, Panel
# ── Preferences (per artist machine, like the AE panel's Settings) ────────────
class VFXRPreferences(AddonPreferences):
bl_idname = __name__
server_url: StringProperty(
name="Server URL",
default="https://review.twotalesvfx.com",
)
api_key: StringProperty(
name="API Key",
subtype="PASSWORD",
description="Matches API_SECRET_KEY on the server",
)
artist_email: StringProperty(
name="Artist Email",
description="Recorded against the job so the queue shows who submitted it",
)
def draw(self, context):
col = self.layout.column()
col.prop(self, "server_url")
col.prop(self, "api_key")
col.prop(self, "artist_email")
def prefs():
return bpy.context.preferences.addons[__name__].preferences
ADDON_VERSION = ".".join(str(n) for n in bl_info["version"])
USER_AGENT = "VFXReviewBlender/%s (Blender %s)" % (ADDON_VERSION, bpy.app.version_string)
# ── API ───────────────────────────────────────────────────────────────────────
def api_request(path, payload=None, method="GET"):
"""Minimal JSON client. urllib keeps the add-on dependency-free."""
p = prefs()
if not p.server_url or not p.api_key:
raise RuntimeError("Set the server URL and API key in Add-on Preferences")
url = p.server_url.rstrip("/") + path
data = json.dumps(payload).encode("utf-8") if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + p.api_key)
req.add_header("Accept", "application/json")
# urllib identifies itself as "Python-urllib/3.x" by default, which
# Cloudflare blocks outright as a scripted signature (error 1010). Sending a
# real name for this tool is also the honest thing: the studio's logs should
# say what is calling them.
req.add_header("User-Agent", USER_AGENT)
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as res:
return json.loads(res.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")
try:
parsed = json.loads(body)
except Exception:
parsed = {}
# A Cloudflare block is not a pipeline error and reads nothing like one,
# so say what it actually is rather than showing the artist a wall of
# JSON about browser signatures.
if parsed.get("cloudflare_error") or parsed.get("error_code") == 1010:
raise RuntimeError(
"Blocked by Cloudflare before reaching the server (error %s). "
"The studio's firewall needs to allow this add-on — ask the pipeline TD."
% parsed.get("error_code", e.code)
)
message = parsed.get("error", body) if parsed else body
raise RuntimeError("Server said %s: %s" % (e.code, message))
except urllib.error.URLError as e:
raise RuntimeError("Could not reach %s (%s)" % (url, e.reason))
_project_cache = []
# Blender does not copy the strings an EnumProperty callback returns — Python
# must keep a reference to them or the UI renders freed memory, which shows up
# as garbled labels rather than as a crash. So the items are built once when the
# project list changes and held here; the callback only ever returns this list.
_enum_items = [("", "Refresh projects...", "")]
def rebuild_enum_items():
global _enum_items
if _project_cache:
_enum_items = [
(p["code"], "%s - %s" % (p["code"], p["name"]), "")
for p in _project_cache
]
else:
_enum_items = [("", "Refresh projects...", "")]
return _enum_items
def project_items(self, context):
"""Called on every redraw, so it does no work and allocates nothing."""
return _enum_items
# ── Scene → job ───────────────────────────────────────────────────────────────
def scene_summary(scene):
"""
Everything the farm needs, read from the file as saved. The .blend is
expected to be ready to render, so nothing here changes settings — it only
reports them.
"""
return {
"blendFile": bpy.data.filepath.replace("\\", "/"),
"scene": scene.name,
"frameStart": scene.frame_start,
"frameEnd": scene.frame_end,
"outputPath": bpy.path.abspath(scene.render.filepath).replace("\\", "/"),
"engine": scene.render.engine,
"blenderVersion": bpy.app.version_string,
}
class VFXR_OT_refresh_projects(Operator):
bl_idname = "vfxreview.refresh_projects"
bl_label = "Refresh Projects"
bl_description = "Load the project list from the server"
def execute(self, context):
global _project_cache
try:
data = api_request("/api/ext/projects")
except RuntimeError as err:
self.report({"ERROR"}, str(err))
return {"CANCELLED"}
_project_cache = data.get("projects", [])
rebuild_enum_items()
self.report({"INFO"}, "%d project(s) loaded" % len(_project_cache))
return {"FINISHED"}
class VFXR_OT_submit(Operator):
bl_idname = "vfxreview.submit"
bl_label = "Submit to Render Farm"
bl_description = "Queue this .blend on the farm instead of rendering locally"
def execute(self, context):
scene = context.scene
settings = scene.vfxreview
if not bpy.data.filepath:
self.report({"ERROR"}, "Save the .blend first — the farm opens it from disk")
return {"CANCELLED"}
if bpy.data.is_dirty:
# Unsaved changes would render the previous state without warning.
self.report({"ERROR"}, "Unsaved changes — save the file before submitting")
return {"CANCELLED"}
if not settings.project_code:
self.report({"ERROR"}, "Pick a project (Refresh Projects first)")
return {"CANCELLED"}
manifest = scene_summary(scene)
if manifest["frameEnd"] < manifest["frameStart"]:
self.report({"ERROR"}, "Frame end is before frame start")
return {"CANCELLED"}
label = settings.label.strip() or os.path.splitext(os.path.basename(bpy.data.filepath))[0]
try:
result = api_request(
"/api/ext/render/blender",
{
"projectCode": settings.project_code,
"label": label,
"manifest": manifest,
"submittedByEmail": prefs().artist_email or None,
"urgent": settings.urgent,
},
method="POST",
)
except RuntimeError as err:
self.report({"ERROR"}, str(err))
return {"CANCELLED"}
job = result.get("job", {})
self.report(
{"INFO"},
"Queued '%s' — %d frames%s" % (label, job.get("frames", 0), " (urgent)" if settings.urgent else ""),
)
return {"FINISHED"}
class VFXRSceneSettings(bpy.types.PropertyGroup):
project_code: EnumProperty(name="Project", items=project_items)
label: StringProperty(
name="Label",
description="Shown in the render queue. Defaults to the .blend filename",
)
urgent: BoolProperty(
name="Urgent",
description="Claimable outside the render window, ahead of normal jobs",
default=False,
)
# ── UI ────────────────────────────────────────────────────────────────────────
class VFXR_PT_submit(Panel):
bl_label = "VFXReview"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "output"
def draw(self, context):
scene = context.scene
settings = scene.vfxreview
col = self.layout.column()
row = col.row(align=True)
row.prop(settings, "project_code", text="")
row.operator("vfxreview.refresh_projects", text="", icon="FILE_REFRESH")
col.prop(settings, "label")
col.prop(settings, "urgent")
box = col.box()
box.label(text="Renders as saved:", icon="INFO")
box.label(text="Frames %d–%d (%d)" % (
scene.frame_start, scene.frame_end, scene.frame_end - scene.frame_start + 1))
box.label(text="Engine: %s" % scene.render.engine)
output = bpy.path.abspath(scene.render.filepath)
box.label(text="Output: %s" % (output[-48:] if len(output) > 48 else output))
if bpy.data.is_dirty or not bpy.data.filepath:
warn = col.row()
warn.alert = True
warn.label(text="Save the file before submitting", icon="ERROR")
col.separator()
col.operator("vfxreview.submit", icon="RENDER_ANIMATION")
classes = (
VFXRPreferences,
VFXRSceneSettings,
VFXR_OT_refresh_projects,
VFXR_OT_submit,
VFXR_PT_submit,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.vfxreview = bpy.props.PointerProperty(type=VFXRSceneSettings)
def unregister():
del bpy.types.Scene.vfxreview
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
+310
View File
@@ -0,0 +1,310 @@
# Delivery Packages — Workflow Guide
How a finished shot becomes a dated folder ready to upload to the client.
There are two kinds of package. An **EXR delivery** is the shippable one and is
what most of this guide describes. A **MOV review** is a flat folder of preview
MOVs shared for a formal review — see
[MOV review packages](#mov-review-packages) at the end.
This is the operational guide. The design rationale and normative rules live in
[RenderPipeline2.md](RenderPipeline2.md) §11.
---
## The short version
1. A shot renders, validates, gets a preview, and **passes QC** → it becomes
eligible for delivery.
2. On **Pipeline → Deliveries**, pick the project, see *"Episode 101 — Ready:
18 shots"*, click **Build delivery**.
3. A render worker builds the folder on the delivery volume, verifies every
file, and writes a manifest and CSV report.
4. A human uploads that folder to the client, then clicks **Mark delivered**.
Nothing is ever sent to the client automatically. Step 4 is deliberately manual.
---
## What makes a shot eligible
An Export reaches `READY_FOR_DELIVERY` only by passing QC — from the AE panel
or the web. That is the single gate; there is no way to package an export that
has not been QC'd.
A render that failed automated validation can still get there: **Override
validation** on the Export detail page (admin/producer/supervisor, reason
required) resumes the pipeline at the preview stage, so the shot still goes
through QC like any other. The failed checks stay on record and keep appearing
as warnings in the QC queue, so whoever QCs it knows what was waved through.
Two rules worth knowing:
- **Newest version per shot only.** If a shot has two exports sitting ready, the
older one is history and is not offered. Building refuses outright if you
somehow select two versions of the same shot.
- **Version locking.** A package references the exact Export rows it was built
from and copies the shot code and version string at creation time. Re-render a
shot afterwards and the package is untouched — delivery history is immutable
by construction.
---
## Building a package
**Pipeline → Deliveries**, choose a project. Each episode with ready shots shows
its count and the shots themselves, with frame counts and sizes.
**Build delivery** creates the package (`QUEUED`) and a `DELIVERY_BUILD` job on
the same queue the renders use. Requires **admin, producer or supervisor**.
Delivery jobs run at priority 10, which is inside the urgent threshold — so they
claim immediately even during work hours, when a render would wait for the
evening window. Packaging is file work with no After Effects involvement, so it
does not disturb an artist at the machine.
Watch the package status on the same page:
| Status | Meaning |
|---|---|
| `QUEUED` | Waiting for a worker to pick it up |
| `BUILDING` | A worker is creating folders and linking files |
| `READY` | Built and verified — upload it |
| `DELIVERED` | You confirmed the client has it |
| `FAILED` | Something did not verify — see the error and the per-shot list |
| `CANCELLED` | Abandoned before delivery |
---
## What lands on disk
Default layout (the studio's confirmed structure):
```
{deliveryRoot}/{episode}/{YYMMDD}_Delivery/
├── {SHOT_CODE}/
│ ├── {shot}_cmp_TT_{ver}.[#####].exr ← the EXR sequence
│ └── {shot}_cmp_TT_{ver}.mov ← slate/burn-in delivery MOV
├── manifest.json ← every file, size, checksum, source export
└── {YYMMDD}_Delivery_report.csv ← one row per shot (client-facing)
```
EXRs and the MOV sit side by side in each shot folder — no `exr/` and `mov/`
subfolders, matching current practice.
The CSV carries: shot, version, frame range, frame count, file count, bytes,
checksum, MOV filename and status. Confirm these columns against the
production's delivery spec before the first real delivery.
---
## Hard links, and why the package appears instantly
Files are **hard-linked**, not copied, when the source and destination are on the
same volume. A hard link is a second name for the same data — not a shortcut and
not a copy — so an 18-shot EXR package builds in seconds and consumes
essentially no extra disk.
Verified on the studio SAN (`\\TRUENAS\vfx` over SMB): hard links work there, and
renders and deliveries live on the same share.
**Is that safe?** Deleting the delivery folder does not delete the render — the
data survives until the last name referring to it is gone. And nothing in the
pipeline ever rewrites an existing EXR: a new version is always a new directory.
Two escape hatches:
- A delivery root on a **different volume** falls back to copying automatically.
- `"copyMode": "always-copy"` in the project's delivery config forces real copies
for a production that mandates physical separation.
The `linkMode` column on each item records what actually happened, so a package
never claims to have linked when it copied.
---
## Verification
Verification is proportional to what the builder actually did, which is what
keeps a package fast:
| Mode | How it verifies | Cost for a 25-shot episode |
|---|---|---|
| **Hard-linked** (default) | The entry *is* the source data, so the validation-time digest already describes it. Checks each file exists, and that no source was modified after it passed QC. | seconds |
| **Copied** | Read back in full and hashed; the sequence digest must match the one recorded at validation. | ~30 s per 2.8 GB shot |
Re-reading a hard link cannot disagree with validation — it is the same bytes
under a second name — and at ~100 MB/s over SMB it costs roughly half an hour
per episode to prove something that cannot be false. A copy is genuinely
different: it can be truncated or corrupted, so it is always read back.
Set `"verifyMode": "always-hash"` in the delivery config to read everything back
regardless, for a production that requires it contractually.
An item fails, and the whole package fails with it, when:
| Failure | Meaning |
|---|---|
| Checksum mismatch (copied items) | The delivered bytes differ from what passed QC |
| A source file modified after validation | The render changed after it passed QC — a link would carry the new bytes faithfully |
| Frame count differs from the export record | The render folder changed after QC — someone re-rendered in place |
| Source folder missing | The render is not where the export says it is |
| No matching files | The folder exists but holds nothing matching the output pattern |
**A package only reaches `READY` when every shot verified.** One bad shot means
`FAILED`, and no export is marked as packaged — there is no partial delivery.
A missing delivery MOV is the one non-fatal case: the EXRs are the deliverable,
so it is logged and the package continues without it.
> **Without validation there is nothing to verify against.** An export whose
> checksum was never recorded (validation disabled, or rendered before it
> shipped) still packages, but the digest is computed with nothing to compare it
> to. The ready list flags these as *"N without a validation checksum"*.
---
## Marking delivered
Once you have uploaded the folder, **Mark delivered** moves every packaged
export to `DELIVERED`. Requires admin, producer or supervisor.
This is the end of the pipeline, and it is a human statement of fact — the app
has no way to know the client received anything.
---
## When something goes wrong
**A package failed.** Expand it: every shot shows its own status and error. Fix
the cause (usually a re-render after QC), then build a new package. Packages are
never deleted; a mistaken one is cancelled.
**The worker died mid-build.** The package is marked `FAILED` with *"check the
package folder before rebuilding"*. It is deliberately not retried
automatically — a half-written folder on the delivery volume is something a
human should look at first.
**Cancelling.** Only `DRAFT`, `QUEUED` and `FAILED` packages can be cancelled. A
`READY` or `DELIVERED` package stays in the history permanently; supersede it by
building a new one.
---
## Configuration
Per project, in `Project.deliveryConfig`; studio-wide defaults in the
`delivery.defaultConfig` SystemConfig key; built-in defaults otherwise. Merged
key by key, so a project can override just `deliveryRoot`.
```json
{
"packageNameTemplate": "{yymmdd}_Delivery",
"layout": {
"packageDir": "{deliveryRoot}/{episode}/{packageName}",
"shotDir": "{packageDir}/{shotCode}",
"reportFile": "{packageDir}/{packageName}_report.csv"
},
"deliveryRoot": "//SAN/DELIVERY",
"includeMovs": true,
"checksumAlgo": "xxh64",
"reportFormats": ["csv", "json"],
"copyMode": "auto",
"verifyMode": "auto"
}
```
Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir}
{deliveryRoot} {shotCode} {versionString} {packageNumber}`.
The resolved config is **snapshotted onto the package** at build time, so a
historic package always shows the rules it was built with, and a production with
a different spec is a config change rather than a code change.
`deliveryRoot` must be reachable *from the worker machine* — check with
`VFXReviewWorker.exe --check` if a build fails on paths.
---
## API
| # | Method | Path | Purpose |
|---|--------|------|---------|
| E16 | `POST` | `/api/deliveries` | Create a package and queue the build |
| E17 | `GET` | `/api/deliveries` | List packages / history |
| E18 | `GET` | `/api/deliveries/ready` | Ready exports grouped by episode |
| E19 | `POST` | `/api/deliveries/{id}/delivered` | Mark delivered |
| — | `POST` | `/api/deliveries/{id}/cancel` | Cancel a package |
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) |
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
Omit `exportIds` to take every ready export in the episode; `options` accepts
`date`, `name`, `includeMovs` and `copyMode` to override the config for one
build.
---
## MOV review packages
For a formal review you share the preview MOVs, not the EXRs. That is its own
kind of package, built the same way and listed on the same page.
### Building one
**Shot Status**, tick the shots, then **Bulk Actions → Build MOV Review
Package**. A dialog shows exactly what would be built before anything happens:
the folder name, the version each shot contributes, and any shots being left
out. Requires **admin, producer or supervisor**.
The version each shot contributes is its **newest export that actually has a
preview MOV** — not necessarily its newest export. A shot whose latest version
is still rendering goes into the review as the version that can be watched.
A shot with no MOV at all is listed as left out, with the reason, rather than
quietly reducing the folder.
### What lands on disk
```
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review/
├── EP6_VFX_012_v004.mov
├── EP6_VFX_013_v002.mov
└── UNG_106_010_040_cmp_TT_v001.mov
```
Flat — the MOVs sit beside each other so they can be scrubbed through in order.
Names follow the same client-naming rule as a delivery: the production's own
code where it has one, ours where it does not, so a shot reads identically in a
review folder and in the delivery that follows it.
The folder is **exactly the MOVs**: no CSV, no `manifest.json` beside them. The
manifest still goes back to the app, so the record of what was shared is kept
without cluttering the share.
Second review the same day is `20260828_Producer_review_02`, then `_03`. A
number is never reused, even if the package that took it was cancelled.
### What it does not do
**A review never moves a shot along.** Reviews happen throughout a shot's life,
long before QC passes it, and the same version is routinely shown twice — so
building one changes nothing about the exports in it, and there is no
**Mark delivered** on a review package. Shots stay exactly where they are in
the pipeline.
A **missing MOV fails the package** here, unlike in an EXR delivery where a
missing MOV is logged and the build continues. In a review the MOV is the
deliverable, and a review folder quietly one shot short is worse than one that
says so.
### Configuration
Two more keys in the same `deliveryConfig` (§11.6):
```json
{
"reviewNameTemplate": "{yyyymmdd}_Producer_review",
"layout": {
"reviewDir": "{deliveryRoot}/PRODUCER_REVIEWS/{packageName}"
}
}
```
+30
View File
@@ -24,6 +24,30 @@ X-Api-Key: <API_SECRET_KEY>
| `POST` | `/api/ext/shots` | Create a new shot |
| `PATCH` | `/api/ext/shots/{shotId}` | Update shot fields (e.g. increment version) |
### Render pipeline (RenderPipeline2)
| # | Method | Path | Caller | Description |
|---|--------|------|--------|-------------|
| E1 | `POST` | `/api/ext/exports` | panel | Queue Export — creates Export + RenderJob |
| E2 | `GET` | `/api/ext/exports/latest` | panel | Latest export + live status for the panel header |
| E3 | `GET` | `/api/ext/exports/{exportId}` | panel/worker | Export detail incl. validations and QC |
| E4 | `POST` | `/api/ext/exports/{exportId}/qc` | panel | QC Pass / QC Fail |
| E5 | `GET` | `/api/ext/qc/queue` | panel | Exports in READY_FOR_QC |
| E6 | `POST` | `/api/ext/workers/register` | worker | Register machine, receive tuning |
| E7 | `POST` | `/api/ext/workers/{machineId}/heartbeat` | worker | Heartbeat + cancel channel |
| E8 | `POST` | `/api/ext/render/jobs/claim` | worker | Atomically claim the next job |
| E9 | `PATCH` | `/api/ext/render/jobs/{jobId}/progress` | worker | Progress/ETA; renews the lease |
| E10 | `POST` | `/api/ext/render/jobs/{jobId}/fail` | worker | Report failure |
| E11 | `POST` | `/api/ext/render/jobs/{jobId}/complete-render` | worker | aerender finished → VALIDATING |
| E12 | `POST` | `/api/ext/render/jobs/{jobId}/validation` | worker | Report all validation results |
| E13 | `POST` | `/api/ext/render/jobs/{jobId}/finalize` | worker | Preview artifacts done → READY_FOR_QC |
| E14 | `POST` | `/api/ext/exports/{exportId}/retry` | panel | Retry a failed export |
| E20 | `POST` | `/api/ext/render/jobs/{jobId}/artifact-presign` | worker | Presigned upload URL |
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | worker | Report a DELIVERY_BUILD result |
Session-authenticated twins for the web UI live under `/api/render/*` and
`/api/deliveries/*` — same service layer, no API key.
---
## Shot Status Values
@@ -513,3 +537,9 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`:
| Populate Shot dropdown | `GET /api/ext/projects/{PROJECT_CODE}/shots?episode={value}` |
| All shot operations (overlay, queue, preview, delivery) | `GET /api/ext/shots/lookup?shotCode={code}&projectCode={PROJECT_CODE}` |
| Increment Shot Version | `PATCH /api/ext/shots/{shot.id}` with `{ "shotVersion": "v00X" }` |
| Queue Export | `POST /api/ext/exports` (E1) |
| Refresh Status / poll | `GET /api/ext/exports/latest` (E2) |
| Retry Export | `POST /api/ext/exports/{id}/retry` (E14) |
| Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` |
| Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) |
| QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) |
+14
View File
@@ -166,6 +166,20 @@ prisma/ — schema.prisma, seed.ts
---
## Documentation
| Doc | Covers |
|---|---|
| [RenderPipeline2.md](RenderPipeline2.md) | Render pipeline specification — normative design, state machine, API contracts |
| [DELIVERY_WORKFLOW.md](DELIVERY_WORKFLOW.md) | Building and shipping delivery packages (how-to) |
| [VFXReviewConnector.md](VFXReviewConnector.md) | After Effects panel — setup, Shot Builder, QC, diagnostics |
| [RenderWorker/README.md](RenderWorker/README.md) | Render worker service — packaging, install, validation, delivery builds |
| [BlenderPanel/README.md](BlenderPanel/README.md) | Blender add-on — queueing a .blend on the farm |
| [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md) | External API (`/api/ext/*`) used by the panel and workers |
| [Shot task workflow.md](Shot%20task%20workflow.md) | Shot and task lifecycle in the web app |
---
## License
MIT
+1524
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
bin/
obj/
publish/
*.user
# Published packages — rebuildable from any commit with scripts/publish.ps1,
# and the version stamp (e.g. 1.1.0+a4f8226) already traces a deployed exe
# back to its source. Keeping ~29 MB per publish in history buys nothing.
VFXReviewWorker_*.zip
+252
View File
@@ -0,0 +1,252 @@
# VFXReview RenderWorker
Windows service that drives `aerender.exe` on the artist workstations (RenderPipeline2 §7).
It talks **only HTTP** to the VFXReview server (`/api/ext/*`, API-key auth) — it never
gets a database connection. All scheduling policy (render windows, Render Now,
urgent priority) is enforced server-side in the claim endpoint; the worker polls dumbly.
## One click → EXR + MOV + MP4
A single **Queue Export** click produces all three deliverables. The server
chains two jobs:
1. **AE_RENDER** — `aerender` writes the clean EXR sequence (no overlay, no LUT).
2. **PREVIEW_ONLY** — created automatically when the render completes. The
worker runs `AfterFX.com -noui -r scripts/vfxr_build_preview.jsx`, which opens
the studio slate/overlay **template** AEP, imports the rendered EXRs, rebuilds
the shot around them (OCIO → `_SHOW LUT` → `UNG_VFX_OVERLAY`), duplicates
`UNG_EXPORT_TEMPLATE` into a preview comp with the slate filled in, queues the
MOV (`4444 Tri`) and MP4 (`REVIEW_PREVIEW`) output modules, and **saves a
throwaway AEP**. The worker then runs `aerender -project <that aep>` with no
`-comp`, rendering both outputs in one launch.
The MOV and MP4 land beside the EXRs. The MP4 is uploaded and registered as an
ordinary `Version` — internal-only, never client-visible, and it changes no task
or shot status (§10.0). Preview settings live in `SystemConfig` under
`preview.*`, so template names and paths are changed without redeploying.
If the preview stage fails, only it is retried — the validated EXRs are never
re-rendered. The temp AEP is kept on failure so you can open it and see exactly
what the farm built.
### Proving headless AE first
Headless AE is the one real unknown, so prove it before relying on it
(spec 18.2-C4). **Close After Effects**, then:
```powershell
.\scripts\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\...\v002" -ShotCode "UNG_111_001_030" -Version "v002"
```
It runs the exact build the worker runs and prints the result plus any warnings
(missing LUT comp, missing slate layer, …), then gives you the `aerender` command
to render what it built. If it reports no result file, AE cannot script headlessly
under that account — fall back to having the panel pre-build the preview comp in
the artist's AEP (then it is pure `aerender`), or the ffmpeg engine.
## What it does (Phase 2 scope)
- Registers on startup (E6) and receives server-supplied tuning (poll/heartbeat/lease/stall).
- Heartbeats every 30 s (E7) — the heartbeat response is also the cancel channel.
- Claims one `AE_RENDER` job at a time (E8), runs `aerender.exe` with the manifest's
comp/frame-range/templates, parses `PROGRESS:` lines, reports progress + ETA (E9,
renews the lease).
- Stall watchdog: no progress for `stallTimeoutSeconds` (default 600) → kill process tree, retryable fail.
- Deterministic errors (missing footage / missing comp / unopenable project) → non-retryable fail (E10);
transient errors auto-requeue server-side up to `maxAttempts`.
- On success: uploads the full aerender log via presign (E20), reports complete (E11).
- Crash recovery: `current-job.json` written on claim; on restart the worker asks the server
what became of the job and reports a retryable fail if it was still ours. Partial renders
are never resumed — the next attempt clears its own output files and re-renders.
- Durable reporting: complete/fail reports spool to disk and replay in order with backoff —
rendering continues while the server is down.
- **Validation (Phase 3)**: after a successful render the worker checks the sequence it just
produced and reports every result in one call (E12). See below.
## Validation stage
Runs on the worker straight after the render, while it still holds the job and the files are
local. The Export sits in `VALIDATING` until the report lands; the server decides what the
results mean (any FAIL → `VALIDATION_FAILED`, otherwise the preview stage is queued).
Checks: `file_count`, `missing_frames`, `duplicate_frames`, `first_frame`, `last_frame`,
`filename_consistency`, `zero_byte`, `corrupt`, `resolution`, `bit_depth`, `alpha_channel`,
`exr_type`, `timecode_plate_match`, `timecode_consistency`, `timecode_fps`.
**No oiiotool needed.** Everything is read from the EXR header — one 64 KB read per sampled
frame, no pixel decode. Full-sequence checks (count, gaps, names, sizes) touch every file;
header checks sample first/middle/last plus every Nth frame (`validation.sampleEvery`).
**Timecode is the primary check** (spec §8.1a) and is compared against *the plate*, in this
order: the timing plate named in the manifest → a plate found under `plateSearchRoots` →
the API timecode minus handles, which is **WARN-only** because the API stores the editorial
in-point and is a handle-length ahead of the plate by design.
Checksums are xxHash64 per file, with a sequence digest stored on the Export and reused by
the delivery builder to verify what it packaged.
Server-side switches (SystemConfig, no worker redeploy): `validation.enabled`,
`validation.sampleEvery`, `validation.handleFrames`, and `validation.overrides` for per-check
severity, e.g. `{"exr_type":"warn"}`.
## Blender renders
The worker also claims `BLENDER_RENDER` jobs: standalone renders submitted from
the Blender add-on, with no Export behind them — nothing to validate, preview or
QC. It runs `blender -b file.blend -s N -e M -a`, parses `Fra:`/`Saved:` output
for progress, and reports complete or failed like any other job.
Set `blenderPath` in `config.json` to make a machine eligible. The version is
reported at registration and **the server only hands Blender jobs to machines
that have it**, so a workstation without Blender can never claim work it could
only fail. A machine without it stays perfectly valid for After Effects work.
Blender exits 0 when a frame range is empty or an output path is unwritable, so
a run that saves no frames is reported as a failure rather than a success.
See [BlenderPanel/README.md](../BlenderPanel/README.md).
## Delivery builds
The same service also claims `DELIVERY_BUILD` jobs: creating a dated package
folder, hard-linking (or copying) each shot's EXRs and delivery MOV into it,
verifying every file against the validation-time checksum, and writing
`manifest.json` plus the CSV report.
No After Effects involved, so these are claimed **even while an artist has AE
open** — and at priority 10, inside the urgent threshold, so they run during the
day rather than waiting for the render window. Someone is usually waiting on the
folder.
`deliveryRoot` has to be reachable from this machine under the service account:
`--check` verifies `pathMappings` targets, which is where a build most often
falls over.
Full workflow: [DELIVERY_WORKFLOW.md](../DELIVERY_WORKFLOW.md).
### Checking a render by hand
```powershell
.\VFXReviewWorker.exe --validate "V:\_EXPORTS\UNG\102\UNG_102_019_010\v005" --plate "V:\_FOOTAGE\UNG\UNG_102\UNG_102_019_010_BG01_TT_v001\UNG_102_019_010_BG01_TT_v001.01001.exr"
```
Runs the whole engine over an existing folder with no server, job or queue, and prints the
report. The frame range, pattern and resolution are inferred from the files. Exits non-zero
if any check fails, so it also works in a script. Useful for auditing work that predates the
pipeline, and for proving the checks on real shots before turning the stage on.
Preview generation (Phase 4) plugs into this same service.
## Packaging for another machine
On a machine with the .NET 8+ SDK (only the packaging machine needs it):
```powershell
.\scripts\publish.ps1 -Zip
```
Produces `publish\win-x64\` containing exactly two things: a self-contained
`VFXReviewWorker.exe` (~67 MB, no .NET runtime needed on the target) and
`scripts\vfxr_build_preview.jsx`, which the exe loads from its own folder at
runtime. **Keep them together** — the script is deliberately not embedded in the
exe so the slate/preview build can be patched without a rebuild. `-Zip` also
writes `VFXReviewWorker_<date>.zip` for copying.
### Installing on the target
1. Copy the folder to the render machine, e.g. `C:\pipeline\VFXReviewWorker`.
2. Create `C:\ProgramData\VFXReviewWorker\config.json` (next section).
3. **Verify before installing the service:**
```powershell
.\VFXReviewWorker.exe --check
```
This resolves every path, confirms `aerender.exe` / `AfterFX.com` / the build
script exist, checks each `pathMappings` target is reachable *from that
machine*, and pings the server to confirm the API key is accepted. It exits
non-zero if anything would stop the worker running, so a wrong path is caught
here instead of in the logs an hour later.
4. Install the service (below), then confirm the machine appears on the web
**Pipeline → Machines** page.
Run `--check` again under the *service account* (`runas /user:STUDIO\svc-render`)
if the service will run as someone other than the logged-in artist — mapped
drives and share permissions differ per account, and that is the most common
cause of a worker that registers fine but cannot find footage.
Upgrading later: stop the service, replace the folder, start it again. Config
lives in ProgramData and is untouched.
## Configure
Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8):
```json
{
"serverUrl": "https://review.twotalesvfx.com",
"apiKey": "<API_SECRET_KEY>",
"machineName": "RENDER-01",
"aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
"aeVersion": "24.3",
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
"blenderPath": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe",
"plateSearchRoots": ["V:/_FOOTAGE/UNG"],
"pathMappings": [
{ "from": "//SAN/", "to": "S:/" }
]
}
```
There is no `workerVersion` key: the version is stamped into the exe at build time and
read back from the assembly, so a machine can never report a version it is not running.
`publish.ps1` appends the git commit, giving `1.1.0+feb1c7b`. Bump `<Version>` in
`VFXReviewWorker.csproj` when publishing a change; the worker reports it on startup and
**Pipeline → Machines** flags any machine still on an older one.
`pathMappings` translate the manifest's canonical UNC paths to this machine's drive
mappings. `plateSearchRoots` is optional: it is only consulted when a manifest carries no
timing plate (older panel versions), to find the shot's plates for the timecode check.
Everything tunable (poll interval, lease, stall timeout, max attempts, validation) lives in
the server's SystemConfig and arrives at registration — no per-machine tuning files.
## Run interactively (first-time smoke test)
```bash
VFXReviewWorker.exe
```
Logs go to the console-less service log at `%ProgramData%\VFXReviewWorker\logs\worker_YYYYMMDD.log`
(14-day rolling). Confirm the machine appears on the web **Pipeline → Machines** page, then stop it.
## Install as a Windows service
Run as a studio account with SAN access (works whether or not an artist is logged in):
```powershell
sc.exe create VFXReviewRenderWorker binPath= "C:\pipeline\VFXReviewWorker\VFXReviewWorker.exe" start= auto obj= "STUDIO\svc-render" password= "<password>"
sc.exe description VFXReviewRenderWorker "VFXReview render pipeline worker (aerender)"
sc.exe start VFXReviewRenderWorker
```
Uninstall: `sc.exe stop VFXReviewRenderWorker && sc.exe delete VFXReviewRenderWorker`.
## Rollout notes (spec §17.2)
- Install on **one** workstation first; add the second after a clean week.
- Set `render.maxAttempts = 1` in SystemConfig for the first week (observe before auto-retrying).
- Enter each machine's render windows on the Machines page (`availability`), defaults:
weekdays 19:00–08:00 + full weekends; Render Now override = 4 h.
- The dashboard warns when the two workstations report different AE versions.
## Tests
```bash
cd RenderWorker
dotnet test
```
Covers the progress parser against recorded aerender transcript lines (success, error,
non-retryable), ETA math, path mapping, log-tail ring buffer, and the output-dir
clearing guard (only files matching the job's own pattern prefix are ever deleted).
@@ -0,0 +1,92 @@
using System.Text;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// Blender writes its render status with a carriage return so it overwrites one
/// line in the terminal, and only emits a newline when a frame is saved. A
/// reader that splits on newlines alone therefore sees nothing at all while a
/// long frame renders — which had the stall watchdog killing healthy jobs.
/// </summary>
public class BlenderProgressParserTests
{
[Theory]
[InlineData("Fra:1042 Mem:512.00M (Peak 640.00M) | Time:00:12.34 | Path Tracing Sample 12/128", 1042)]
[InlineData("Fra:1 Mem:64.00M | Syncing Cube", 1)]
[InlineData("Saved: 'X:/renders/frame_1042.exr'", null)]
[InlineData("Blender quit", null)]
public void ParsesTheFrameBeingRendered(string line, int? expected)
{
Assert.Equal(expected, BlenderProgressParser.ParseFrame(line));
}
[Theory]
[InlineData("Saved: 'X:/renders/frame_1042.exr'", "X:/renders/frame_1042.exr")]
[InlineData(" Saved: 'nope'", null)] // leading space: not the real form
[InlineData("Fra:1042 Mem:512.00M", null)]
public void ParsesSavedFrames(string line, string? expected)
{
Assert.Equal(expected, BlenderProgressParser.ParseSaved(line));
}
[Fact]
public void TreatsAMissingBlendAsNonRetryable()
{
const string line = "Error: Cannot read file 'X:/gone.blend': No such file or directory";
Assert.True(BlenderProgressParser.IsErrorLine(line));
Assert.True(BlenderProgressParser.IsNonRetryableError(line));
}
[Fact]
public void TreatsAnUnknownFailureAsRetryable()
{
const string line = "Error: CUDA device lost, out of memory";
Assert.True(BlenderProgressParser.IsErrorLine(line));
Assert.False(BlenderProgressParser.IsNonRetryableError(line));
}
/// <summary>
/// The regression itself: a realistic stdout chunk where progress is
/// separated by CR and only the saved lines end in LF.
/// </summary>
[Fact]
public void CarriageReturnProgressIsSeenWithoutWaitingForANewline()
{
var stdout =
"Fra:1001 Mem:64.00M | Sample 1/128\r" +
"Fra:1001 Mem:120.00M | Sample 64/128\r" +
"Fra:1001 Mem:128.00M | Sample 128/128\r" +
"\nSaved: 'X:/renders/frame_1001.exr'\n" +
"Fra:1002 Mem:128.00M | Sample 1/128\r";
var lines = SplitLikeThePump(stdout);
var frames = lines.Select(BlenderProgressParser.ParseFrame).Where(f => f is not null).ToList();
var saved = lines.Select(BlenderProgressParser.ParseSaved).Where(s => s is not null).ToList();
// Four status updates and one saved frame; splitting on "\n" alone
// would have yielded a single unusable blob and no progress at all.
Assert.Equal(4, frames.Count);
Assert.Single(saved);
Assert.Equal(1002, frames[^1]);
}
/// <summary>Mirrors BlenderRunner.PumpAsync: CR and LF both end a line.</summary>
private static List<string> SplitLikeThePump(string text)
{
var lines = new List<string>();
var current = new StringBuilder();
foreach (var c in text)
{
if (c == '\r' || c == '\n')
{
if (current.Length > 0) { lines.Add(current.ToString()); current.Clear(); }
}
else current.Append(c);
}
if (current.Length > 0) lines.Add(current.ToString());
return lines;
}
}
@@ -0,0 +1,341 @@
using Microsoft.Extensions.Logging.Abstractions;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// Packaging is the last step before files leave the studio, so the parts that
/// could silently ship the wrong thing — checksum verification, frame-count
/// drift, hard-link vs copy — are pinned here.
/// </summary>
public class DeliveryStageTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_delivery_{Guid.NewGuid():N}");
private readonly string _sourceDir;
private readonly string _packageDir;
public DeliveryStageTests()
{
_sourceDir = Path.Combine(_root, "renders", "SHOT_001", "v001");
_packageDir = Path.Combine(_root, "DELIVERY", "101", "260813_Delivery");
Directory.CreateDirectory(_sourceDir);
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private void WriteFrames(int count, int startFrame = 1001)
{
for (var i = 0; i < count; i++)
{
var name = $"SHOT_001_cmp_TT_v001.{startFrame + i:D5}.exr";
File.WriteAllText(Path.Combine(_sourceDir, name), $"frame {startFrame + i} pixels");
}
}
private DeliveryManifest Manifest(string? checksum = null, int fileCount = 3, string copyMode = "auto") => new()
{
PackageId = "pkg1",
PackageName = "260813_Delivery",
PackageNumber = 1,
Episode = "101",
PackageDir = _packageDir,
ReportFile = Path.Combine(_packageDir, "260813_Delivery_report.csv"),
CopyMode = copyMode,
Items = new List<DeliveryManifestItem>
{
new()
{
ItemId = "item1",
ExportId = "exp1",
ShotCode = "SHOT_001",
VersionString = "v001",
SourceDir = _sourceDir,
OutputPattern = "SHOT_001_cmp_TT_v001.[#####].exr",
FrameStart = 1001,
FrameEnd = 1000 + fileCount,
FileCount = fileCount,
Checksum = checksum,
ShotDir = Path.Combine(_packageDir, "SHOT_001"),
},
},
};
private DeliveryStage NewStage() => new(
new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" },
new ApiClient(new WorkerOptions { ServerUrl = "http://localhost:1", ApiKey = "x" }, NullLogger<ApiClient>.Instance),
NullLogger<DeliveryStage>.Instance);
[Fact]
public async Task PackagesFramesAndWritesReport()
{
WriteFrames(3);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(), "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
var item = Assert.Single(result.Items);
Assert.Equal("VERIFIED", item.Status);
Assert.Equal(3, item.FileCount);
var delivered = Directory.GetFiles(Path.Combine(_packageDir, "SHOT_001"), "*.exr");
Assert.Equal(3, delivered.Length);
Assert.True(File.Exists(Path.Combine(_packageDir, "manifest.json")));
var report = File.ReadAllText(Path.Combine(_packageDir, "260813_Delivery_report.csv"));
Assert.Contains("Shot,Version,FrameStart", report);
Assert.Contains("SHOT_001,v001,1001,1003,3,3", report);
}
[Fact]
public async Task HardLinksOnTheSameVolumeSoNoExtraBytesAreWritten()
{
WriteFrames(2);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2), "machine1",
CancellationToken.None, CancellationToken.None);
Assert.Equal("hardlink", result.Items[0].LinkMode);
// A hard link is the same file: writing through one name is visible via
// the other. That is what makes packaging near-instant and free.
var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr");
var linked = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr");
File.WriteAllText(source, "rewritten");
Assert.Equal("rewritten", File.ReadAllText(linked));
}
[Fact]
public async Task AlwaysCopyModeProducesIndependentFiles()
{
WriteFrames(2);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2, copyMode: "always-copy"),
"machine1", CancellationToken.None, CancellationToken.None);
Assert.Equal("copy", result.Items[0].LinkMode);
var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr");
var copied = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr");
File.WriteAllText(source, "rewritten");
Assert.NotEqual("rewritten", File.ReadAllText(copied));
}
[Fact]
public async Task ChecksumMismatchFailsACopiedItem()
{
WriteFrames(3);
var manifest = Manifest(checksum: "deadbeefdeadbeef", copyMode: "always-copy");
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal("FAILED", result.Items[0].Status);
Assert.Contains("Checksum mismatch", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task HardLinkedItemsAreNotReadBack()
{
// A hard link is the same data as the source, so re-hashing it re-reads
// the exact bytes validation already hashed — half an hour per episode
// over SMB to prove something that cannot be false. A deliberately wrong
// recorded checksum is therefore not consulted on the linked path, and
// the validation-time digest carries over unchanged.
WriteFrames(3);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
Manifest(checksum: "deadbeefdeadbeef"), "machine1", CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal("VERIFIED", result.Items[0].Status);
Assert.Equal("hardlink", result.Items[0].LinkMode);
Assert.Equal("deadbeefdeadbeef", result.Items[0].Checksum);
}
[Fact]
public async Task AlwaysHashForcesReadBackEvenWhenLinked()
{
WriteFrames(3);
var manifest = Manifest(checksum: "deadbeefdeadbeef");
manifest.VerifyMode = "always-hash";
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("Checksum mismatch", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task SourceTouchedAfterValidationFailsTheLinkedItem()
{
// The one thing linking cannot rule out: the source changing after it
// passed QC. The link would faithfully carry the new bytes.
WriteFrames(3);
var manifest = Manifest();
manifest.Items[0].ValidatedAt = DateTimeOffset.UtcNow.AddHours(-2);
File.SetLastWriteTimeUtc(
Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01002.exr"), DateTime.UtcNow);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("modified after validation", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task UntouchedSourcesPassTheValidationTimestampCheck()
{
WriteFrames(3);
var manifest = Manifest();
manifest.Items[0].ValidatedAt = DateTimeOffset.UtcNow.AddHours(1);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
}
[Fact]
public async Task FrameCountDriftSinceQcFailsTheItem()
{
// The export recorded 3 frames at validation; the folder now has 2.
WriteFrames(2);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 3), "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("the render changed since QC", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task MissingSourceFolderFailsWithThePath()
{
var manifest = Manifest();
manifest.Items[0].SourceDir = Path.Combine(_root, "does-not-exist");
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("Source folder not found", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task ChecksumMatchesTheValidationTimeDigestForIdenticalContent()
{
WriteFrames(3);
var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
Manifest(copyMode: "always-copy"), "machine1", CancellationToken.None, CancellationToken.None);
// Feeding the recorded digest back in must verify, which is exactly what
// the delivery builder does against Export.checksum on the copy path.
Directory.Delete(_packageDir, recursive: true);
var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
Manifest(checksum: first.Items[0].Checksum, copyMode: "always-copy"), "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(second.Ok);
}
[Fact]
public async Task NoClientCodeKeepsTheInternalRenderFilenames()
{
// Regression pin: DeliveredBaseName defaults to "" for a shot with no
// client code, and delivery must render byte-identical to before this
// feature existed.
WriteFrames(2);
var manifest = Manifest(fileCount: 2);
Assert.Equal("", manifest.Items[0].DeliveredBaseName);
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr")));
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01002.exr")));
}
[Fact]
public async Task ClientCodeRenamesDeliveredFramesPreservingNumberAndPadding()
{
WriteFrames(3, startFrame: 1001);
var manifest = Manifest(fileCount: 3);
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
var deliveredDir = Path.Combine(_packageDir, "SHOT_001");
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01001.exr")));
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01002.exr")));
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01003.exr")));
// The internal shotCode-based name must not leak into the delivery.
Assert.False(File.Exists(Path.Combine(deliveredDir, "SHOT_001_cmp_TT_v001.01001.exr")));
Assert.Equal(3, Directory.GetFiles(deliveredDir, "*.exr").Length);
}
[Fact]
public async Task ClientCodeRenamesTheDeliveryMov()
{
WriteFrames(2);
var movSrc = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.mov");
File.WriteAllText(movSrc, "mov bytes");
var manifest = Manifest(fileCount: 2);
manifest.IncludeMovs = true;
manifest.Items[0].DeliveryMovPath = movSrc;
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
var deliveredDir = Path.Combine(_packageDir, "SHOT_001");
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.mov")));
Assert.Equal(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.mov"), result.Items[0].MovPath);
}
[Fact]
public async Task ClientCodeRenameStillVerifiesAgainstTheValidationTimeChecksum()
{
// Renaming must not change file *content*, so a checksum recorded at
// validation time (against the internal name) has to still match the
// delivered (renamed) files — the hash is content-based, not name-based.
WriteFrames(3);
var manifest = Manifest(copyMode: "always-copy");
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(first.Ok);
Directory.Delete(_packageDir, recursive: true);
var replay = Manifest(checksum: first.Items[0].Checksum, copyMode: "always-copy");
replay.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, replay, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(second.Ok);
}
[Fact]
public async Task UnparseableFrameNumberFallsBackToTheSourceFilename()
{
// A file that doesn't match the render's own numbering convention
// (shouldn't happen for a real render, but must never crash a delivery)
// is delivered under its original name rather than failing the item.
Directory.CreateDirectory(_sourceDir);
File.WriteAllText(Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001_no_frame_number.exr"), "junk");
var manifest = Manifest(fileCount: 1);
manifest.Items[0].OutputPattern = "SHOT_001_cmp_TT_v001_no_frame_number.exr";
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001_no_frame_number.exr")));
}
}
@@ -0,0 +1,199 @@
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// The timecode check is the reason the validation engine exists (§8.1a), so
/// the header parse and the timecode arithmetic are pinned here against a
/// synthetic EXR built byte by byte.
/// </summary>
public class ExrHeaderTests
{
[Fact]
public void ReadsTimecodeResolutionChannelsAndFps()
{
var path = ExrFixture.Write(timecode: "18:58:57:12", width: 1920, height: 1080,
channels: new[] { ("A", 1), ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 4);
try
{
var header = ExrHeader.Read(path);
Assert.Equal(new Timecode(18, 58, 57, 12, false), header.Timecode);
Assert.Equal(1920, header.DataWidth);
Assert.Equal(1080, header.DataHeight);
Assert.Equal(24, header.FramesPerSecond);
Assert.Equal("half", header.BitDepth);
Assert.True(header.HasAlpha);
Assert.Equal("PIZ", header.CompressionName);
}
finally { File.Delete(path); }
}
[Fact]
public void MissingTimecodeIsReportedAsAbsentNotAsZero()
{
var path = ExrFixture.Write(timecode: null, width: 1920, height: 1080,
channels: new[] { ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 3);
try
{
var header = ExrHeader.Read(path);
Assert.Null(header.Timecode);
Assert.False(header.HasAlpha);
}
finally { File.Delete(path); }
}
[Fact]
public void NonExrFileThrows()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "this is not an exr at all, not even close");
Assert.Throws<InvalidDataException>(() => ExrHeader.Read(path));
Assert.Null(ExrHeader.TryRead(path));
}
finally { File.Delete(path); }
}
[Theory]
// The BCD packing means the raw value reads back as its own hex digits.
[InlineData(0x18585712u, 18, 58, 57, 12)]
[InlineData(0x00004117u, 0, 0, 41, 17)]
[InlineData(0x00000000u, 0, 0, 0, 0)]
public void DecodesPackedSmpteTimecode(uint packed, int hh, int mm, int ss, int ff)
{
Assert.Equal(new Timecode(hh, mm, ss, ff, false), Timecode.FromPacked(packed));
}
[Fact]
public void FrameArithmeticRoundTrips()
{
var tc = new Timecode(18, 58, 57, 12, false);
Assert.Equal(tc, Timecode.FromFrames(tc.ToFrames(24), 24));
// 133 frames on from 18:58:57:12 at 24 fps is 18:59:03:01 — the real
// first/last frame pair measured on UNG_102_019_010 v005.
Assert.Equal(new Timecode(18, 59, 3, 1, false), Timecode.FromFrames(tc.ToFrames(24) + 133, 24));
}
[Fact]
public void ApiTimecodeIsOneHandleAheadOfThePlate()
{
// The defect class from §8.1a: the API stores the editorial in-point and
// the plate starts 8 frames earlier, so a comp anchored to the API value
// renders 8 frames late.
var plate = Timecode.Parse("13:44:06:02")!.Value;
var api = Timecode.Parse("13:44:06:10")!.Value;
Assert.Equal(8, api.ToFrames(24) - plate.ToFrames(24));
}
[Fact]
public void FrameDerivedTimecodeIsFrameNumberOverFps()
{
// 00:00:41:17 is frame 1001 at 24 fps — the signature of a comp built
// with no source timecode at all.
Assert.Equal(1001, Timecode.Parse("00:00:41:17")!.Value.ToFrames(24));
}
[Theory]
[InlineData("UNG_102_019_010_cmp_TT_v005.01001.exr", 1001)]
[InlineData("UNG_108_030_010_cmp_TT_v003.01204.exr", 1204)]
// AE-written renders separate the frame with an underscore, and turn up as
// layers in a shot comp alongside dot-separated delivered plates.
[InlineData("UNG_110_030_010_PRERENDER_01001.exr", 1001)]
[InlineData("UNG_110_030_010_render_1001.exr", 1001)]
[InlineData("no_frame_number.exr", null)]
// A version suffix is not a frame number.
[InlineData("UNG_110_030_010_BG01_TT_v001.exr", null)]
public void ParsesFrameNumbersFromFilenames(string name, int? expected)
{
Assert.Equal(expected, SequencePattern.FrameFromName(name));
}
[Fact]
public void ParsesOutputPattern()
{
var p = SequencePattern.FromOutputPattern("UNG_102_019_010_cmp_TT_v005.[#####].exr");
Assert.Equal("UNG_102_019_010_cmp_TT_v005", p.Prefix);
Assert.Equal(".exr", p.Extension);
Assert.Equal(5, p.Padding);
}
}
/// <summary>Builds a minimal but structurally real EXR header on disk.</summary>
internal static class ExrFixture
{
public static string Write(string? timecode, int width, int height,
(string Name, int PixelType)[] channels, int fpsNum, byte compression)
{
var path = Path.Combine(Path.GetTempPath(), $"vfxr_test_{Guid.NewGuid():N}.exr");
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write);
using var w = new BinaryWriter(fs);
w.Write(ExrHeader.Magic);
w.Write(2); // version 2, no flags
// channels (chlist)
using (var chData = new MemoryStream())
using (var cw = new BinaryWriter(chData))
{
foreach (var (name, pixelType) in channels)
{
WriteCString(cw, name);
cw.Write(pixelType);
cw.Write((byte)0); // pLinear
cw.Write(new byte[3]); // reserved
cw.Write(1); // xSampling
cw.Write(1); // ySampling
}
cw.Write((byte)0); // list terminator
WriteAttr(w, "channels", "chlist", chData.ToArray());
}
WriteAttr(w, "compression", "compression", new[] { compression });
var window = new byte[16];
BitConverter.GetBytes(0).CopyTo(window, 0);
BitConverter.GetBytes(0).CopyTo(window, 4);
BitConverter.GetBytes(width - 1).CopyTo(window, 8);
BitConverter.GetBytes(height - 1).CopyTo(window, 12);
WriteAttr(w, "dataWindow", "box2i", window);
WriteAttr(w, "displayWindow", "box2i", window);
var rational = new byte[8];
BitConverter.GetBytes(fpsNum).CopyTo(rational, 0);
BitConverter.GetBytes(1u).CopyTo(rational, 4);
WriteAttr(w, "framesPerSecond", "rational", rational);
if (timecode is not null)
{
var parts = timecode.Split(':');
uint Bcd(int v) => (uint)((v / 10) * 16 + v % 10);
var packed = (Bcd(int.Parse(parts[0])) << 24) | (Bcd(int.Parse(parts[1])) << 16)
| (Bcd(int.Parse(parts[2])) << 8) | Bcd(int.Parse(parts[3]));
var tcData = new byte[8];
BitConverter.GetBytes(packed).CopyTo(tcData, 0);
WriteAttr(w, "timeCode", "timecode", tcData);
WriteAttr(w, "timeCodeString", "string", System.Text.Encoding.ASCII.GetBytes(timecode));
}
w.Write((byte)0); // end of header
return path;
}
private static void WriteAttr(BinaryWriter w, string name, string type, byte[] data)
{
WriteCString(w, name);
WriteCString(w, type);
w.Write(data.Length);
w.Write(data);
}
private static void WriteCString(BinaryWriter w, string s)
{
w.Write(System.Text.Encoding.ASCII.GetBytes(s));
w.Write((byte)0);
}
}
@@ -0,0 +1,193 @@
using Microsoft.Extensions.Logging.Abstractions;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// MOV review packages (RenderPipeline2 §11.6). A review folder is opened
/// directly by a producer, so the things pinned here are the ones they would
/// notice: it is flat, it carries the client's own naming, and it contains the
/// MOVs and nothing else.
/// </summary>
public class MovReviewStageTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_review_{Guid.NewGuid():N}");
private readonly string _renderDir;
private readonly string _reviewDir;
public MovReviewStageTests()
{
_renderDir = Path.Combine(_root, "renders", "SHOT_001", "v004");
_reviewDir = Path.Combine(_root, "DELIVERY", "PRODUCER_REVIEWS", "20260828_Producer_review");
Directory.CreateDirectory(_renderDir);
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string WriteMov(string shotCode, string version, string contents = "mov bytes")
{
var path = Path.Combine(_renderDir, $"{shotCode}_cmp_TT_{version}.mov");
File.WriteAllText(path, contents);
return path;
}
private DeliveryManifest Manifest(params DeliveryManifestItem[] items) => new()
{
Kind = "MOV_REVIEW",
PackageId = "pkg1",
PackageName = "20260828_Producer_review",
PackageNumber = 7,
Episode = "101",
PackageDir = _reviewDir,
// A review writes no report; the server keeps the record instead.
ReportFile = "",
Items = items.ToList(),
};
private DeliveryManifestItem Item(
string shotCode, string version, string? movPath, string? deliveredBase = null) => new()
{
ItemId = $"item_{shotCode}",
ExportId = $"exp_{shotCode}",
ShotCode = shotCode,
VersionString = version,
SourceDir = "",
OutputPattern = "",
FrameStart = 1001,
FrameEnd = 1050,
DeliveryMovPath = movPath,
// Flat: every item's folder is the package folder itself.
ShotDir = _reviewDir,
DeliveredBaseName = deliveredBase ?? $"{shotCode}_cmp_TT_{version}",
};
private DeliveryStage NewStage() => new(
new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" },
new ApiClient(new WorkerOptions { ServerUrl = "http://localhost:1", ApiKey = "x" }, NullLogger<ApiClient>.Instance),
NullLogger<DeliveryStage>.Instance);
[Fact]
public async Task BuildsAFlatFolderOfMovsUnderTheClientFacingName()
{
var mov = WriteMov("SHOT_001", "v004");
var manifest = Manifest(Item("SHOT_001", "v004", mov, deliveredBase: "EP6_VFX_012_v004"));
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
var item = Assert.Single(result.Items);
Assert.Equal("VERIFIED", item.Status);
Assert.Equal(1, item.FileCount);
// No shot subfolder: the MOVs sit beside each other to be scrubbed through.
Assert.Null(item.ExrPath);
Assert.Equal(Path.Combine(_reviewDir, "EP6_VFX_012_v004.mov"), item.MovPath);
Assert.Single(Directory.GetDirectories(_reviewDir).Concat(Directory.GetFiles(_reviewDir)));
}
[Fact]
public async Task LeavesNoReportOrManifestBesideTheMovs()
{
var manifest = Manifest(Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")));
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
Assert.Null(result.ReportPath);
// What a producer opens is exactly the MOVs — no manifest.json, no CSV.
Assert.False(File.Exists(Path.Combine(_reviewDir, "manifest.json")));
Assert.All(Directory.GetFiles(_reviewDir), f => Assert.EndsWith(".mov", f));
}
[Fact]
public async Task HardLinksSoASeventyShotReviewCostsNoDisk()
{
var mov = WriteMov("SHOT_001", "v004");
var manifest = Manifest(Item("SHOT_001", "v004", mov));
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.Equal("hardlink", result.Items[0].LinkMode);
File.WriteAllText(mov, "rewritten");
Assert.Equal("rewritten", File.ReadAllText(result.Items[0].MovPath!));
}
[Fact]
public async Task AlwaysCopyModeProducesAnIndependentFile()
{
var mov = WriteMov("SHOT_001", "v004");
var manifest = Manifest(Item("SHOT_001", "v004", mov));
manifest.CopyMode = "always-copy";
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal("copy", result.Items[0].LinkMode);
File.WriteAllText(mov, "rewritten");
Assert.NotEqual("rewritten", File.ReadAllText(result.Items[0].MovPath!));
}
[Fact]
public async Task AMissingMovFailsTheReviewRatherThanShippingItShort()
{
// In an EXR delivery a missing MOV is a missing viewing copy and the
// package continues. In a review the MOV *is* the deliverable.
var manifest = Manifest(Item("SHOT_001", "v004", Path.Combine(_renderDir, "never_rendered.mov")));
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal("FAILED", result.Items[0].Status);
Assert.Contains("Preview MOV not found", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task AnItemWithNoRecordedMovFails()
{
var manifest = Manifest(Item("SHOT_001", "v004", null));
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("No preview MOV", result.Items[0].ErrorMessage!);
}
[Fact]
public async Task HashesEveryFileWhenTheConfigDemandsIt()
{
var manifest = Manifest(Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")));
manifest.VerifyMode = "always-hash";
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.True(result.Ok);
// A MOV has no validation-time digest of its own, so the one recorded
// here is the one this build computed.
Assert.False(string.IsNullOrWhiteSpace(result.Items[0].Checksum));
}
[Fact]
public async Task OneBadShotFailsThePackageAndTheRestStillReport()
{
var manifest = Manifest(
Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")),
Item("SHOT_002", "v002", Path.Combine(_renderDir, "absent.mov")));
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
CancellationToken.None, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal("VERIFIED", result.Items[0].Status);
Assert.Equal("FAILED", result.Items[1].Status);
}
}
@@ -0,0 +1,87 @@
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
public class PathMapperTests
{
private static PathMapper Mapper(params (string from, string to)[] maps) =>
new(maps.Select(m => new PathMapping { From = m.from, To = m.to }));
[Fact]
public void MapsUncToDriveLetter()
{
var mapper = Mapper(("//SAN/", "S:/"));
Assert.Equal(@"S:\renders\UNG_S1\106\shot\v004", mapper.Map("//SAN/renders/UNG_S1/106/shot/v004"));
}
[Fact]
public void MapsBackslashInputToo()
{
var mapper = Mapper(("//SAN/", "S:/"));
Assert.Equal(@"S:\projects\a.aep", mapper.Map(@"\\SAN\projects\a.aep"));
}
[Fact]
public void CaseInsensitiveMatch()
{
var mapper = Mapper(("//SAN/", "S:/"));
Assert.Equal(@"S:\x", mapper.Map("//san/x"));
}
[Fact]
public void FirstMatchingMappingWins()
{
var mapper = Mapper(("//SAN/renders/", "R:/"), ("//SAN/", "S:/"));
Assert.Equal(@"R:\a", mapper.Map("//SAN/renders/a"));
Assert.Equal(@"S:\projects\a", mapper.Map("//SAN/projects/a"));
}
[Fact]
public void UnmappedPathPassesThroughWithLocalSeparators()
{
var mapper = Mapper(("//SAN/", "S:/"));
Assert.Equal(@"D:\local\thing", mapper.Map("D:/local/thing"));
}
}
public class PrepareOutputDirTests
{
[Fact]
public void ClearsOnlyFilesMatchingPatternPrefix()
{
var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old");
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1002.exr"), "old");
File.WriteAllText(Path.Combine(dir, "unrelated-notes.txt"), "keep me");
AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr");
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr")));
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1002.exr")));
Assert.True(File.Exists(Path.Combine(dir, "unrelated-notes.txt")));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void CreatesMissingDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"), "v001");
try
{
AerenderRunner.PrepareOutputDir(dir, "X.[####].exr");
Assert.True(Directory.Exists(dir));
}
finally
{
Directory.Delete(Path.GetDirectoryName(dir)!, recursive: true);
}
}
}
@@ -0,0 +1,120 @@
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// Recorded-transcript tests (§17.2): lines below are representative aerender
/// stdout from AE 2024/2025/2026 successes, errors, and stalls.
/// </summary>
public class ProgressParserTests
{
[Theory]
[InlineData("PROGRESS: 0:00:02:03 (51): 0 Seconds", 51)]
[InlineData("PROGRESS: 2 (2): 1 Seconds", 2)]
[InlineData("PROGRESS: 0:00:00:00 (1): 12 Seconds", 1)]
[InlineData("PROGRESS: 0:00:04:23 (120): 3 Seconds", 120)]
public void ParsesProgressFrames(string line, int expected)
{
Assert.Equal(expected, ProgressParser.ParseFrame(line));
}
[Theory]
[InlineData("PROGRESS: Rendering frame data")]
[InlineData("Starting composition \"UNG_106_010_020_cmp\".")]
[InlineData("aerender version 24.3x52")]
[InlineData("")]
public void IgnoresNonFrameLines(string line)
{
Assert.Null(ProgressParser.ParseFrame(line));
}
[Theory]
[InlineData("aerender ERROR: An existing connection was forcibly closed")]
[InlineData("aerender ERROR -1610153464: After Effects error")]
[InlineData("ERROR: Unable to open project")]
[InlineData("aerender SYNTAX ERROR: Illegal argument flag: false")]
public void DetectsErrorLines(string line)
{
Assert.True(ProgressParser.IsErrorLine(line));
}
[Fact]
public void ProgressLineIsNotAnError()
{
Assert.False(ProgressParser.IsErrorLine("PROGRESS: 0:00:02:03 (51): 0 Seconds"));
}
[Theory]
[InlineData("aerender ERROR: After Effects error: layer source file is missing or inaccessible")]
[InlineData("aerender ERROR: No comp was found with the given name.")]
[InlineData("aerender ERROR: project file could not be opened")]
[InlineData("WARNING: Missing footage in comp")]
[InlineData("aerender SYNTAX ERROR: Illegal argument flag: false")]
public void DeterministicErrorsAreNonRetryable(string line)
{
Assert.True(ProgressParser.IsNonRetryableError(line));
}
[Theory]
[InlineData("aerender ERROR: An existing connection was forcibly closed")]
[InlineData("aerender ERROR -1610153464: After Effects crashed")]
public void TransientErrorsStayRetryable(string line)
{
Assert.False(ProgressParser.IsNonRetryableError(line));
}
}
public class EtaCalculatorTests
{
[Fact]
public void NoFramesMeansNoEstimate()
{
var eta = new EtaCalculator();
Assert.Null(eta.EstimateSeconds(100));
}
[Fact]
public void EstimatesFromRollingAverage()
{
var eta = new EtaCalculator();
var t = DateTimeOffset.UtcNow;
eta.RecordFrame(t);
eta.RecordFrame(t.AddSeconds(2));
eta.RecordFrame(t.AddSeconds(4)); // 2 s/frame average
Assert.Equal(20, eta.EstimateSeconds(10));
}
[Fact]
public void ZeroRemainingMeansNoEstimate()
{
var eta = new EtaCalculator();
var t = DateTimeOffset.UtcNow;
eta.RecordFrame(t);
eta.RecordFrame(t.AddSeconds(1));
Assert.Null(eta.EstimateSeconds(0));
}
[Fact]
public void RollingWindowDropsOldFrames()
{
var eta = new EtaCalculator(windowSize: 2);
var t = DateTimeOffset.UtcNow;
eta.RecordFrame(t);
eta.RecordFrame(t.AddSeconds(100)); // old slow frame, will roll out
eta.RecordFrame(t.AddSeconds(101));
eta.RecordFrame(t.AddSeconds(102)); // window now [1, 1]
Assert.Equal(10, eta.EstimateSeconds(10));
}
}
public class LogTailTests
{
[Fact]
public void KeepsOnlyLastNLines()
{
var tail = new LogTail(3);
for (var i = 1; i <= 5; i++) tail.Add($"line {i}");
Assert.Equal("line 3\nline 4\nline 5", tail.ToString());
}
}
@@ -0,0 +1,54 @@
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// The preview MP4 opens on the Netflix slate. ffmpeg's <c>thumbnail</c> filter
/// only considers the first 100 frames, so every thumbnail the pipeline has ever
/// produced was a picture of the slate card rather than of the shot — invisible
/// while thumbnails were only decoration in the QC queue, and unacceptable now
/// they can become a shot's thumbnail.
///
/// The fix is to seek past the slate, so the seek arithmetic is worth pinning.
/// </summary>
public class ThumbnailSeekTests
{
private static PreviewBuildResult Build(double? pictureStart, int? frameCount) =>
new() { PictureStartSeconds = pictureStart, FrameCount = frameCount };
[Fact]
public void SeeksAQuarterIntoPictureNotIntoTheFile()
{
// 5s slate, then 96 frames (4s) of picture at 24fps → 5 + 1 = 6s.
var seek = PreviewStage.ThumbnailSeekSeconds(Build(5.0, 96), 24);
Assert.NotNull(seek);
Assert.Equal(6.0, seek!.Value, 3);
}
[Fact]
public void HandlesATemplateWithNoSlateAtAll()
{
var seek = PreviewStage.ThumbnailSeekSeconds(Build(0.0, 240), 24);
Assert.Equal(2.5, seek!.Value, 3);
}
[Fact]
public void FallsBackWhenTheBuildDidNotReportWherePictureStarts()
{
// Older worker, or a template with no SHOT layer: null means "keep the
// old whole-file behaviour" rather than guessing an offset.
Assert.Null(PreviewStage.ThumbnailSeekSeconds(Build(null, 96), 24));
Assert.Null(PreviewStage.ThumbnailSeekSeconds(null, 24));
}
[Theory]
[InlineData(-1.0, 96, 24)] // negative slate length
[InlineData(5.0, 0, 24)] // no picture frames
[InlineData(5.0, -5, 24)] // nonsense frame count
[InlineData(5.0, 96, 0)] // no fps to divide by
public void RefusesNumbersThatDoNotDescribeARealClip(double start, int frames, double fps)
{
Assert.Null(PreviewStage.ThumbnailSeekSeconds(Build(start, frames), fps));
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VFXReviewWorker\VFXReviewWorker.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,194 @@
using Microsoft.Extensions.Logging.Abstractions;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// Regression cover for the two coordinate systems a render lives in, taken
/// from a real failure on UNG_107_051_010 v004:
///
/// comp frames 1247792-1247896 (anchored to the plate's timecode 14:26:31:08)
/// file numbers 1001-1105 (the output module's starting number)
///
/// Checking file numbers against comp frames failed every render, and using
/// the comp range as the timecode anchor produced a million-frame offset.
/// </summary>
public class ValidationStageTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"vfxr_val_{Guid.NewGuid():N}");
public ValidationStageTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
/// <summary>Writes a sequence numbered from 1001 with a plate-derived timecode.</summary>
private void WriteSequence(int count, string startTimecode, int firstFrame = 1001, int fps = 24)
{
var start = Timecode.Parse(startTimecode)!.Value.ToFrames(fps);
for (var i = 0; i < count; i++)
{
var tc = Timecode.FromFrames(start + i, fps);
var path = Path.Combine(_dir, $"UNG_107_051_010_cmp_TT_v004.{firstFrame + i:D5}.exr");
File.Move(ExrFixture.Write(tc.ToString(), 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, fps, 4), path);
}
}
private static RenderManifest Manifest(string dir, string? apiTimecode, int? outputFrameStart) => new()
{
ShotCode = "UNG_107_051_010",
OutputDir = dir,
OutputPattern = "UNG_107_051_010_cmp_TT_v004.[#####].exr",
// The comp is anchored to the plate timecode, so its frames are absolute.
FrameStart = 1247792,
FrameEnd = 1247896,
OutputFrameStart = outputFrameStart,
Fps = 24,
Width = 3840,
Height = 2160,
Expected = apiTimecode is null ? null : new ManifestExpected { TimecodeStart = apiTimecode },
};
private ValidationStage NewStage() =>
new(new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" }, NullLogger<ValidationStage>.Instance);
private static ValidationCheck Check(ValidationOutcome o, string name) =>
o.Checks.Single(c => c.CheckName == name);
[Fact]
public void FileNumberingIsCheckedAgainstOutputStartNotCompFrames()
{
WriteSequence(105, "14:26:31:08");
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", 1001), _dir, new ServerConfig());
Assert.Equal("PASS", Check(outcome, "file_count").Status);
Assert.Equal("PASS", Check(outcome, "first_frame").Status);
Assert.Equal("PASS", Check(outcome, "last_frame").Status);
Assert.Equal("PASS", Check(outcome, "missing_frames").Status);
Assert.Equal("1105", Check(outcome, "last_frame").Actual);
}
[Fact]
public void ApiTimecodeIsAnchoredOnTheRenderNotTheCompRange()
{
// Shot.timecodeStart is the edit in-point; the plate carries 8 frames of
// head handle, so the first rendered frame is 8 frames earlier.
WriteSequence(105, "14:26:31:08");
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", 1001), _dir, new ServerConfig());
var tc = Check(outcome, "timecode_plate_match");
// WARN, never PASS: the API is a fallback reference (§8.1a).
Assert.Equal("WARN", tc.Status);
Assert.Equal("14:26:31:08", tc.Expected);
Assert.Equal("14:26:31:08", tc.Actual);
Assert.DoesNotContain("frames)", tc.Message ?? "");
}
[Fact]
public void RealTimecodeDefectStillFailsAgainstAPlate()
{
// Same shot, but rendered 8 frames late — the handle-offset defect.
WriteSequence(105, "14:26:31:16");
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), plate);
var manifest = Manifest(_dir, "14:26:31:16", 1001);
manifest.Source = new ManifestSource
{
TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 },
HandleFrames = 8,
};
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
Assert.Equal("FAIL", tc.Status);
Assert.Contains("+8 frames", tc.Message);
Assert.Contains("8-frame handle", tc.Message);
}
[Fact]
public void APlateAtADifferentRateFailsPlateFps()
{
// The DH2 case: a 25 fps show whose shot records defaulted to 24, so
// every comp was built at 24. The render agrees with its own manifest,
// so only the plate can reveal it.
WriteSequence(105, "14:26:31:08");
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 25, 4), plate);
var manifest = Manifest(_dir, "14:26:31:16", 1001);
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
var outcome = NewStage().Run(manifest, _dir, new ServerConfig());
var fpsCheck = Check(outcome, "plate_fps");
Assert.Equal("WARN", fpsCheck.Status);
Assert.Equal("25", fpsCheck.Expected);
Assert.Equal("24", fpsCheck.Actual);
Assert.Contains("wrong rate", fpsCheck.Message);
// timecode_fps alone would not have caught it: the render and the
// manifest agree with each other at 24.
Assert.Equal("PASS", Check(outcome, "timecode_fps").Status);
}
[Fact]
public void AnOverCrankedPlateIsRecognisedRatherThanReportedAsAMistake()
{
// A 50 fps plate on a 25 fps show is deliberate slow motion, conformed
// on purpose — real on DH2, and it must not read as a rate error.
WriteSequence(105, "14:26:31:08", fps: 25);
var plate = Path.Combine(_dir, "plate", "DH2_PL01.01001.exr");
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 50, 4), plate);
var manifest = Manifest(_dir, null, 1001);
manifest.Fps = 25;
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
var check = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "plate_fps");
Assert.Equal("WARN", check.Status);
Assert.Contains("over-crank", check.Message);
Assert.DoesNotContain("wrong rate", check.Message);
}
[Fact]
public void MatchingRatesPassPlateFps()
{
WriteSequence(105, "14:26:31:08");
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), plate);
var manifest = Manifest(_dir, "14:26:31:16", 1001);
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
Assert.Equal("PASS", Check(NewStage().Run(manifest, _dir, new ServerConfig()), "plate_fps").Status);
}
[Fact]
public void GapsAreStillCaughtWithinTheRun()
{
WriteSequence(105, "14:26:31:08");
File.Delete(Path.Combine(_dir, "UNG_107_051_010_cmp_TT_v004.01050.exr"));
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", 1001), _dir, new ServerConfig());
Assert.Equal("FAIL", Check(outcome, "file_count").Status);
Assert.Equal("FAIL", Check(outcome, "missing_frames").Status);
Assert.Contains("1050", Check(outcome, "missing_frames").Message);
}
[Fact]
public void OldManifestsWithoutOutputNumberingSkipRatherThanFail()
{
WriteSequence(105, "14:26:31:08");
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", null), _dir, new ServerConfig());
Assert.Equal("SKIPPED", Check(outcome, "first_frame").Status);
Assert.Equal("PASS", Check(outcome, "missing_frames").Status);
Assert.Contains("1001-1105", Check(outcome, "first_frame").Message);
}
}
@@ -0,0 +1,61 @@
using System.Text;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// Reference vectors from the xxHash specification. The delivery builder
/// verifies packaged files against digests recorded here at validation time, so
/// a wrong implementation would not be caught by round-tripping our own code —
/// only by pinning the published values.
/// </summary>
public class XxHash64Tests
{
[Theory]
[InlineData("", 0xEF46DB3751D8E999UL)]
[InlineData("a", 0xD24EC4F1A98C6E5BUL)]
[InlineData("abc", 0x44BC2CF5AD770999UL)]
public void MatchesReferenceVectors(string input, ulong expected)
{
Assert.Equal(expected, XxHash64.Hash(Encoding.ASCII.GetBytes(input)));
}
[Fact]
public void StreamingInChunksMatchesSingleShot()
{
var data = new byte[1000];
new Random(42).NextBytes(data);
var oneShot = XxHash64.Hash(data);
// Chunk sizes deliberately straddle the 32-byte stripe boundary.
foreach (var chunk in new[] { 1, 7, 31, 32, 33, 64, 999 })
{
var state = new XxHash64.State(0);
for (var i = 0; i < data.Length; i += chunk)
{
state.Update(new ReadOnlySpan<byte>(data, i, Math.Min(chunk, data.Length - i)));
}
Assert.Equal(oneShot, state.Digest());
}
}
[Fact]
public void HashFileIsStableAndContentSensitive()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "frame data");
var first = XxHash64.HashFile(path);
Assert.Equal(first, XxHash64.HashFile(path));
File.WriteAllText(path, "frame datb");
Assert.NotEqual(first, XxHash64.HashFile(path));
}
finally
{
File.Delete(path);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Diagnostics;
namespace VFXReviewWorker;
/// <summary>
/// Detects an *interactive* After Effects session on this machine.
///
/// This matters because `AfterFX.com -noui -r script.jsx` is handed to an
/// already-running After Effects instance rather than starting an isolated
/// one — so running the preview build while an artist has AE open would take
/// over their session and quit it, losing unsaved work.
///
/// Deliberately conservative: this matches any After Effects process, the
/// interactive `AfterFX.exe` as well as the `AfterFX.com` render engine that
/// aerender drives. Waiting for either to finish also avoids two AE instances
/// competing for the same box, and the worst case is only that a preview build
/// starts a little later.
/// </summary>
public static class AeSession
{
public static bool InteractiveRunning()
{
try
{
return Process.GetProcessesByName("AfterFX").Length > 0;
}
catch
{
// If we cannot tell, assume an artist is working — never risk their session.
return true;
}
}
}
@@ -0,0 +1,298 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
public sealed record RenderResult(
bool Success,
int ExitCode,
bool Retryable,
string? ErrorMessage,
string LogTail,
string FullLogPath,
double RenderSeconds);
/// <summary>
/// Stage 1: aerender execution (§7.3). Launches aerender.exe, parses stdout
/// for PROGRESS/ERROR lines, reports progress (renewing the lease), enforces
/// the stall timeout, and honours cancellation by killing the process tree.
/// </summary>
public sealed class AerenderRunner
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly ILogger<AerenderRunner> _log;
public AerenderRunner(WorkerOptions options, ApiClient api, ILogger<AerenderRunner> log)
{
_options = options;
_api = api;
_log = log;
}
/// <summary>
/// Renders every queued item in an already-prepared project (no -comp), used
/// by the preview stage: the build script saved an AEP with the MOV and MP4
/// output modules already queued, so one launch produces both.
/// </summary>
public Task<RenderResult> RunProjectAsync(
string jobId,
string machineId,
string projectPathLocal,
int totalFrames,
ServerConfig config,
CancellationToken cancelJob,
CancellationToken shutdown)
{
var args = new List<string> { "-project", projectPathLocal, "-mp" };
_log.LogInformation("Rendering prepared project for job {JobId}: {Project}", jobId, projectPathLocal);
return ExecuteAsync(jobId, machineId, args, totalFrames, 0, config, $"aerender_{jobId}_preview.log", cancelJob, shutdown);
}
public async Task<RenderResult> RunAsync(
ClaimedJob job,
RenderManifest m,
string machineId,
string outputDirLocal,
ServerConfig config,
CancellationToken cancelJob,
CancellationToken shutdown)
{
var mapper = new PathMapper(_options.PathMappings);
var aepLocal = mapper.Map(m.AepPath);
var outputArg = Path.Combine(outputDirLocal, m.OutputPattern);
PrepareOutputDir(outputDirLocal, m.OutputPattern);
var args = new List<string>
{
"-project", aepLocal,
"-comp", m.CompName,
"-s", m.FrameStart.ToString(),
"-e", m.FrameEnd.ToString(),
};
if (!string.IsNullOrEmpty(m.RenderSettingsTemplate)) { args.Add("-RStemplate"); args.Add(m.RenderSettingsTemplate); }
if (!string.IsNullOrEmpty(m.OutputModuleTemplate)) { args.Add("-OMtemplate"); args.Add(m.OutputModuleTemplate); }
args.Add("-output"); args.Add(outputArg);
// NB: no "-continueOnMissingFootage" — the real flag takes no value
// (passing "false" is an aerender SYNTAX ERROR), and its *presence*
// enables skipping missing footage. aerender's default is to stop on
// missing footage, which is exactly what the pipeline wants.
_log.LogInformation("Launching aerender for job {JobId}: {Aep} comp \"{Comp}\" frames {S}-{E} → {Out}",
job.Id, aepLocal, m.CompName, m.FrameStart, m.FrameEnd, outputArg);
return await ExecuteAsync(job.Id, machineId, args, m.TotalFrames, m.FrameStart, config,
$"aerender_{job.Id}.log", cancelJob, shutdown);
}
private async Task<RenderResult> ExecuteAsync(
string jobId,
string machineId,
List<string> args,
int totalFrames,
int frameStart,
ServerConfig config,
string logFileName,
CancellationToken cancelJob,
CancellationToken shutdown)
{
var fullLogPath = Path.Combine(WorkerOptions.LogsDir, logFileName);
Directory.CreateDirectory(WorkerOptions.LogsDir);
var psi = new ProcessStartInfo
{
FileName = _options.AerenderPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var a in args) psi.ArgumentList.Add(a);
var tail = new LogTail(200);
var eta = new EtaCalculator();
var sw = Stopwatch.StartNew();
var lastProgressAt = DateTimeOffset.UtcNow;
var lastReportAt = DateTimeOffset.MinValue;
int framesDone = 0;
string? firstError = null;
bool nonRetryable = false;
bool cancelledByServer = false;
using var process = new Process { StartInfo = psi };
await using var fullLog = new StreamWriter(fullLogPath, append: false, Encoding.UTF8);
var logLock = new object();
void HandleLine(string? line, bool isStderr)
{
if (line is null) return;
lock (logLock)
{
fullLog.WriteLine(line);
tail.Add(line);
}
var frame = ProgressParser.ParseFrame(line);
if (frame is not null)
{
framesDone = Math.Max(framesDone, frame.Value);
lastProgressAt = DateTimeOffset.UtcNow;
eta.RecordFrame(lastProgressAt);
}
if ((isStderr || ProgressParser.IsErrorLine(line)) && !string.IsNullOrWhiteSpace(line))
{
if (ProgressParser.IsErrorLine(line)) firstError ??= line.Trim();
if (ProgressParser.IsNonRetryableError(line)) nonRetryable = true;
}
}
process.OutputDataReceived += (_, e) => HandleLine(e.Data, isStderr: false);
process.ErrorDataReceived += (_, e) => HandleLine(e.Data, isStderr: true);
try
{
process.Start();
}
catch (Exception ex)
{
return new RenderResult(false, -1, Retryable: false,
$"Failed to launch aerender at {_options.AerenderPath}: {ex.Message}",
tail.ToString(), fullLogPath, 0);
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
var total = totalFrames;
while (!process.HasExited)
{
await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested)
{
_log.LogWarning("Cancellation requested — killing aerender tree for job {JobId}", jobId);
KillTree(process);
cancelledByServer = cancelJob.IsCancellationRequested;
break;
}
// Stall watchdog (§7.3): no progress line for stallTimeoutSeconds → kill
if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds)
{
_log.LogError("aerender stalled ({Sec}s without progress) — killing job {JobId}", config.StallTimeoutSeconds, jobId);
KillTree(process);
await WaitForExitSafe(process);
return new RenderResult(false, -2, Retryable: true,
$"aerender produced no progress for {config.StallTimeoutSeconds}s (stall timeout)",
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
// Progress report every ~10 s (renews lease); best-effort
if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10)
{
lastReportAt = DateTimeOffset.UtcNow;
var progress = total > 0 ? Math.Min(1.0, (double)framesDone / total) : 0;
try
{
var resp = await _api.ProgressAsync(jobId, machineId, progress,
framesDone > 0 ? frameStart + framesDone - 1 : null,
total, eta.EstimateSeconds(total - framesDone), tail.ToString(), shutdown);
if (resp?.CancelRequested == true)
{
_log.LogWarning("Server requested cancel for job {JobId}", jobId);
KillTree(process);
cancelledByServer = true;
break;
}
}
catch (Exception ex)
{
_log.LogDebug(ex, "Progress report failed (non-fatal; lease covered by reaper)");
}
}
}
await WaitForExitSafe(process);
sw.Stop();
lock (logLock) { fullLog.Flush(); }
if (cancelledByServer)
{
return new RenderResult(false, -3, Retryable: false, "Cancelled", tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
var exitCode = process.ExitCode;
if (exitCode == 0 && firstError is null)
{
return new RenderResult(true, 0, true, null, tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
return new RenderResult(false, exitCode, Retryable: !nonRetryable,
firstError ?? $"aerender exited with code {exitCode}",
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
/// <summary>
/// §7.3: outputDir is created before launch; a pre-existing non-empty dir
/// can only hold output from a previous crashed attempt of this same
/// immutable version. Safety guard: only files matching the job's own
/// output pattern prefix are ever deleted.
/// </summary>
/// <summary>Filename prefix of an output pattern ("X.[####].exr" → "X").</summary>
internal static string OutputPatternPrefix(string outputPattern)
{
var bracket = outputPattern.IndexOf('[');
return bracket > 0 ? outputPattern[..bracket].TrimEnd('.') : Path.GetFileNameWithoutExtension(outputPattern);
}
internal static void PrepareOutputDir(string outputDirLocal, string outputPattern)
{
Directory.CreateDirectory(outputDirLocal);
var prefix = OutputPatternPrefix(outputPattern);
if (string.IsNullOrEmpty(prefix)) return;
foreach (var file in Directory.EnumerateFiles(outputDirLocal))
{
if (Path.GetFileName(file).StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
try { File.Delete(file); } catch { /* locked leftovers surface as an aerender error */ }
}
}
}
private static void KillTree(Process process)
{
try
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
catch { /* already gone */ }
}
private static async Task WaitForExitSafe(Process process)
{
try { await process.WaitForExitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token); }
catch { try { process.Kill(entireProcessTree: true); } catch { } }
}
}
/// <summary>Ring buffer of the last N output lines (§5.3 logTail).</summary>
public sealed class LogTail
{
private readonly Queue<string> _lines;
private readonly int _capacity;
public LogTail(int capacity)
{
_capacity = capacity;
_lines = new Queue<string>(capacity);
}
public void Add(string line)
{
_lines.Enqueue(line);
while (_lines.Count > _capacity) _lines.Dequeue();
}
public override string ToString() => string.Join('\n', _lines);
}
+248
View File
@@ -0,0 +1,248 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// HTTP client for the VFXReview /api/ext pipeline endpoints. Workers talk
/// only HTTP — never a database (§2.1 principle 4).
/// </summary>
public sealed class ApiClient
{
private readonly HttpClient _http;
private readonly ILogger<ApiClient> _log;
public ApiClient(WorkerOptions options, ILogger<ApiClient> log)
{
_log = log;
_http = new HttpClient
{
BaseAddress = new Uri(options.ServerUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
}
private static readonly JsonSerializerOptions Json = WorkerOptions.JsonOpts;
// E6
public async Task<RegisterResponse> RegisterAsync(WorkerOptions o, CancellationToken ct)
{
var res = await _http.PostAsJsonAsync("api/ext/workers/register", new
{
name = o.MachineName,
hostname = Dns.GetHostName(),
workerVersion = o.WorkerVersion,
aeVersion = o.AeVersion,
// The server hands out BLENDER_RENDER work on the strength of the
// blender entry, so it reports the resolved path rather than a bare
// flag: a machine claiming Blender jobs it cannot run is worse than
// one that never claims them.
capabilities = new
{
maxConcurrentJobs = 1,
tools = new
{
ffmpeg = o.FfmpegPath != null,
oiiotool = o.OiiotoolPath != null,
blender = !string.IsNullOrWhiteSpace(o.BlenderPath) && File.Exists(o.BlenderPath) ? o.BlenderPath : null,
},
},
}, Json, ct);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<RegisterResponse>(Json, ct))!;
}
// E7
public async Task<HeartbeatResponse> HeartbeatAsync(string machineId, string? currentJobId, CancellationToken ct)
{
var res = await _http.PostAsJsonAsync($"api/ext/workers/{machineId}/heartbeat", new
{
cpuPercent = (double?)null,
memPercent = (double?)null,
diskFreeGb = GetDiskFreeGb(),
currentJobId,
}, Json, ct);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<HeartbeatResponse>(Json, ct))!;
}
// E8 — null when the queue is empty / machine outside its render window (204)
public async Task<ClaimedJob?> ClaimAsync(string machineId, string[] types, CancellationToken ct)
{
var res = await _http.PostAsJsonAsync("api/ext/render/jobs/claim", new
{
machineId,
types,
}, Json, ct);
if (res.StatusCode == HttpStatusCode.NoContent) return null;
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ClaimResponse>(Json, ct);
return body?.Job;
}
// E9 — best-effort by contract: a progress report is a nicety, and losing
// one must never abort work in flight. Transport failures return null here
// rather than throwing at the call site; the lease and the reaper are what
// actually protect the job.
public async Task<ProgressResponse?> ProgressAsync(string jobId, string machineId, double progress,
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
{
Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json),
};
var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) return null;
return await res.Content.ReadFromJsonAsync<ProgressResponse>(Json, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // a real shutdown still stops us
}
catch (Exception ex)
{
_log.LogDebug(ex, "Progress report failed for job {JobId} (non-fatal)", jobId);
return null;
}
}
// E10 / E11 — lifecycle reports, sent via the spooler for durability.
// Returns true when the server accepted (2xx) OR permanently rejected (409
// contradictory / 404 gone) — both mean "stop retrying".
public async Task<bool> SendLifecycleAsync(string kind, string jobId, Dictionary<string, object?> payload, CancellationToken ct)
{
var path = kind switch
{
"fail" => $"api/ext/render/jobs/{jobId}/fail",
"complete-render" => $"api/ext/render/jobs/{jobId}/complete-render",
"validation" => $"api/ext/render/jobs/{jobId}/validation",
"delivery" => $"api/ext/render/jobs/{jobId}/delivery",
"finalize" => $"api/ext/render/jobs/{jobId}/finalize",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
};
var res = await _http.PostAsJsonAsync(path, payload, Json, ct);
if (res.IsSuccessStatusCode) return true;
if (res.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound or HttpStatusCode.UnprocessableEntity)
{
// Conflict/NotFound: contradictory or vanished report — nothing to retry.
// UnprocessableEntity: the server rejected the payload shape itself, so
// resending the same bytes can only ever produce the same 422 — retrying
// just spams the log forever instead of surfacing the real problem.
_log.LogWarning("Server permanently rejected {Kind} for job {JobId}: {Status} {Body}",
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
return true;
}
_log.LogWarning("Lifecycle report {Kind} for {JobId} failed with {Status}; will retry", kind, jobId, (int)res.StatusCode);
return false;
}
// E20 + PUT upload. Returns the storage key, or null when the upload failed.
public async Task<string?> UploadArtifactAsync(
string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct)
{
try
{
if (!File.Exists(filePath))
{
_log.LogWarning("Artifact {Kind} not found at {Path}", kind, filePath);
return null;
}
var presignRes = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-presign", new
{
machineId,
kind,
fileName = Path.GetFileName(filePath),
contentType,
}, Json, ct);
if (!presignRes.IsSuccessStatusCode)
{
_log.LogWarning("Presign for {Kind} failed: {Status}", kind, (int)presignRes.StatusCode);
return null;
}
var presign = await presignRes.Content.ReadFromJsonAsync<PresignResponse>(Json, ct);
if (presign is null) return null;
// Media files can be large — generous timeout, separate client so the
// presigned URL is not sent with our Authorization header.
using var upload = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
await using var stream = File.OpenRead(filePath);
var content = new StreamContent(stream);
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
var putRes = await upload.PutAsync(presign.PresignedUrl, content, ct);
if (!putRes.IsSuccessStatusCode)
{
_log.LogWarning("Upload of {Kind} failed: {Status}", kind, (int)putRes.StatusCode);
return null;
}
return presign.Key;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Artifact upload ({Kind}) failed for job {JobId}", kind, jobId);
return null;
}
}
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct);
/// <summary>
/// Reports the deployed worker version so the web UI can show what is
/// running where. Fire-and-forget — never worth failing a startup over.
/// </summary>
public async Task ReportVersionAsync(WorkerOptions o, CancellationToken ct)
{
try
{
await _http.PostAsJsonAsync("api/ext/tools/ping", new
{
tool = "RENDER_WORKER",
machineName = o.MachineName,
version = o.WorkerVersion,
hostUser = Environment.UserName,
hostApp = $"{Environment.OSVersion.VersionString} ({Dns.GetHostName()})",
details = new
{
aeVersion = o.AeVersion,
aerenderPath = o.AerenderPath,
ffmpeg = o.FfmpegPath is not null,
plateSearchRoots = o.PlateSearchRoots,
},
}, Json, ct);
}
catch (Exception ex)
{
_log.LogDebug(ex, "Version ping failed (non-fatal)");
}
}
// E3 — used by crash-recovery reconcile (§7.5)
public async Task<ExportDetail?> GetExportAsync(string exportId, CancellationToken ct)
{
var res = await _http.GetAsync($"api/ext/exports/{exportId}", ct);
if (!res.IsSuccessStatusCode) return null;
var body = await res.Content.ReadFromJsonAsync<ExportDetailResponse>(Json, ct);
return body?.Export;
}
private static double? GetDiskFreeGb()
{
try
{
var root = Path.GetPathRoot(Environment.SystemDirectory);
if (root is null) return null;
return Math.Round(new DriveInfo(root).AvailableFreeSpace / 1_073_741_824.0, 1);
}
catch
{
return null;
}
}
}
@@ -0,0 +1,68 @@
using System.Text.RegularExpressions;
namespace VFXReviewWorker;
/// <summary>
/// Parses `blender -b` stdout. Blender reports the frame it is working on in
/// its status lines ("Fra:1042 Mem:512.00M ...") and confirms each written file
/// with "Saved: '...'". The saved lines are the reliable signal — a frame can
/// be reported for a long time while it renders — so progress counts those and
/// falls back to Fra: for a first sign of life.
/// </summary>
public static partial class BlenderProgressParser
{
[GeneratedRegex(@"^Fra:(\d+)", RegexOptions.Compiled)]
private static partial Regex FrameLine();
[GeneratedRegex(@"^Saved:\s*'(.+)'", RegexOptions.Compiled)]
private static partial Regex SavedLine();
[GeneratedRegex(@"^(Error|Warning): |^Error: ", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex ErrorLine();
/// <summary>
/// Failures that will repeat identically on another machine — a missing
/// file, an unknown scene, an unwritable output. Retrying these burns the
/// queue for nothing; a human has to fix the .blend or the path.
/// </summary>
private static readonly string[] NonRetryablePatterns =
{
"cannot read file",
"file not found",
"unable to open",
"is not a blend file",
"failed to read blend file",
"unknown scene",
"scene not found",
"unable to create directory",
"cannot write file",
"no camera",
"python script fail",
"unknown argument",
};
/// <summary>The frame Blender is currently working on, or null.</summary>
public static int? ParseFrame(string line)
{
var m = FrameLine().Match(line);
return m.Success && int.TryParse(m.Groups[1].Value, out var f) ? f : null;
}
/// <summary>The path of a frame Blender has just written, or null.</summary>
public static string? ParseSaved(string line)
{
var m = SavedLine().Match(line);
return m.Success ? m.Groups[1].Value : null;
}
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line);
public static bool IsNonRetryableError(string line)
{
foreach (var p in NonRetryablePatterns)
{
if (line.Contains(p, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}
@@ -0,0 +1,313 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Standalone Blender renders: `blender -b file.blend -s N -e M -a`.
///
/// The .blend is expected to be ready to render — output path, format and
/// frame range already set by whoever built it. Overrides are only applied
/// when the submission asked for them, so this stays "Ctrl+F12 on the farm"
/// rather than a second place where render settings can drift.
/// </summary>
public sealed class BlenderRunner
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly ILogger<BlenderRunner> _log;
public BlenderRunner(WorkerOptions options, ApiClient api, ILogger<BlenderRunner> log)
{
_options = options;
_api = api;
_log = log;
}
public async Task<RenderResult> RunAsync(
ClaimedJob job,
BlenderManifest m,
string machineId,
ServerConfig config,
CancellationToken cancelJob,
CancellationToken shutdown)
{
var mapper = new PathMapper(_options.PathMappings);
var blendLocal = mapper.Map(m.BlendFile);
var totalFrames = m.TotalFrames;
if (!File.Exists(blendLocal))
{
return new RenderResult(false, -1, Retryable: false,
$"Blend file not found on this machine: {blendLocal}", "", "", 0);
}
// Argument order matters to Blender: everything before -a configures the
// render, and -a must come last or it renders with the old settings.
// -noaudio: a render node has no sound device, and initialising audio
// under a session-less service account is a known way to hang at startup.
var args = new List<string> { "-b", blendLocal, "-noaudio" };
if (!string.IsNullOrWhiteSpace(m.Scene)) { args.Add("-S"); args.Add(m.Scene!); }
if (!string.IsNullOrWhiteSpace(m.OutputPath))
{
args.Add("-o");
args.Add(mapper.Map(m.OutputPath!));
}
if (!string.IsNullOrWhiteSpace(m.Engine)) { args.Add("-E"); args.Add(m.Engine!); }
var pythonOverrides = BuildOverrideExpression(m);
if (pythonOverrides is not null) { args.Add("--python-expr"); args.Add(pythonOverrides); }
args.Add("-s"); args.Add(m.FrameStart.ToString());
args.Add("-e"); args.Add(m.FrameEnd.ToString());
args.Add("-a");
_log.LogInformation("Rendering {Label}: {Blend} frames {Start}-{End}",
job.Label ?? job.Id, blendLocal, m.FrameStart, m.FrameEnd);
return await ExecuteAsync(job.Id, machineId, args, totalFrames, m.FrameStart, config,
$"blender_{job.Id}.log", cancelJob, shutdown);
}
/// <summary>
/// Settings Blender has no command-line flag for. Returns null when the
/// submission asked for no overrides, so the file renders exactly as saved.
/// </summary>
private static string? BuildOverrideExpression(BlenderManifest m)
{
var parts = new List<string>();
if (m.Samples is int samples)
{
parts.Add($"getattr(S.cycles, 'samples', None) and setattr(S.cycles, 'samples', {samples})");
}
if (m.ResolutionPercent is int pct)
{
parts.Add($"setattr(S.render, 'resolution_percentage', {pct})");
}
if (!string.IsNullOrWhiteSpace(m.Device))
{
var deviceValue = m.Device!.Equals("GPU", StringComparison.OrdinalIgnoreCase) ? "GPU" : "CPU";
parts.Add($"hasattr(S, 'cycles') and setattr(S.cycles, 'device', '{deviceValue}')");
}
if (parts.Count == 0) return null;
return "import bpy; S = bpy.context.scene; " + string.Join("; ", parts);
}
private async Task<RenderResult> ExecuteAsync(
string jobId,
string machineId,
List<string> args,
int totalFrames,
int frameStart,
ServerConfig config,
string logFileName,
CancellationToken cancelJob,
CancellationToken shutdown)
{
var fullLogPath = Path.Combine(WorkerOptions.LogsDir, logFileName);
Directory.CreateDirectory(WorkerOptions.LogsDir);
var psi = new ProcessStartInfo
{
FileName = _options.BlenderPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var a in args) psi.ArgumentList.Add(a);
var tail = new LogTail(200);
var eta = new EtaCalculator();
var sw = Stopwatch.StartNew();
var lastProgressAt = DateTimeOffset.UtcNow;
var lastReportAt = DateTimeOffset.MinValue;
var framesSaved = 0;
var currentFrame = frameStart;
string? firstError = null;
var nonRetryable = false;
var cancelledByServer = false;
using var process = new Process { StartInfo = psi };
await using var fullLog = new StreamWriter(fullLogPath, append: false, Encoding.UTF8);
var logLock = new object();
void HandleLine(string? line, bool isStderr)
{
if (line is null) return;
lock (logLock)
{
fullLog.WriteLine(line);
tail.Add(line);
}
// Any output at all means Blender is alive, which is what the stall
// watchdog is actually asking. Requiring a recognised progress line
// made the watchdog stricter than its own error message and killed
// healthy renders whose output we simply were not parsing.
lastProgressAt = DateTimeOffset.UtcNow;
var frame = BlenderProgressParser.ParseFrame(line);
if (frame is not null) currentFrame = frame.Value;
if (BlenderProgressParser.ParseSaved(line) is not null)
{
// Progress counts finished frames only: a frame emits hundreds
// of status updates while it renders.
framesSaved++;
eta.RecordFrame(DateTimeOffset.UtcNow);
}
if ((isStderr || BlenderProgressParser.IsErrorLine(line)) && !string.IsNullOrWhiteSpace(line))
{
if (BlenderProgressParser.IsErrorLine(line)) firstError ??= line.Trim();
if (BlenderProgressParser.IsNonRetryableError(line)) nonRetryable = true;
}
}
try
{
process.Start();
}
catch (Exception ex)
{
return new RenderResult(false, -1, Retryable: false,
$"Failed to launch Blender at {_options.BlenderPath}: {ex.Message}",
tail.ToString(), fullLogPath, 0);
}
// Deliberately not BeginOutputReadLine: that raises an event per
// newline, and Blender's render status is written with a carriage
// return so it overwrites one line in place. A frame that takes longer
// than the stall timeout would emit no newline for its whole duration
// and be killed as stalled while rendering perfectly well.
var pumps = new[]
{
PumpAsync(process.StandardOutput, line => HandleLine(line, isStderr: false)),
PumpAsync(process.StandardError, line => HandleLine(line, isStderr: true)),
};
while (!process.HasExited)
{
await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested)
{
_log.LogWarning("Cancellation requested — killing Blender tree for job {JobId}", jobId);
KillTree(process);
cancelledByServer = cancelJob.IsCancellationRequested;
break;
}
if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds)
{
_log.LogError("Blender stalled ({Sec}s without output) — killing job {JobId}", config.StallTimeoutSeconds, jobId);
KillTree(process);
await WaitForExitSafe(process);
return new RenderResult(false, -2, Retryable: true,
$"Blender produced no output for {config.StallTimeoutSeconds}s (stall timeout)",
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10)
{
lastReportAt = DateTimeOffset.UtcNow;
var progress = totalFrames > 0 ? Math.Min(1.0, (double)framesSaved / totalFrames) : 0;
var resp = await _api.ProgressAsync(jobId, machineId, progress, currentFrame, totalFrames,
eta.EstimateSeconds(totalFrames - framesSaved), tail.ToString(), shutdown);
if (resp?.CancelRequested == true)
{
_log.LogWarning("Server requested cancel for job {JobId}", jobId);
KillTree(process);
cancelledByServer = true;
break;
}
}
}
await WaitForExitSafe(process);
// Let the readers drain what is still buffered before judging the run —
// the last frame's "Saved:" often arrives after the process exits.
try { await Task.WhenAll(pumps).WaitAsync(TimeSpan.FromSeconds(10)); } catch { }
sw.Stop();
lock (logLock) { fullLog.Flush(); }
if (cancelledByServer)
{
return new RenderResult(false, -3, Retryable: false, "Cancelled", tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
var exitCode = process.ExitCode;
if (exitCode != 0)
{
return new RenderResult(false, exitCode, Retryable: !nonRetryable,
firstError ?? $"Blender exited with code {exitCode}",
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
// Blender exits 0 having rendered nothing if the range is empty or the
// output path is unwritable, so a silent success is treated as failure.
if (framesSaved == 0)
{
return new RenderResult(false, 0, Retryable: false,
"Blender exited cleanly but saved no frames — check the output path and frame range in the .blend",
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
_log.LogInformation("Job {JobId}: {Saved}/{Total} frames in {Sec:F0}s", jobId, framesSaved, totalFrames, sw.Elapsed.TotalSeconds);
return new RenderResult(true, 0, true, null, tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
}
/// <summary>
/// Reads a stream treating both CR and LF as line terminators, so
/// Blender's carriage-return progress updates are seen as they happen
/// rather than only when a newline eventually arrives.
/// </summary>
private static async Task PumpAsync(StreamReader reader, Action<string> onLine)
{
var buffer = new char[4096];
var current = new StringBuilder(256);
int read;
try
{
while ((read = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
for (var i = 0; i < read; i++)
{
var c = buffer[i];
if (c == '\r' || c == '\n')
{
if (current.Length > 0)
{
onLine(current.ToString());
current.Clear();
}
}
else
{
current.Append(c);
}
}
}
if (current.Length > 0) onLine(current.ToString());
}
catch (Exception)
{
// The process died mid-read; the exit path reports what happened.
}
}
private static void KillTree(Process process)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
}
private static async Task WaitForExitSafe(Process process)
{
try { await process.WaitForExitAsync(); } catch { }
}
}
@@ -0,0 +1,515 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Delivery builder (RenderPipeline2 §11). Creates the dated package folder,
/// hard-links (or copies) each shot's EXRs and delivery MOV into it, verifies
/// what it wrote, and leaves a manifest + CSV report beside the shots.
///
/// A MOV_REVIEW package (§11.6) runs the same path for a different shape: one
/// flat folder, one preview MOV per shot, and nothing written into it but the
/// MOVs themselves — it is shared with a producer, not filed as a delivery.
///
/// Hard links make an 18-shot EXR package near-instant and cost no extra disk.
/// They are safe here because rendered versions are immutable by pipeline
/// design — a new version is a new directory, nothing ever rewrites an EXR in
/// place. Cross-volume targets fall back to copying automatically.
/// </summary>
public sealed class DeliveryStage
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly ILogger<DeliveryStage> _log;
public DeliveryStage(WorkerOptions options, ApiClient api, ILogger<DeliveryStage> log)
{
_options = options;
_api = api;
_log = log;
}
public async Task<DeliveryResult> RunAsync(
ClaimedJob job, DeliveryManifest manifest, string machineId, CancellationToken cancelJob, CancellationToken shutdown)
{
var mapper = new PathMapper(_options.PathMappings);
var packageDir = mapper.Map(manifest.PackageDir);
var results = new List<DeliveryItemResult>();
var manifestFiles = new List<object>();
var alwaysCopy = string.Equals(manifest.CopyMode, "always-copy", StringComparison.OrdinalIgnoreCase);
var alwaysHash = string.Equals(manifest.VerifyMode, "always-hash", StringComparison.OrdinalIgnoreCase);
var isReview = string.Equals(manifest.Kind, "MOV_REVIEW", StringComparison.OrdinalIgnoreCase);
_log.LogInformation("Building {What} {Name} ({Count} shots) at {Dir}",
isReview ? "MOV review" : "delivery package", manifest.PackageName, manifest.Items.Count, packageDir);
Directory.CreateDirectory(packageDir);
for (var i = 0; i < manifest.Items.Count; i++)
{
if (cancelJob.IsCancellationRequested) break;
var item = manifest.Items[i];
await _api.ProgressAsync(job.Id, machineId, (double)i / manifest.Items.Count, i, manifest.Items.Count, null,
$"Packaging {item.ShotCode} {item.VersionString} ({i + 1}/{manifest.Items.Count})", shutdown);
var result = isReview
? BuildReviewItem(item, mapper, alwaysCopy, alwaysHash, manifestFiles)
: BuildItem(item, mapper, alwaysCopy, alwaysHash, manifestFiles);
results.Add(result);
if (result.Status == "FAILED")
{
_log.LogError("Delivery item {Shot} failed: {Error}", item.ShotCode, result.ErrorMessage);
}
}
var ok = results.Count == manifest.Items.Count && results.All(r => r.Status == "VERIFIED");
// The report is the client-facing document, so it is written even for a
// partial build — it is the fastest way to see what did and did not land.
//
// A review folder gets neither: it is opened by a producer who wants the
// MOVs and nothing else. The manifest still travels back to the server,
// so the record of what was shared is kept without cluttering the share.
string? reportPath = null;
if (!isReview)
{
try
{
reportPath = WriteReport(manifest, results, mapper);
WriteManifestJson(manifest, results, manifestFiles, packageDir);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Could not write delivery report/manifest for {Name}", manifest.PackageName);
}
}
return new DeliveryResult(ok, results, packageDir, reportPath, manifestFiles);
}
private DeliveryItemResult BuildItem(
DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, bool alwaysHash, List<object> manifestFiles)
{
var sourceDir = mapper.Map(item.SourceDir);
var shotDir = mapper.Map(item.ShotDir);
try
{
if (!Directory.Exists(sourceDir))
{
return DeliveryItemResult.Failed(item, $"Source folder not found: {sourceDir}");
}
var pattern = SequencePattern.FromOutputPattern(item.OutputPattern);
var sources = Directory.EnumerateFiles(sourceDir)
.Where(f => Path.GetFileName(f).StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)
&& Path.GetFileName(f).EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase))
.OrderBy(f => f, StringComparer.Ordinal)
.ToList();
if (sources.Count == 0)
{
return DeliveryItemResult.Failed(item, $"No {pattern.Extension} files matching {pattern.Prefix}* in {sourceDir}");
}
if (item.FileCount is int expected && sources.Count != expected)
{
return DeliveryItemResult.Failed(item,
$"Source has {sources.Count} frames but the export recorded {expected} — the render changed since QC");
}
Directory.CreateDirectory(shotDir);
// Delivered names carry the client-facing base (or the studio's own
// convention when no client code is set), not the internal render
// filename — only the frame number and extension survive from the
// source. A frame whose number can't be parsed falls back to the
// source filename verbatim rather than failing the whole delivery.
var destNames = sources.ToDictionary(s => s, s => DeliveredFrameName(s, item, pattern));
long totalBytes = 0;
var usedCopy = false;
foreach (var src in sources)
{
var dest = Path.Combine(shotDir, destNames[src]);
if (Place(src, dest, alwaysCopy, out var copied) is string placeError)
{
return DeliveryItemResult.Failed(item, placeError);
}
usedCopy |= copied;
totalBytes += new FileInfo(dest).Length;
}
// Verification is proportional to what was actually done.
//
// A hard link is a second name for the same data, so hashing it
// re-reads the exact bytes validation already hashed — it cannot
// disagree, and at ~100 MB/s over SMB it costs half an hour for an
// episode to prove nothing. A copy is different: it can be
// truncated or corrupted, so it is read back in full.
string? digest;
string verifiedBy;
if (usedCopy || alwaysHash)
{
digest = SequenceChecksum(sources.Select(s => Path.Combine(shotDir, destNames[s])));
if (item.Checksum is { Length: > 0 } && !string.Equals(digest, item.Checksum, StringComparison.OrdinalIgnoreCase))
{
return DeliveryItemResult.Failed(item,
$"Checksum mismatch after packaging: expected {item.Checksum}, got {digest}");
}
verifiedBy = "hash";
}
else
{
// The one thing linking cannot rule out is the source having
// changed since it passed QC — the link would faithfully carry
// the new bytes. Timestamps answer that for the price of a stat.
if (item.ValidatedAt is DateTimeOffset validatedAt)
{
var touched = sources
.Select(s => new FileInfo(s))
.Where(f => f.LastWriteTimeUtc > validatedAt.UtcDateTime.AddSeconds(1))
.ToList();
if (touched.Count > 0)
{
return DeliveryItemResult.Failed(item,
$"{touched.Count} source file(s) were modified after validation " +
$"({touched[0].Name} at {touched[0].LastWriteTimeUtc:u}, validated {validatedAt.UtcDateTime:u}) — " +
"the render changed after it passed QC");
}
}
// Same data as the source, so the validation-time digest describes
// the delivered files exactly.
digest = item.Checksum;
verifiedBy = "link identity";
}
_log.LogDebug("{Shot}: verified by {How}", item.ShotCode, verifiedBy);
string? movDest = null;
if (!string.IsNullOrWhiteSpace(item.DeliveryMovPath))
{
var movSrc = mapper.Map(item.DeliveryMovPath!);
if (File.Exists(movSrc))
{
var movName = string.IsNullOrWhiteSpace(item.DeliveredBaseName)
? Path.GetFileName(movSrc)
: item.DeliveredBaseName + Path.GetExtension(movSrc);
movDest = Path.Combine(shotDir, movName);
if (Place(movSrc, movDest, alwaysCopy, out var movCopied) is string movError)
{
return DeliveryItemResult.Failed(item, movError);
}
usedCopy |= movCopied;
totalBytes += new FileInfo(movDest).Length;
}
else
{
// Not fatal: the EXRs are the deliverable, the MOV is a
// viewing copy. It is recorded so it is not silently absent.
_log.LogWarning("Delivery MOV missing for {Shot}: {Path}", item.ShotCode, movSrc);
}
}
foreach (var src in sources)
{
var name = destNames[src];
manifestFiles.Add(new
{
shotCode = item.ShotCode,
relativePath = Path.Combine(Path.GetFileName(shotDir), name).Replace('\\', '/'),
bytes = new FileInfo(Path.Combine(shotDir, name)).Length,
exportId = item.ExportId,
});
}
return new DeliveryItemResult
{
ItemId = item.ItemId,
ShotCode = item.ShotCode,
VersionString = item.VersionString,
Status = "VERIFIED",
ExrPath = shotDir,
MovPath = movDest,
FileCount = sources.Count,
TotalBytes = totalBytes,
Checksum = digest,
LinkMode = usedCopy ? "copy" : "hardlink",
FrameStart = item.FrameStart,
FrameEnd = item.FrameEnd,
};
}
catch (Exception ex)
{
return DeliveryItemResult.Failed(item, $"{ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// One shot of a MOV review package: place its preview MOV straight into
/// the (flat) review folder under the client-facing name.
///
/// A missing MOV is fatal here, unlike in an EXR delivery where the MOV is
/// a viewing copy beside the real deliverable — in a review it *is* the
/// deliverable, and a review folder quietly one shot short is worse than
/// one that says so.
/// </summary>
private DeliveryItemResult BuildReviewItem(
DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, bool alwaysHash, List<object> manifestFiles)
{
try
{
if (string.IsNullOrWhiteSpace(item.DeliveryMovPath))
{
return DeliveryItemResult.Failed(item, "No preview MOV is recorded for this version");
}
var movSrc = mapper.Map(item.DeliveryMovPath!);
if (!File.Exists(movSrc))
{
return DeliveryItemResult.Failed(item, $"Preview MOV not found: {movSrc}");
}
// ShotDir is the package folder itself for a review — the MOVs sit
// beside each other so a producer can scrub through them in order.
var reviewDir = mapper.Map(item.ShotDir);
Directory.CreateDirectory(reviewDir);
var name = string.IsNullOrWhiteSpace(item.DeliveredBaseName)
? Path.GetFileName(movSrc)
: item.DeliveredBaseName + Path.GetExtension(movSrc);
var dest = Path.Combine(reviewDir, name);
if (Place(movSrc, dest, alwaysCopy, out var copied) is string error)
{
return DeliveryItemResult.Failed(item, error);
}
var sourceLength = new FileInfo(movSrc).Length;
var destLength = new FileInfo(dest).Length;
// There is no validation-time digest for a MOV to check against, so
// a copy is proved against the source it came from: length always
// (it catches the truncation a full disk causes, for free), and the
// full hash when the config asks for it. A hard link is the source.
string? digest = null;
if (copied)
{
if (destLength != sourceLength)
{
return DeliveryItemResult.Failed(item,
$"Copied MOV is {destLength} bytes but the source is {sourceLength} — the copy is incomplete");
}
if (alwaysHash)
{
digest = XxHash64.HashFile(dest);
var sourceDigest = XxHash64.HashFile(movSrc);
if (!string.Equals(digest, sourceDigest, StringComparison.OrdinalIgnoreCase))
{
return DeliveryItemResult.Failed(item,
$"Copied MOV does not match its source: expected {sourceDigest}, got {digest}");
}
}
}
else if (alwaysHash)
{
digest = XxHash64.HashFile(dest);
}
manifestFiles.Add(new
{
shotCode = item.ShotCode,
relativePath = name,
bytes = destLength,
exportId = item.ExportId,
});
return new DeliveryItemResult
{
ItemId = item.ItemId,
ShotCode = item.ShotCode,
VersionString = item.VersionString,
Status = "VERIFIED",
// No EXR sequence is involved, so there is no shot folder to record.
ExrPath = null,
MovPath = dest,
FileCount = 1,
TotalBytes = destLength,
Checksum = digest,
LinkMode = copied ? "copy" : "hardlink",
FrameStart = item.FrameStart,
FrameEnd = item.FrameEnd,
};
}
catch (Exception ex)
{
return DeliveryItemResult.Failed(item, $"{ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Hard-link when possible, copy when not. Returns null on success or the
/// error message. `copied` reports which happened, so the package records
/// what it actually did rather than what it intended.
/// </summary>
private string? Place(string source, string dest, bool alwaysCopy, out bool copied)
{
copied = false;
try
{
if (File.Exists(dest)) File.Delete(dest);
if (!alwaysCopy && CreateHardLink(dest, source, IntPtr.Zero))
{
return null;
}
// Cross-volume, a filesystem without link support, or always-copy.
File.Copy(source, dest, overwrite: true);
copied = true;
return null;
}
catch (Exception ex)
{
return $"Could not place {Path.GetFileName(source)}: {ex.Message}";
}
}
/// <summary>
/// The delivered filename for one source frame: DeliveredBaseName with the
/// frame number (re-padded to the render's own convention) and extension —
/// never the internal render filename it was copied from. Falls back to
/// the source filename verbatim (never fatal) when there's no client-facing
/// base to use, or the frame number can't be parsed out of the source name.
/// </summary>
internal string DeliveredFrameName(string src, DeliveryManifestItem item, SequencePattern pattern)
{
var sourceName = Path.GetFileName(src);
if (string.IsNullOrWhiteSpace(item.DeliveredBaseName))
{
return sourceName;
}
var frame = pattern.FrameNumberOf(sourceName);
if (frame is null)
{
_log.LogWarning("Could not parse a frame number out of {Name} for {Shot} — delivering under its original name",
sourceName, item.ShotCode);
return sourceName;
}
var padding = Math.Max(1, pattern.Padding);
return $"{item.DeliveredBaseName}.{frame.Value.ToString().PadLeft(padding, '0')}{pattern.Extension}";
}
private static string SequenceChecksum(IEnumerable<string> files)
{
var state = new XxHash64.State(0);
foreach (var f in files)
{
state.Update(Encoding.ASCII.GetBytes(XxHash64.HashFile(f)));
}
return state.Digest().ToString("x16");
}
/// <summary>
/// The client-facing delivery report (§11.4): one row per shot. Columns are
/// confirmed against the production's delivery spec before the first real
/// delivery (18.2-C2), which is why this is a plain, easily-edited writer.
/// </summary>
private string WriteReport(DeliveryManifest manifest, List<DeliveryItemResult> results, PathMapper mapper)
{
var path = mapper.Map(manifest.ReportFile);
var dir = Path.GetDirectoryName(path);
if (dir is not null) Directory.CreateDirectory(dir);
var sb = new StringBuilder();
sb.AppendLine("Shot,Version,FrameStart,FrameEnd,FrameCount,Files,Bytes,Checksum,MOV,Status");
foreach (var r in results)
{
sb.AppendLine(string.Join(",", new[]
{
Csv(r.ShotCode),
Csv(r.VersionString),
r.FrameStart.ToString(CultureInfo.InvariantCulture),
r.FrameEnd.ToString(CultureInfo.InvariantCulture),
(r.FrameEnd - r.FrameStart + 1).ToString(CultureInfo.InvariantCulture),
(r.FileCount ?? 0).ToString(CultureInfo.InvariantCulture),
(r.TotalBytes ?? 0).ToString(CultureInfo.InvariantCulture),
Csv(r.Checksum ?? ""),
Csv(r.MovPath is null ? "" : Path.GetFileName(r.MovPath)),
Csv(r.Status),
}));
}
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
_log.LogInformation("Wrote delivery report {Path}", path);
return path;
}
private void WriteManifestJson(
DeliveryManifest manifest, List<DeliveryItemResult> results, List<object> files, string packageDir)
{
var payload = new
{
package = manifest.PackageName,
packageNumber = manifest.PackageNumber,
episode = manifest.Episode,
builtAt = DateTimeOffset.UtcNow,
builtBy = Environment.MachineName,
checksumAlgo = manifest.ChecksumAlgo,
shots = results.Select(r => new
{
r.ShotCode,
r.VersionString,
r.FileCount,
r.TotalBytes,
r.Checksum,
r.LinkMode,
r.Status,
r.ErrorMessage,
}),
files,
};
var path = Path.Combine(packageDir, "manifest.json");
File.WriteAllText(path, JsonSerializer.Serialize(payload, WorkerOptions.JsonOpts), new UTF8Encoding(false));
}
private static string Csv(string value) =>
value.Contains(',') || value.Contains('"') ? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes);
}
public sealed record DeliveryResult(
bool Ok, List<DeliveryItemResult> Items, string PackageDir, string? ReportPath, List<object> Files);
public sealed class DeliveryItemResult
{
public string ItemId { get; set; } = "";
public string ShotCode { get; set; } = "";
public string VersionString { get; set; } = "";
public string Status { get; set; } = "PENDING";
public string? ExrPath { get; set; }
public string? MovPath { get; set; }
public int? FileCount { get; set; }
public long? TotalBytes { get; set; }
public string? Checksum { get; set; }
public string? LinkMode { get; set; }
public string? ErrorMessage { get; set; }
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
public static DeliveryItemResult Failed(DeliveryManifestItem item, string error) => new()
{
ItemId = item.ItemId,
ShotCode = item.ShotCode,
VersionString = item.VersionString,
Status = "FAILED",
ErrorMessage = error,
FrameStart = item.FrameStart,
FrameEnd = item.FrameEnd,
};
}
+339
View File
@@ -0,0 +1,339 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace VFXReviewWorker;
// Wire types for the /api/ext pipeline endpoints (RenderPipeline2 §6).
public sealed class RegisterResponse
{
public MachineInfo Machine { get; set; } = new();
public ServerConfig Config { get; set; } = new();
}
public sealed class MachineInfo
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public bool Enabled { get; set; }
}
/// <summary>Server-supplied tuning (E6) — fleet-tunable without touching installs.</summary>
public sealed class ServerConfig
{
public int PollSeconds { get; set; } = 10;
public int HeartbeatSeconds { get; set; } = 30;
public int LeaseSeconds { get; set; } = 300;
public int MaxAttempts { get; set; } = 3;
public int StallTimeoutSeconds { get; set; } = 600;
/// <summary>
/// Validation stage (§8). The server decides whether it runs, so turning it
/// off is a config change there rather than a redeploy of every worker.
/// </summary>
public bool ValidationEnabled { get; set; } = true;
public int ValidationSampleEvery { get; set; } = 25;
/// <summary>Head handles the plates carry; only used for the API timecode fallback (§8.1a).</summary>
public int ValidationHandleFrames { get; set; } = 8;
}
public sealed class ClaimResponse
{
public ClaimedJob Job { get; set; } = new();
}
public sealed class ClaimedJob
{
public string Id { get; set; } = "";
public string Type { get; set; } = "AE_RENDER";
public int Attempt { get; set; }
public int MaxAttempts { get; set; }
public string? ExportId { get; set; }
public string? DeliveryId { get; set; }
/// <summary>Set for jobs with no shot behind them; shown instead of a shot code.</summary>
public string? Label { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
/// <summary>Manifest shape depends on the job type — deserialize per stage.</summary>
public JsonElement Manifest { get; set; }
public RenderManifest AsRenderManifest() =>
Manifest.Deserialize<RenderManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a render manifest");
public PreviewManifest AsPreviewManifest() =>
Manifest.Deserialize<PreviewManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a preview manifest");
public DeliveryManifest AsDeliveryManifest() =>
Manifest.Deserialize<DeliveryManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a delivery manifest");
public BlenderManifest AsBlenderManifest() =>
Manifest.Deserialize<BlenderManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a Blender manifest");
}
/// <summary>
/// A standalone Blender render. Overrides are all optional: an unset value
/// leaves whatever the .blend was saved with, which is the point — the file is
/// expected to be ready to render.
/// </summary>
public sealed class BlenderManifest
{
public string Stage { get; set; } = "BLENDER";
public string BlendFile { get; set; } = "";
public string? Scene { get; set; }
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
public string? OutputPath { get; set; }
public string? Engine { get; set; }
public string? Device { get; set; }
public int? Samples { get; set; }
public int? ResolutionPercent { get; set; }
public string? BlenderVersion { get; set; }
public int TotalFrames => FrameEnd - FrameStart + 1;
}
/// <summary>DELIVERY_BUILD manifest (server-built, RenderPipeline2 §11).</summary>
public sealed class DeliveryManifest
{
public string Stage { get; set; } = "DELIVERY";
/// <summary>
/// "EXR_DELIVERY" builds the per-shot folders of EXRs + MOVs; "MOV_REVIEW"
/// builds a flat folder of preview MOVs for a formal review (§11.6). One
/// job type and one claim path either way — only what lands on disk differs.
/// </summary>
public string Kind { get; set; } = "EXR_DELIVERY";
public string PackageId { get; set; } = "";
public string PackageName { get; set; } = "";
public int PackageNumber { get; set; }
public string Episode { get; set; } = "";
public string PackageDir { get; set; } = "";
public string ReportFile { get; set; } = "";
public bool IncludeMovs { get; set; } = true;
public string CopyMode { get; set; } = "auto";
/// <summary>"auto" verifies copies by hash and links by identity; "always-hash" reads everything back.</summary>
public string VerifyMode { get; set; } = "auto";
public string ChecksumAlgo { get; set; } = "xxh64";
public List<string> ReportFormats { get; set; } = new();
public List<DeliveryManifestItem> Items { get; set; } = new();
}
public sealed class DeliveryManifestItem
{
public string ItemId { get; set; } = "";
public string ExportId { get; set; } = "";
public string ShotCode { get; set; } = "";
public string VersionString { get; set; } = "";
public string SourceDir { get; set; } = "";
public string OutputPattern { get; set; } = "";
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
public int? FileCount { get; set; }
public string? Checksum { get; set; }
public string? DeliveryMovPath { get; set; }
public string ShotDir { get; set; } = "";
/// <summary>
/// Server-computed: "{clientCode}_{versionString}" when the shot has a
/// client naming code, else the studio's own "{shotCode}_cmp_TT_{versionString}".
/// The delivered EXR sequence and MOV are named from this, not the
/// internal render filename they're copied from.
/// </summary>
public string DeliveredBaseName { get; set; } = "";
/// <summary>When this export's validation ran — lets a linked package detect a source changed since QC.</summary>
public DateTimeOffset? ValidatedAt { get; set; }
/// <summary>
/// True on a MOV_REVIEW item: there is no EXR sequence, so SourceDir,
/// OutputPattern, FileCount and Checksum are empty and DeliveryMovPath is
/// the whole deliverable.
/// </summary>
public bool IsMovOnly => string.IsNullOrWhiteSpace(SourceDir);
}
/// <summary>PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9).</summary>
public sealed class PreviewManifest
{
public string Stage { get; set; } = "PREVIEW";
public string ExportId { get; set; } = "";
public string ShotCode { get; set; } = "";
public string VersionString { get; set; } = "";
public string OutputDir { get; set; } = "";
public string OutputPattern { get; set; } = "";
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
public double Fps { get; set; } = 24;
public string TemplateAep { get; set; } = "";
public string TemplateComp { get; set; } = "";
public string? OverlayComp { get; set; }
public string? LutComp { get; set; }
public string MovTemplate { get; set; } = "";
public string Mp4Template { get; set; } = "";
public string MovOutput { get; set; } = "";
public string Mp4Output { get; set; } = "";
public string? SlateScopeProp { get; set; }
public string? SlateSubmissionProp { get; set; }
public SlateInfo Slate { get; set; } = new();
public int TotalFrames => FrameEnd - FrameStart + 1;
}
public sealed class SlateInfo
{
public string VersionName { get; set; } = "";
public string Date { get; set; } = "";
public string? Description { get; set; }
public string? Notes { get; set; }
public string ShotCode { get; set; } = "";
/// <summary>Production name, burned into the slate and the overlay.</summary>
public string? Show { get; set; }
public string? Episode { get; set; }
public string? Scene { get; set; }
public string? VfxScope { get; set; }
public string? SubmissionNote { get; set; }
}
/// <summary>Result JSON written by vfxr_build_preview.jsx.</summary>
public sealed class PreviewBuildResult
{
public bool Ok { get; set; }
public string? Error { get; set; }
public List<string> Warnings { get; set; } = new();
public string? TempAep { get; set; }
public string? PreviewComp { get; set; }
public int? FrameCount { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
/// <summary>
/// Seconds of slate ahead of picture in the preview comp (the SHOT layer's
/// in-point). Null on builds predating this field, or when the template has
/// no SHOT layer — the thumbnail then falls back to scanning from frame 0.
/// </summary>
public double? PictureStartSeconds { get; set; }
}
/// <summary>The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw.</summary>
public sealed class RenderManifest
{
public string ShotCode { get; set; } = "";
public string AepPath { get; set; } = "";
public string CompName { get; set; } = "";
public string OutputDir { get; set; } = "";
public string OutputPattern { get; set; } = "";
public string? OutputModuleTemplate { get; set; }
public string? RenderSettingsTemplate { get; set; }
/// <summary>Comp timeline range handed to aerender (-s/-e).</summary>
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
/// <summary>
/// The number the *first output file* carries — the output module's
/// starting number (house convention 1001), which is deliberately not the
/// comp's frame range: a comp anchored to the plate's timecode starts in
/// the millions. Null on manifests predating this field.
/// </summary>
public int? OutputFrameStart { get; set; }
public double Fps { get; set; } = 24;
public int Width { get; set; }
public int Height { get; set; }
public ManifestExpected? Expected { get; set; }
public ManifestSource? Source { get; set; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extra { get; set; }
public int TotalFrames => FrameEnd - FrameStart + 1;
}
/// <summary>Per-project expectations the validation engine checks against (§8.1).</summary>
public sealed class ManifestExpected
{
public string? Colorspace { get; set; }
public string? BitDepth { get; set; }
public string? ExrCompression { get; set; }
public bool? Alpha { get; set; }
/// <summary>The API's editorial in-point — the rank 3 timecode fallback only (§8.1a).</summary>
public string? TimecodeStart { get; set; }
}
/// <summary>Plate provenance resolved by the panel from the comp's footage layers (§6.2).</summary>
public sealed class ManifestSource
{
public PlateRef? TimingPlate { get; set; }
public List<PlateRef> Plates { get; set; } = new();
public string? StartSource { get; set; }
public int? StartFrames { get; set; }
public int? HandleFrames { get; set; }
}
public sealed class PlateRef
{
public string? Element { get; set; }
public string Path { get; set; } = "";
public int? FrameStart { get; set; }
public int? FrameEnd { get; set; }
}
public sealed class ProgressResponse
{
public bool Ok { get; set; }
public bool CancelRequested { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
}
public sealed class HeartbeatResponse
{
public bool Ok { get; set; }
public List<WorkerCommand> Commands { get; set; } = new();
}
public sealed class WorkerCommand
{
public string Type { get; set; } = "";
public string? JobId { get; set; }
}
public sealed class PresignResponse
{
public string PresignedUrl { get; set; } = "";
public string Key { get; set; } = "";
public string Url { get; set; } = "";
}
public sealed class ExportDetailResponse
{
public ExportDetail Export { get; set; } = new();
}
public sealed class ExportDetail
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
public List<RenderJobDetail> RenderJobs { get; set; } = new();
}
public sealed class RenderJobDetail
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
}
/// <summary>Durable lifecycle report persisted to the spool directory (§7.6).</summary>
public sealed class SpooledReport
{
public string Kind { get; set; } = ""; // "fail" | "complete-render"
public string JobId { get; set; } = "";
public Dictionary<string, object?> Payload { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
/// <summary>current-job.json — crash recovery state (§7.5).</summary>
public sealed class CurrentJobState
{
public string JobId { get; set; } = "";
public string? ExportId { get; set; } = "";
public string MachineId { get; set; } = "";
public string OutputDirLocal { get; set; } = "";
public DateTimeOffset ClaimedAt { get; set; }
}
+234
View File
@@ -0,0 +1,234 @@
using System.Text;
namespace VFXReviewWorker;
/// <summary>
/// Minimal OpenEXR header reader (RenderPipeline2 §8.1a).
///
/// Everything validation needs — timecode, fps, resolution, channel types,
/// compression — lives in the header, so a check costs one 64 KB read and no
/// pixel decode. That is why the engine needs no oiiotool/OpenImageIO
/// dependency and can run on a bare workstation.
///
/// Header layout: magic(4) version(4) then attributes as
/// `name\0 type\0 int32 size data`, terminated by an empty name.
/// </summary>
public sealed class ExrHeader
{
public const uint Magic = 20000630;
private const int MaxHeaderBytes = 1 << 18; // 256 KB — far beyond any sane header
public int DataWidth { get; private init; }
public int DataHeight { get; private init; }
public int DisplayWidth { get; private init; }
public int DisplayHeight { get; private init; }
public IReadOnlyList<ExrChannel> Channels { get; private init; } = Array.Empty<ExrChannel>();
public byte? CompressionCode { get; private init; }
public Timecode? Timecode { get; private init; }
public string? TimecodeString { get; private init; }
public double? FramesPerSecond { get; private init; }
public bool HasChromaticities { get; private init; }
public bool IsTiled { get; private init; }
public IReadOnlyList<string> AttributeNames { get; private init; } = Array.Empty<string>();
public bool HasAlpha => Channels.Any(c => c.Name.Equals("A", StringComparison.Ordinal));
/// <summary>half / float / uint — the deepest channel type present.</summary>
public string? BitDepth
{
get
{
if (Channels.Count == 0) return null;
if (Channels.Any(c => c.PixelType == 2)) return "float";
if (Channels.Any(c => c.PixelType == 1)) return "half";
return "uint";
}
}
public string CompressionName => CompressionCode switch
{
0 => "none", 1 => "RLE", 2 => "ZIPS", 3 => "ZIP", 4 => "PIZ",
5 => "PXR24", 6 => "B44", 7 => "B44A", 8 => "DWAA", 9 => "DWAB",
null => "unknown",
_ => $"code {CompressionCode}",
};
/// <summary>Reads the header, or throws <see cref="InvalidDataException"/> when the file is not a readable EXR.</summary>
public static ExrHeader Read(string path)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var buf = new byte[Math.Min(MaxHeaderBytes, Math.Max(1024, (int)Math.Min(fs.Length, MaxHeaderBytes)))];
var read = fs.Read(buf, 0, buf.Length);
if (read < 16) throw new InvalidDataException("File is too small to be an EXR");
var span = new ReadOnlySpan<byte>(buf, 0, read);
if (BitConverter.ToUInt32(span[..4]) != Magic) throw new InvalidDataException("Not an EXR (bad magic number)");
var version = BitConverter.ToInt32(span.Slice(4, 4));
int pos = 8;
var names = new List<string>();
var channels = new List<ExrChannel>();
int dataW = 0, dataH = 0, dispW = 0, dispH = 0;
byte? compression = null;
Timecode? tc = null;
string? tcString = null;
double? fps = null;
bool chroma = false;
while (pos < read)
{
var name = ReadCString(span, ref pos);
if (name.Length == 0) break; // end of header
var type = ReadCString(span, ref pos);
if (pos + 4 > read) throw new InvalidDataException("Truncated EXR header (attribute size)");
var size = BitConverter.ToInt32(span.Slice(pos, 4));
pos += 4;
if (size < 0 || pos + size > read) throw new InvalidDataException($"Truncated EXR header (attribute \"{name}\")");
var data = span.Slice(pos, size);
names.Add(name);
switch (name)
{
case "dataWindow" when size >= 16:
dataW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1;
dataH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1;
break;
case "displayWindow" when size >= 16:
dispW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1;
dispH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1;
break;
case "compression" when size >= 1:
compression = data[0];
break;
case "timeCode" when size >= 8:
tc = VFXReviewWorker.Timecode.FromPacked(
BitConverter.ToUInt32(data[..4]), BitConverter.ToUInt32(data.Slice(4, 4)));
break;
case "timeCodeString":
tcString = Encoding.ASCII.GetString(data).TrimEnd('\0');
break;
case "framesPerSecond" when size >= 8:
{
var num = BitConverter.ToInt32(data[..4]);
var den = BitConverter.ToUInt32(data.Slice(4, 4));
if (den != 0) fps = num / (double)den;
break;
}
case "chromaticities":
chroma = true;
break;
case "channels":
channels.AddRange(ReadChannels(data));
break;
}
pos += size;
}
return new ExrHeader
{
DataWidth = dataW,
DataHeight = dataH,
DisplayWidth = dispW,
DisplayHeight = dispH,
Channels = channels,
CompressionCode = compression,
Timecode = tc,
TimecodeString = tcString,
FramesPerSecond = fps,
HasChromaticities = chroma,
IsTiled = (version & 0x200) != 0,
AttributeNames = names,
};
}
/// <summary>Header read that returns null instead of throwing — for "is this file readable at all" checks.</summary>
public static ExrHeader? TryRead(string path)
{
try { return Read(path); }
catch { return null; }
}
private static IEnumerable<ExrChannel> ReadChannels(ReadOnlySpan<byte> data)
{
var result = new List<ExrChannel>();
int p = 0;
while (p < data.Length)
{
var name = ReadCString(data, ref p);
if (name.Length == 0) break; // null terminator ends the channel list
if (p + 16 > data.Length) break;
var pixelType = BitConverter.ToInt32(data.Slice(p, 4));
p += 16; // pixelType(4) + pLinear(1) + reserved(3) + xSampling(4) + ySampling(4)
result.Add(new ExrChannel(name, pixelType));
}
return result;
}
private static string ReadCString(ReadOnlySpan<byte> span, ref int pos)
{
int start = pos;
while (pos < span.Length && span[pos] != 0) pos++;
var s = Encoding.ASCII.GetString(span[start..pos]);
pos++; // skip the terminator
return s;
}
}
public readonly record struct ExrChannel(string Name, int PixelType)
{
public string TypeName => PixelType switch { 0 => "uint", 1 => "half", 2 => "float", _ => $"type {PixelType}" };
}
/// <summary>
/// SMPTE 12M timecode as stored in the EXR `timeCode` attribute: BCD-packed,
/// which is why the raw value reads back as its own hex digits
/// (0x18585712 → 18:58:57:12).
/// </summary>
public readonly record struct Timecode(int Hours, int Minutes, int Seconds, int Frames, bool DropFrame)
{
public static Timecode FromPacked(uint timeAndFlags, uint _userData = 0)
{
int Bits(int shift, int mask) => (int)((timeAndFlags >> shift) & (uint)mask);
return new Timecode(
Bits(24, 0xF) + Bits(28, 0x3) * 10,
Bits(16, 0xF) + Bits(20, 0x7) * 10,
Bits(8, 0xF) + Bits(12, 0x7) * 10,
Bits(0, 0xF) + Bits(4, 0x3) * 10,
Bits(6, 0x1) == 1);
}
public static Timecode? Parse(string? text, bool dropFrame = false)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var parts = text.Trim().Split(':', ';');
if (parts.Length < 4) return null;
if (!int.TryParse(parts[0], out var hh) || !int.TryParse(parts[1], out var mm) ||
!int.TryParse(parts[2], out var ss) || !int.TryParse(parts[3], out var ff)) return null;
return new Timecode(hh, mm, ss, ff, dropFrame || text.Contains(';'));
}
/// <summary>
/// Non-drop frame count. The studio shoots 24 fps, so drop-frame never
/// applies; a drop-frame flag mismatch is reported as its own check rather
/// than silently changing this arithmetic.
/// </summary>
public long ToFrames(double fps)
{
var rate = (long)Math.Round(fps <= 0 ? 24 : fps);
return ((Hours * 60L + Minutes) * 60L + Seconds) * rate + Frames;
}
public static Timecode FromFrames(long frames, double fps)
{
var rate = (long)Math.Round(fps <= 0 ? 24 : fps);
if (rate <= 0) rate = 24;
var f = Math.Max(0, frames);
var ff = (int)(f % rate);
var totalSeconds = f / rate;
return new Timecode((int)(totalSeconds / 3600), (int)(totalSeconds / 60 % 60), (int)(totalSeconds % 60), ff, false);
}
public override string ToString() =>
$"{Hours:D2}:{Minutes:D2}:{Seconds:D2}{(DropFrame ? ';' : ':')}{Frames:D2}";
}
@@ -0,0 +1,82 @@
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Minimal rolling file logger (§7.9): one file per day under
/// %ProgramData%\VFXReviewWorker\logs, files older than 14 days pruned on
/// startup and at midnight rollover. No external logging dependencies.
/// </summary>
public sealed class FileLoggerProvider : ILoggerProvider
{
private readonly object _lock = new();
private StreamWriter? _writer;
private DateOnly _currentDay;
public FileLoggerProvider()
{
Directory.CreateDirectory(WorkerOptions.LogsDir);
Prune();
}
public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName);
internal void Write(string category, LogLevel level, string message, Exception? ex)
{
lock (_lock)
{
var today = DateOnly.FromDateTime(DateTime.Now);
if (_writer is null || today != _currentDay)
{
_writer?.Dispose();
_currentDay = today;
_writer = new StreamWriter(
Path.Combine(WorkerOptions.LogsDir, $"worker_{today:yyyyMMdd}.log"),
append: true) { AutoFlush = true };
Prune();
}
_writer.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{level,-5}] {Shorten(category)}: {message}{(ex is null ? "" : $"\n{ex}")}");
}
}
private static string Shorten(string category) => category[(category.LastIndexOf('.') + 1)..];
private static void Prune()
{
try
{
var cutoff = DateTime.Now.AddDays(-14);
foreach (var f in Directory.EnumerateFiles(WorkerOptions.LogsDir, "*.log"))
{
if (File.GetLastWriteTime(f) < cutoff) File.Delete(f);
}
}
catch { /* best effort */ }
}
public void Dispose()
{
lock (_lock) { _writer?.Dispose(); _writer = null; }
}
private sealed class FileLogger : ILogger
{
private readonly FileLoggerProvider _provider;
private readonly string _category;
public FileLogger(FileLoggerProvider provider, string category)
{
_provider = provider;
_category = category;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
_provider.Write(_category, logLevel, formatter(state, exception), exception);
}
}
}
+556
View File
@@ -0,0 +1,556 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Executes one claimed job end-to-end: writes crash-recovery state, runs
/// aerender, uploads the full log, and enqueues the durable complete/fail
/// report. One job at a time per machine (§7.2).
/// </summary>
public sealed class JobExecutor
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly AerenderRunner _runner;
private readonly PreviewStage _preview;
private readonly ValidationStage _validation;
private readonly DeliveryStage _delivery;
private readonly BlenderRunner _blender;
private readonly ReportSpooler _spooler;
private readonly ILogger<JobExecutor> _log;
public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview,
ValidationStage validation, DeliveryStage delivery, BlenderRunner blender, ReportSpooler spooler, ILogger<JobExecutor> log)
{
_options = options;
_api = api;
_runner = runner;
_preview = preview;
_validation = validation;
_delivery = delivery;
_blender = blender;
_spooler = spooler;
_log = log;
}
public async Task RunAsync(ClaimedJob job, string machineId, ServerConfig config, CancellationToken cancelJob, CancellationToken shutdown)
{
if (job.Type == "PREVIEW_ONLY")
{
await RunPreviewAsync(job, machineId, config, cancelJob, shutdown);
return;
}
if (job.Type == "DELIVERY_BUILD")
{
await RunDeliveryAsync(job, machineId, cancelJob, shutdown);
return;
}
if (job.Type == "BLENDER_RENDER")
{
await RunBlenderAsync(job, machineId, config, cancelJob, shutdown);
return;
}
var manifest = job.AsRenderManifest();
var mapper = new PathMapper(_options.PathMappings);
var outputDirLocal = mapper.Map(manifest.OutputDir);
WriteState(new CurrentJobState
{
JobId = job.Id,
ExportId = job.ExportId,
MachineId = machineId,
OutputDirLocal = outputDirLocal,
ClaimedAt = DateTimeOffset.UtcNow,
});
try
{
var result = await _runner.RunAsync(job, manifest, machineId, outputDirLocal, config, cancelJob, shutdown);
var logKey = await _api.UploadLogAsync(job.Id, machineId, result.FullLogPath, shutdown);
if (result.Success)
{
long totalBytes = 0;
int fileCount = 0;
var prefix = AerenderRunner.OutputPatternPrefix(manifest.OutputPattern);
try
{
foreach (var f in Directory.EnumerateFiles(outputDirLocal))
{
if (!Path.GetFileName(f).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
fileCount++;
totalBytes += new FileInfo(f).Length;
}
}
catch { /* stats are best-effort in Phase 2; validation owns this in Phase 3 */ }
if (fileCount == 0)
{
// aerender can exit 0 having rendered nothing (bad frame
// range, template mismatch, silently swallowed error).
// Until Phase 3 validation exists, an empty output dir must
// never become READY_FOR_QC.
_log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*\" files exist in {Dir} — reporting failure",
job.Id, prefix, outputDirLocal);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "RENDER",
["exitCode"] = 0,
["errorMessage"] = "aerender exited 0 but produced no output files in " + outputDirLocal,
["logTail"] = result.LogTail,
["logFileKey"] = logKey,
["retryable"] = false,
});
return;
}
_log.LogInformation("Job {JobId} complete: {Frames} frames in {Sec:F0}s ({Files} files, {Bytes} bytes)",
job.Id, manifest.TotalFrames, result.RenderSeconds, fileCount, totalBytes);
_spooler.Enqueue("complete-render", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["renderSeconds"] = (int)result.RenderSeconds,
["logFileKey"] = logKey,
["logTail"] = result.LogTail,
["exrFileCount"] = fileCount,
["exrTotalBytes"] = totalBytes,
});
// Validation runs here, while we still hold the job and the
// files are local. It is queued behind complete-render, and the
// spooler's in-order replay is what guarantees the server has
// moved the Export to VALIDATING before this lands (§8).
if (config.ValidationEnabled)
{
await RunValidationAsync(job, manifest, machineId, outputDirLocal, config, shutdown);
}
}
else if (result.ErrorMessage == "Cancelled")
{
// Ack the cancel so the server clears the CANCEL_JOB command
_log.LogInformation("Job {JobId} cancelled — acknowledging", job.Id);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "RENDER",
["errorMessage"] = "Cancelled by server",
["logTail"] = result.LogTail,
["logFileKey"] = logKey,
["retryable"] = false,
});
}
else
{
_log.LogError("Job {JobId} failed (exit {Exit}, retryable {Retryable}): {Error}",
job.Id, result.ExitCode, result.Retryable, result.ErrorMessage);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "RENDER",
["exitCode"] = result.ExitCode,
["errorMessage"] = result.ErrorMessage,
["logTail"] = result.LogTail,
["logFileKey"] = logKey,
["retryable"] = result.Retryable,
});
}
}
catch (Exception ex)
{
_log.LogError(ex, "Unexpected executor error for job {JobId}", job.Id);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "RENDER",
["errorMessage"] = $"Worker exception: {ex.Message}",
["retryable"] = true,
});
}
finally
{
ClearState();
}
}
/// <summary>
/// BLENDER_RENDER job: a standalone render with no Export behind it, so
/// there is nothing to validate, preview or QC — it finishes at
/// complete-render and that is the whole lifecycle.
/// </summary>
private async Task RunBlenderAsync(ClaimedJob job, string machineId, ServerConfig config,
CancellationToken cancelJob, CancellationToken shutdown)
{
var manifest = job.AsBlenderManifest();
WriteState(new CurrentJobState
{
JobId = job.Id,
ExportId = null,
MachineId = machineId,
OutputDirLocal = manifest.OutputPath ?? "",
ClaimedAt = DateTimeOffset.UtcNow,
});
try
{
var result = await _blender.RunAsync(job, manifest, machineId, config, cancelJob, shutdown);
var logKey = string.IsNullOrEmpty(result.FullLogPath)
? null
: await _api.UploadLogAsync(job.Id, machineId, result.FullLogPath, shutdown);
if (result.Success)
{
_log.LogInformation("Blender job {JobId} complete: {Frames} frames in {Sec:F0}s",
job.Id, manifest.TotalFrames, result.RenderSeconds);
_spooler.Enqueue("complete-render", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["renderSeconds"] = (int)result.RenderSeconds,
["logFileKey"] = logKey,
["logTail"] = result.LogTail,
});
return;
}
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "BLENDER",
["exitCode"] = result.ExitCode,
["errorMessage"] = result.ErrorMessage,
["logTail"] = result.LogTail,
["logFileKey"] = logKey,
["retryable"] = result.ErrorMessage == "Cancelled" ? false : result.Retryable,
});
}
catch (Exception ex)
{
_log.LogError(ex, "Unexpected Blender error for job {JobId}", job.Id);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "BLENDER",
["errorMessage"] = $"Worker exception: {ex.Message}",
["retryable"] = true,
});
}
finally
{
ClearState();
}
}
/// <summary>
/// DELIVERY_BUILD job: build the dated package folder from already-QC'd
/// exports. No AE involved — folders, links and verification only.
/// </summary>
private async Task RunDeliveryAsync(ClaimedJob job, string machineId, CancellationToken cancelJob, CancellationToken shutdown)
{
var manifest = job.AsDeliveryManifest();
WriteState(new CurrentJobState
{
JobId = job.Id,
ExportId = null,
MachineId = machineId,
OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.PackageDir),
ClaimedAt = DateTimeOffset.UtcNow,
});
try
{
var result = await _delivery.RunAsync(job, manifest, machineId, cancelJob, shutdown);
_log.LogInformation("Delivery {Name}: {Ok} ({Verified}/{Total} shots verified)",
manifest.PackageName, result.Ok ? "READY" : "FAILED",
result.Items.Count(i => i.Status == "VERIFIED"), manifest.Items.Count);
string? reportKey = null;
if (result.ReportPath is not null)
{
reportKey = await _api.UploadArtifactAsync(job.Id, machineId, "report", result.ReportPath, "text/csv", shutdown);
}
_spooler.Enqueue("delivery", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["ok"] = result.Ok,
["rootPath"] = result.PackageDir,
["reportKey"] = reportKey,
["items"] = result.Items.Select(i => new Dictionary<string, object?>
{
["itemId"] = i.ItemId,
["status"] = i.Status,
["exrPath"] = i.ExrPath,
["movPath"] = i.MovPath,
["fileCount"] = i.FileCount,
["totalBytes"] = i.TotalBytes,
["checksum"] = i.Checksum,
["linkMode"] = i.LinkMode,
["errorMessage"] = i.ErrorMessage,
}).ToList(),
["manifest"] = new Dictionary<string, object?>
{
["packageDir"] = result.PackageDir,
["reportPath"] = result.ReportPath,
["files"] = result.Files,
},
["errorMessage"] = result.Ok
? null
: string.Join("; ", result.Items.Where(i => i.Status == "FAILED").Select(i => $"{i.ShotCode}: {i.ErrorMessage}").Take(5)),
});
}
catch (Exception ex)
{
_log.LogError(ex, "Unexpected delivery error for job {JobId}", job.Id);
_spooler.Enqueue("delivery", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["ok"] = false,
["items"] = new List<object>(),
["errorMessage"] = $"Worker exception: {ex.Message}",
});
}
finally
{
ClearState();
}
}
/// <summary>
/// Runs the validation engine and spools the E12 report. Deliberately
/// non-fatal on its own errors: a crash in the checks must not lose a
/// finished render, so it is reported as a failed validation run rather
/// than being allowed to escape.
/// </summary>
private async Task RunValidationAsync(
ClaimedJob job, RenderManifest manifest, string machineId, string outputDirLocal,
ServerConfig config, CancellationToken shutdown)
{
var started = DateTimeOffset.UtcNow;
try
{
// Keep the lease alive: the job stays open through validation.
await _api.ProgressAsync(job.Id, machineId, 1.0, manifest.FrameEnd, manifest.TotalFrames, 0,
"Validating rendered sequence…", shutdown);
var outcome = await Task.Run(() => _validation.Run(manifest, outputDirLocal, config), shutdown);
var seconds = (DateTimeOffset.UtcNow - started).TotalSeconds;
var failed = outcome.Checks.Where(c => c.Status == "FAIL").Select(c => c.CheckName).ToList();
if (failed.Count > 0)
{
_log.LogWarning("Job {JobId} validation FAILED in {Sec:F1}s: {Checks}", job.Id, seconds, string.Join(", ", failed));
}
else
{
_log.LogInformation("Job {JobId} validation passed in {Sec:F1}s ({Count} checks, {Files} files)",
job.Id, seconds, outcome.Checks.Count, outcome.FileCount);
}
_spooler.Enqueue("validation", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["checks"] = outcome.Checks,
["checksum"] = outcome.Checksum,
["checksumAlgo"] = "xxh64",
["exrFileCount"] = outcome.FileCount,
["exrTotalBytes"] = outcome.TotalBytes,
["validationSeconds"] = (int)seconds,
});
}
catch (Exception ex)
{
_log.LogError(ex, "Validation engine error for job {JobId}", job.Id);
_spooler.Enqueue("validation", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["checks"] = new[]
{
ValidationCheck.Fail("engine_error", "validation completes", "worker exception",
$"The validation engine threw: {ex.Message}"),
},
});
}
}
/// <summary>
/// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate
/// and burn-ins, render the delivery MOV + review MP4, upload the review
/// media, and finalize (which registers the internal-only Version).
/// </summary>
private async Task RunPreviewAsync(ClaimedJob job, string machineId, ServerConfig config, CancellationToken cancelJob, CancellationToken shutdown)
{
var manifest = job.AsPreviewManifest();
WriteState(new CurrentJobState
{
JobId = job.Id,
ExportId = job.ExportId,
MachineId = machineId,
OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.OutputDir),
ClaimedAt = DateTimeOffset.UtcNow,
});
try
{
var result = await _preview.RunAsync(job, manifest, machineId, config, cancelJob, shutdown);
if (!result.Success)
{
_log.LogError("Preview job {JobId} failed (retryable {Retryable}): {Error}",
job.Id, result.Retryable, result.ErrorMessage);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "PREVIEW",
["errorMessage"] = result.ErrorMessage,
["retryable"] = result.Retryable,
});
return; // temp AEP is deliberately kept for debugging
}
var previewKey = await _api.UploadArtifactAsync(
job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown);
if (previewKey is null)
{
// The renders are on the SAN and fine — only the upload failed,
// so this is worth retrying without rebuilding anything.
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "PREVIEW",
["errorMessage"] = "Preview MP4 upload failed",
["retryable"] = true,
});
return;
}
string? thumbKey = null;
if (result.ThumbnailPath is not null)
{
thumbKey = await _api.UploadArtifactAsync(
job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown);
}
_spooler.Enqueue("finalize", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["artifacts"] = new Dictionary<string, object?>
{
["deliveryMovPath"] = manifest.MovOutput,
["previewMovKey"] = previewKey,
["thumbnailKey"] = thumbKey,
},
["renderStats"] = new Dictionary<string, object?>
{
["previewSeconds"] = (int)result.Seconds,
},
["media"] = new Dictionary<string, object?>
{
["width"] = result.Build?.Width,
["height"] = result.Build?.Height,
["frameCount"] = result.Build?.FrameCount,
["fps"] = manifest.Fps,
["fileName"] = Path.GetFileName(result.Mp4Path!),
},
});
// Only clean up once the outputs are safely reported.
CleanScratch(job.Id, result.TempAep);
}
catch (Exception ex)
{
_log.LogError(ex, "Unexpected preview error for job {JobId}", job.Id);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "PREVIEW",
["errorMessage"] = $"Worker exception: {ex.Message}",
["retryable"] = true,
});
}
finally
{
ClearState();
}
}
private static void CleanScratch(string jobId, string? tempAep)
{
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_context.json"));
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_result.json"));
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_run.jsx"));
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg"));
if (tempAep is not null) PreviewStage.TryDelete(tempAep);
}
/// <summary>
/// Crash recovery (§7.5): on service start, if current-job.json names a
/// job, ask the server what became of it. Still ours (CLAIMED/RUNNING) →
/// we crashed mid-render; report a retryable fail so the server requeues
/// (never resume a partial aerender). Reassigned/finished → discard state;
/// the next attempt's PrepareOutputDir clears any orphaned frames.
/// </summary>
public async Task ReconcileAsync(string machineId, CancellationToken ct)
{
var state = ReadState();
if (state is null) return;
_log.LogWarning("Found crash-recovery state for job {JobId} — reconciling", state.JobId);
try
{
var stillOurs = false;
if (!string.IsNullOrEmpty(state.ExportId))
{
var export = await _api.GetExportAsync(state.ExportId!, ct);
var job = export?.RenderJobs.FirstOrDefault(j => j.Id == state.JobId);
stillOurs = job?.Status is "CLAIMED" or "RUNNING";
}
if (stillOurs)
{
_log.LogWarning("Server still shows job {JobId} as ours — reporting crash for requeue", state.JobId);
_spooler.Enqueue("fail", state.JobId, new Dictionary<string, object?>
{
["machineId"] = machineId,
["stage"] = "RENDER",
["errorMessage"] = "Worker restarted mid-render (crash/power loss); never resuming a partial render",
["retryable"] = true,
});
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "Reconcile failed for job {JobId}; discarding local state (reaper covers the lease)", state.JobId);
}
ClearState();
}
private static void WriteState(CurrentJobState state)
{
Directory.CreateDirectory(WorkerOptions.DataDir);
File.WriteAllText(WorkerOptions.StateFilePath, JsonSerializer.Serialize(state, WorkerOptions.JsonOpts));
}
private static CurrentJobState? ReadState()
{
try
{
if (!File.Exists(WorkerOptions.StateFilePath)) return null;
return JsonSerializer.Deserialize<CurrentJobState>(File.ReadAllText(WorkerOptions.StateFilePath), WorkerOptions.JsonOpts);
}
catch
{
return null;
}
}
private static void ClearState()
{
try { File.Delete(WorkerOptions.StateFilePath); } catch { }
}
}
@@ -0,0 +1,36 @@
namespace VFXReviewWorker;
/// <summary>
/// Translates canonical UNC paths from manifests to this machine's local
/// drive mappings (§7.8 pathMappings). Comparison is case-insensitive and
/// slash-direction tolerant; the first matching mapping wins.
/// </summary>
public sealed class PathMapper
{
private readonly List<(string From, string To)> _mappings;
public PathMapper(IEnumerable<PathMapping> mappings)
{
_mappings = mappings
.Where(m => !string.IsNullOrWhiteSpace(m.From))
.Select(m => (Normalize(m.From), m.To))
.ToList();
}
private static string Normalize(string p) => p.Replace('\\', '/');
public string Map(string path)
{
if (string.IsNullOrEmpty(path)) return path;
var normalized = Normalize(path);
foreach (var (from, to) in _mappings)
{
if (normalized.StartsWith(from, StringComparison.OrdinalIgnoreCase))
{
var mapped = to + normalized[from.Length..];
return mapped.Replace('/', Path.DirectorySeparatorChar);
}
}
return path.Replace('/', Path.DirectorySeparatorChar);
}
}
+121
View File
@@ -0,0 +1,121 @@
using System.Net.Http.Headers;
namespace VFXReviewWorker;
/// <summary>
/// `VFXReviewWorker.exe --check` — install verification for a new machine.
/// Resolves every path and dependency the worker needs, reports what is
/// missing, and exits non-zero if anything would stop it running. Cheaper than
/// installing the service and reading logs to find a typo'd path.
/// </summary>
public static class Preflight
{
public static async Task<int> RunAsync(string? configPath)
{
var ok = true;
Console.WriteLine("VFXReview RenderWorker preflight");
Console.WriteLine();
var resolvedConfigPath = configPath ?? WorkerOptions.DefaultConfigPath;
WorkerOptions? options = null;
try
{
options = WorkerOptions.Load(configPath);
ok &= Report("config file", resolvedConfigPath, true);
}
catch (Exception ex)
{
Report("config file", resolvedConfigPath, false, ex.Message);
Console.WriteLine();
Console.WriteLine("Cannot continue without a config file. See RenderWorker/README.md.");
return 1;
}
Console.WriteLine($" worker version : {options.WorkerVersion}");
Console.WriteLine($" machine name : {options.MachineName}");
Console.WriteLine($" server : {options.ServerUrl}");
Console.WriteLine($" api key : {(string.IsNullOrWhiteSpace(options.ApiKey) ? "(missing)" : "set")}");
Console.WriteLine();
ok &= Report("aerender.exe", options.AerenderPath, File.Exists(options.AerenderPath));
// Not required: a machine without Blender simply never claims Blender
// jobs, so this reports rather than fails.
Report("blender.exe", options.BlenderPath ?? "(not configured)",
!string.IsNullOrWhiteSpace(options.BlenderPath) && File.Exists(options.BlenderPath),
"needed only for Blender render jobs");
var afterFx = options.ResolveAfterFxPath();
ok &= Report("AfterFX.com", afterFx, File.Exists(afterFx),
"needed for preview builds only");
var script = options.ResolvePreviewScriptPath();
ok &= Report("preview script", script, File.Exists(script),
"needed for preview builds only");
if (!string.IsNullOrWhiteSpace(options.FfmpegPath))
{
Report("ffmpeg", options.FfmpegPath!, File.Exists(options.FfmpegPath!),
"optional — thumbnails are skipped without it");
}
else
{
Console.WriteLine(" [ - ] ffmpeg not configured (thumbnails will be skipped)");
}
// Path mappings: prove the mapped roots are actually reachable here.
Console.WriteLine();
if (options.PathMappings.Count == 0)
{
Console.WriteLine(" [ ! ] no pathMappings configured — manifest UNC paths will be used as-is");
}
foreach (var m in options.PathMappings)
{
var reachable = Directory.Exists(m.To);
Report($"path map {m.From}", m.To, reachable, "target must be reachable from this machine");
ok &= reachable;
}
// Server reachability + API key validity in one call.
Console.WriteLine();
try
{
using var http = new HttpClient { BaseAddress = new Uri(options.ServerUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(15) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
var res = await http.GetAsync("api/ext/projects");
if (res.IsSuccessStatusCode)
{
Console.WriteLine(" [ok ] server reachable and API key accepted");
}
else if ((int)res.StatusCode == 401)
{
Console.WriteLine(" [FAIL] server reachable but API key was rejected (401)");
ok = false;
}
else
{
Console.WriteLine($" [FAIL] server responded {(int)res.StatusCode}");
ok = false;
}
}
catch (Exception ex)
{
Console.WriteLine($" [FAIL] cannot reach {options.ServerUrl}: {ex.Message}");
ok = false;
}
Console.WriteLine();
Console.WriteLine(ok
? "All checks passed — safe to install the service."
: "One or more checks FAILED — fix the above before installing.");
return ok ? 0 : 1;
}
private static bool Report(string label, string path, bool exists, string? note = null)
{
var status = exists ? "ok " : "FAIL";
Console.WriteLine($" [{status}] {label,-16} {path}");
if (!exists && note is not null)
{
Console.WriteLine($" ({note})");
}
return exists;
}
}
@@ -0,0 +1,440 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
public sealed record PreviewStageResult(
bool Success,
bool Retryable,
string? ErrorMessage,
string? MovPath,
string? Mp4Path,
string? ThumbnailPath,
string? TempAep,
PreviewBuildResult? Build,
double Seconds);
/// <summary>
/// Stage 3 — preview generation (RenderPipeline2 §9), the second half of a
/// one-click export.
///
/// Runs in two steps so the unproven part stays small:
/// 1. AfterFX.com -noui -r → builds the shot around the rendered EXRs using
/// the studio slate/overlay template, queues the MOV + MP4 output modules
/// and saves a throwaway AEP. Does no rendering.
/// 2. aerender -project &lt;that aep&gt; (no -comp) → renders the whole queue in
/// one launch, through the same proven runner as the EXR stage.
/// </summary>
public sealed class PreviewStage
{
private readonly WorkerOptions _options;
private readonly AerenderRunner _runner;
private readonly ILogger<PreviewStage> _log;
public PreviewStage(WorkerOptions options, AerenderRunner runner, ILogger<PreviewStage> log)
{
_options = options;
_runner = runner;
_log = log;
}
public async Task<PreviewStageResult> RunAsync(
ClaimedJob job,
PreviewManifest manifest,
string machineId,
ServerConfig config,
CancellationToken cancelJob,
CancellationToken shutdown)
{
var sw = Stopwatch.StartNew();
var mapper = new PathMapper(_options.PathMappings);
Directory.CreateDirectory(WorkerOptions.ScratchDir);
var contextPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_context.json");
var resultPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_result.json");
var wrapperPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_run.jsx");
var tempAep = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_preview.aep");
var movLocal = mapper.Map(manifest.MovOutput);
var mp4Local = mapper.Map(manifest.Mp4Output);
// Everything the script needs, in machine-local paths.
var context = new
{
jobId = job.Id,
exportId = manifest.ExportId,
shotCode = manifest.ShotCode,
versionString = manifest.VersionString,
outputDirLocal = mapper.Map(manifest.OutputDir),
templateAep = mapper.Map(manifest.TemplateAep),
templateComp = manifest.TemplateComp,
overlayComp = manifest.OverlayComp,
lutComp = manifest.LutComp,
movTemplate = manifest.MovTemplate,
mp4Template = manifest.Mp4Template,
movOutput = movLocal,
mp4Output = mp4Local,
tempAep,
resultPath,
frameStart = manifest.FrameStart,
fps = manifest.Fps,
slateScopeProp = manifest.SlateScopeProp,
slateSubmissionProp = manifest.SlateSubmissionProp,
slate = manifest.Slate,
};
await File.WriteAllTextAsync(contextPath, JsonSerializer.Serialize(context, WorkerOptions.JsonOpts), shutdown);
var scriptPath = _options.ResolvePreviewScriptPath();
if (!File.Exists(scriptPath))
{
return Fail($"Preview build script not found at {scriptPath}", retryable: false, sw);
}
// ExtendScript cannot read argv — hand the context path over through a
// generated wrapper that evaluates the shared build script.
var wrapper = new StringBuilder()
.AppendLine("// generated by VFXReviewWorker — do not edit")
.AppendLine($"var VFXR_CONTEXT_PATH = {JsonSerializer.Serialize(contextPath)};")
.AppendLine($"$.evalFile({JsonSerializer.Serialize(scriptPath)});")
.ToString();
await File.WriteAllTextAsync(wrapperPath, wrapper, shutdown);
TryDelete(resultPath);
// ── Step 1: headless build ───────────────────────────────────────────
var afterFx = _options.ResolveAfterFxPath();
if (!File.Exists(afterFx))
{
return Fail($"AfterFX.com not found at {afterFx}", retryable: false, sw);
}
// Belt and braces: the claim loop already avoids preview jobs while AE
// is open, but an artist may have launched it in between. Never take
// over a live session — defer instead (retryable).
if (AeSession.InteractiveRunning())
{
return Fail(
"An interactive After Effects session is open on this machine — preview build deferred to avoid taking it over",
retryable: true, sw);
}
_log.LogInformation("Building preview comp for job {JobId} via {AfterFx}", job.Id, afterFx);
var buildExit = await RunAfterFxAsync(afterFx, wrapperPath, config, cancelJob, shutdown);
if (!File.Exists(resultPath))
{
return Fail(
$"Headless AE produced no result file (exit {buildExit}). Check that AfterFX.com can run under this account.",
retryable: true, sw);
}
PreviewBuildResult? build;
try
{
build = JsonSerializer.Deserialize<PreviewBuildResult>(
await File.ReadAllTextAsync(resultPath, shutdown), WorkerOptions.JsonOpts);
}
catch (Exception ex)
{
return Fail($"Could not parse preview build result: {ex.Message}", retryable: false, sw);
}
if (build is null || !build.Ok)
{
// A build failure is deterministic (missing template comp, missing
// output module, bad layer name) — humans fix the template.
return Fail($"Preview build failed: {build?.Error ?? "unknown error"}", retryable: false, sw, build);
}
foreach (var w in build.Warnings)
{
_log.LogWarning("Preview build warning (job {JobId}): {Warning}", job.Id, w);
}
if (string.IsNullOrWhiteSpace(build.TempAep) || !File.Exists(build.TempAep))
{
return Fail("Preview build reported success but the temp project is missing", retryable: false, sw, build);
}
// ── Step 2: render the prepared queue ────────────────────────────────
var render = await _runner.RunProjectAsync(
job.Id, machineId, build.TempAep, build.FrameCount ?? manifest.TotalFrames,
config, cancelJob, shutdown);
if (!render.Success)
{
return new PreviewStageResult(false, render.Retryable, render.ErrorMessage,
null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds);
}
// aerender can exit 0 without writing — verify both outputs exist.
var missing = new List<string>();
if (!File.Exists(movLocal)) missing.Add(movLocal);
if (!File.Exists(mp4Local)) missing.Add(mp4Local);
if (missing.Count > 0)
{
return new PreviewStageResult(false, false,
"aerender exited 0 but expected preview outputs are missing: " + string.Join(", ", missing),
null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds);
}
var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, build, manifest.Fps, shutdown);
sw.Stop();
_log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})",
job.Id, sw.Elapsed.TotalSeconds, thumbnail is null ? "" : " + thumbnail");
return new PreviewStageResult(true, true, null, movLocal, mp4Local, thumbnail,
build.TempAep, build, sw.Elapsed.TotalSeconds);
}
private async Task<int> RunAfterFxAsync(
string afterFx, string wrapperPath, ServerConfig config,
CancellationToken cancelJob, CancellationToken shutdown)
{
var psi = new ProcessStartInfo
{
FileName = afterFx,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-noui");
psi.ArgumentList.Add("-r");
psi.ArgumentList.Add(wrapperPath);
using var process = new Process { StartInfo = psi };
process.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) _log.LogDebug("[AfterFX] {Line}", e.Data); };
process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) _log.LogDebug("[AfterFX] {Line}", e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
// The build does no rendering, so it should finish in well under the
// stall timeout. A hang here means AE is waiting on something (a dialog,
// a licence prompt) and must be killed rather than left holding the job.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancelJob, shutdown);
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(120, config.StallTimeoutSeconds)));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
_log.LogError("Headless AE build timed out or was cancelled — killing process tree");
try { process.Kill(entireProcessTree: true); } catch { }
return -1;
}
return process.ExitCode;
}
/// <summary>
/// Optional 960-wide poster frame at 25% of <em>picture</em> duration
/// (§9.1). Never fatal.
/// </summary>
/// <remarks>
/// The preview MP4 opens on the Netflix slate, so the frame has to be taken
/// relative to picture rather than to the file. This previously used
/// ffmpeg's <c>thumbnail</c> filter, which considers only the first 100
/// frames — on a slated preview that is the slate card, which is what every
/// thumbnail in the system was until now. Seeking to
/// <c>pictureStart + 25%</c> matches what §9.1 always documented and is what
/// makes these frames usable as shot thumbnails.
///
/// A build that reports no picture start (older worker, or a template with
/// no SHOT layer) keeps the old whole-file behaviour rather than guessing.
/// </remarks>
private async Task<string?> TryMakeThumbnailAsync(
string jobId, string mp4Path, PreviewBuildResult? build, double fps, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_options.FfmpegPath))
{
_log.LogWarning(
"No thumbnail for job {JobId}: ffmpegPath is not set in config.json. " +
"Thumbnails are skipped, and shots cannot take a frame from this render.",
jobId);
return null;
}
if (!File.Exists(_options.FfmpegPath))
{
_log.LogWarning(
"No thumbnail for job {JobId}: ffmpegPath points at {Path}, which does not exist.",
jobId, _options.FfmpegPath);
return null;
}
var outPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg");
var seek = ThumbnailSeekSeconds(build, fps);
if (seek is { } t)
{
_log.LogInformation(
"Thumbnail for job {JobId}: seeking to {Seek:F2}s (slate {Start:F2}s + 25% of {Frames} frames @ {Fps}fps)",
jobId, t, build!.PictureStartSeconds, build.FrameCount, fps);
var attempt = await RunFfmpegThumbnailAsync(jobId, mp4Path, outPath, t, ct);
if (attempt == ThumbAttempt.Wrote) return outPath;
// A hung or unstartable ffmpeg will hang the same way a second
// time, and each attempt costs the full two-minute cap.
if (attempt == ThumbAttempt.Broken) return null;
// The seek is derived from what the AE build reported; if those
// numbers disagree with the rendered file, ffmpeg lands past the
// end and writes nothing. Falling back to the whole-file scan means
// a wrong seek costs picture content, never the thumbnail itself.
_log.LogWarning(
"Thumbnail seek to {Seek:F2}s produced nothing for job {JobId} — retrying across the whole file. " +
"The frame will likely be the slate.", t, jobId);
}
else
{
_log.LogInformation(
"Thumbnail for job {JobId}: build reported no picture start, scanning the whole file " +
"(the frame will likely be the slate).", jobId);
}
if (await RunFfmpegThumbnailAsync(jobId, mp4Path, outPath, null, ct) == ThumbAttempt.Wrote) return outPath;
_log.LogWarning("No thumbnail produced for job {JobId} — see the ffmpeg output above.", jobId);
return null;
}
/// <summary>How one ffmpeg attempt ended.</summary>
private enum ThumbAttempt
{
/// <summary>A thumbnail was written.</summary>
Wrote,
/// <summary>ffmpeg ran and wrote nothing — a different attempt may work.</summary>
NoOutput,
/// <summary>ffmpeg hung or could not start — retrying costs minutes and fixes nothing.</summary>
Broken,
}
/// <summary>
/// One ffmpeg invocation. <paramref name="seekSeconds"/> null means the old
/// whole-file <c>thumbnail</c> filter. Logs ffmpeg's own diagnosis when no
/// file is produced.
/// </summary>
private async Task<ThumbAttempt> RunFfmpegThumbnailAsync(
string jobId, string mp4Path, string outPath, double? seekSeconds, CancellationToken ct)
{
TryDelete(outPath);
var psi = new ProcessStartInfo
{
FileName = _options.FfmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var args = new List<string> { "-y" };
if (seekSeconds is { } t)
{
// Before -i: ffmpeg seeks by keyframe, which is fast and accurate
// enough for a poster frame.
args.Add("-ss");
args.Add(t.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture));
args.Add("-i");
args.Add(mp4Path);
args.Add("-vf");
args.Add("scale=960:-1");
}
else
{
args.Add("-i");
args.Add(mp4Path);
args.Add("-vf");
args.Add("thumbnail,scale=960:-1");
}
args.Add("-frames:v");
args.Add("1");
args.Add(outPath);
foreach (var a in args) psi.ArgumentList.Add(a);
try
{
using var process = Process.Start(psi);
if (process is null)
{
_log.LogWarning("Could not start ffmpeg at {Path} for job {JobId}", _options.FfmpegPath, jobId);
return ThumbAttempt.Broken;
}
// Drain BOTH pipes while the process runs. Windows gives an
// anonymous pipe a ~4 KB buffer; ffmpeg's banner and stream summary
// exceed that on their own, so a redirected-but-unread pipe fills,
// ffmpeg blocks forever on the write, and the wait below times out
// with the process still alive. That is a deadlock, not a slow
// encode, and it is why this step failed with nothing but a
// cancellation to show for it.
var stderrTask = process.StandardError.ReadToEndAsync();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromMinutes(2));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Our own 2-minute cap, not a shutdown: leaving ffmpeg running
// would hold the scratch file open and leak a process per job.
_log.LogWarning(
"ffmpeg timed out after 2 minutes for job {JobId} — killing it. Command: {Args}",
jobId, string.Join(" ", args));
try { process.Kill(entireProcessTree: true); } catch { }
return ThumbAttempt.Broken;
}
var stderr = await stderrTask;
await stdoutTask;
if (File.Exists(outPath) && new FileInfo(outPath).Length > 0) return ThumbAttempt.Wrote;
_log.LogWarning(
"ffmpeg wrote no thumbnail for job {JobId} (exit {Exit}). Command: {Args} -- ffmpeg said: {Stderr}",
jobId, process.ExitCode, string.Join(" ", args), Tail(stderr, 1200));
return ThumbAttempt.NoOutput;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Thumbnail generation failed for job {JobId} (non-fatal)", jobId);
return ThumbAttempt.Broken;
}
}
/// <summary>Last <paramref name="max"/> characters — ffmpeg puts the error at the end.</summary>
private static string Tail(string? text, int max) =>
string.IsNullOrEmpty(text) ? "(no output)"
: text.Length <= max ? text
: text[^max..];
/// <summary>
/// Timestamp for the poster frame: a quarter of the way into picture,
/// skipping the slate. Null when the build did not report where picture
/// starts, or when the numbers do not describe a real clip.
/// </summary>
internal static double? ThumbnailSeekSeconds(PreviewBuildResult? build, double fps)
{
if (build?.PictureStartSeconds is not { } start || start < 0) return null;
if (build.FrameCount is not { } frames || frames <= 0) return null;
if (fps <= 0) return null;
return start + (frames / fps) * 0.25;
}
private static PreviewStageResult Fail(string message, bool retryable, Stopwatch sw, PreviewBuildResult? build = null)
{
sw.Stop();
return new PreviewStageResult(false, retryable, message, null, null, null, build?.TempAep, build, sw.Elapsed.TotalSeconds);
}
internal static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
}
+49
View File
@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VFXReviewWorker;
// VFXReview RenderWorker (RenderPipeline2 §7) — Windows service driving
// aerender.exe on artist workstations / render nodes. All state flows through
// /api/ext/* with API-key auth; the worker never touches the database.
//
// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json]
// Install as a service: see RenderWorker/README.md
// Dry-run the validation engine over an existing render folder. No server, no
// config, no queue — useful for checking work that predates the pipeline.
if (args.Any(a => a.Equals("--validate", StringComparison.OrdinalIgnoreCase)))
{
return ValidateCommand.Run(args);
}
var configPath = args.FirstOrDefault(a => !a.StartsWith('-'));
// Install verification on a new machine — resolves paths, checks tools and
// pings the server, then exits. Never starts the service loop.
if (args.Any(a => a.Equals("--check", StringComparison.OrdinalIgnoreCase)))
{
return await Preflight.RunAsync(configPath);
}
var options = WorkerOptions.Load(configPath);
// Positional args (config path) are consumed above and deliberately not
// forwarded — the command-line configuration provider rejects bare tokens.
var builder = Host.CreateApplicationBuilder();
builder.Services.AddWindowsService(o => o.ServiceName = "VFXReviewRenderWorker");
builder.Logging.AddProvider(new FileLoggerProvider());
builder.Services.AddSingleton(options);
builder.Services.AddSingleton<ApiClient>();
builder.Services.AddSingleton<ReportSpooler>();
builder.Services.AddSingleton<AerenderRunner>();
builder.Services.AddSingleton<PreviewStage>();
builder.Services.AddSingleton<ValidationStage>();
builder.Services.AddSingleton<DeliveryStage>();
builder.Services.AddSingleton<BlenderRunner>();
builder.Services.AddSingleton<JobExecutor>();
builder.Services.AddHostedService<WorkerService>();
await builder.Build().RunAsync();
return 0;
@@ -0,0 +1,80 @@
using System.Text.RegularExpressions;
namespace VFXReviewWorker;
/// <summary>
/// Parses aerender stdout line-by-line (§7.3): PROGRESS lines yield the
/// current frame; ERROR lines are collected; deterministic errors (missing
/// footage/comp/project) mark the job non-retryable — humans fix the comp.
/// </summary>
public static partial class ProgressParser
{
// e.g. "PROGRESS: 0:00:02:03 (51): 0 Seconds" / "PROGRESS: 2 (2): ..."
[GeneratedRegex(@"^PROGRESS:.*\((\d+)\)", RegexOptions.Compiled)]
private static partial Regex ProgressLine();
[GeneratedRegex(@"aerender\s+(SYNTAX\s+)?ERROR|^ERROR:", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex ErrorLine();
private static readonly string[] NonRetryablePatterns =
{
"missing footage",
"layer source file missing",
"file is missing",
"no comp was found",
"no composition",
"project file could not be opened",
"can not be opened",
"cannot be opened",
"the file format module could not parse the file",
"no render settings template",
"no output module template",
"syntax error",
"illegal argument",
};
/// <summary>Returns the rendered-frame ordinal (1-based within the range) or null.</summary>
public static int? ParseFrame(string line)
{
var m = ProgressLine().Match(line);
return m.Success && int.TryParse(m.Groups[1].Value, out var f) ? f : null;
}
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line);
/// <summary>Deterministic failures re-render identically — don't burn retries on them.</summary>
public static bool IsNonRetryableError(string line)
{
foreach (var p in NonRetryablePatterns)
{
if (line.Contains(p, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}
/// <summary>ETA = rolling average seconds/frame × frames remaining (§7.3).</summary>
public sealed class EtaCalculator
{
private readonly int _windowSize;
private readonly Queue<double> _frameSeconds = new();
private DateTimeOffset? _lastFrameAt;
public EtaCalculator(int windowSize = 10) => _windowSize = windowSize;
public void RecordFrame(DateTimeOffset now)
{
if (_lastFrameAt is { } last)
{
_frameSeconds.Enqueue((now - last).TotalSeconds);
while (_frameSeconds.Count > _windowSize) _frameSeconds.Dequeue();
}
_lastFrameAt = now;
}
public int? EstimateSeconds(int framesRemaining)
{
if (_frameSeconds.Count == 0 || framesRemaining <= 0) return null;
return (int)Math.Round(_frameSeconds.Average() * framesRemaining);
}
}
@@ -0,0 +1,96 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Durable lifecycle reports (§7.6): fail/complete reports are written to the
/// spool directory before the first send attempt and replayed strictly in
/// order with exponential backoff (1 s → 60 s). Rendering continues during
/// server downtime; a report is deleted only once the server accepts it (or
/// permanently rejects it as contradictory).
///
/// Strict ordering is load-bearing beyond durability: the validation report
/// (E12) is only legal once complete-render (E11) has moved the Export to
/// VALIDATING, and in-order replay is what guarantees that, with no need for
/// the worker to wait on an ACK.
/// </summary>
public sealed class ReportSpooler
{
private readonly ApiClient _api;
private readonly ILogger<ReportSpooler> _log;
private readonly string _dir;
private readonly SemaphoreSlim _wake = new(0);
private long _seq;
public ReportSpooler(ApiClient api, ILogger<ReportSpooler> log, string? dir = null)
{
_api = api;
_log = log;
_dir = dir ?? WorkerOptions.SpoolDir;
Directory.CreateDirectory(_dir);
}
public void Enqueue(string kind, string jobId, Dictionary<string, object?> payload)
{
var report = new SpooledReport { Kind = kind, JobId = jobId, Payload = payload };
var name = $"{DateTimeOffset.UtcNow.UtcTicks:D20}_{Interlocked.Increment(ref _seq):D6}.json";
var tmp = Path.Combine(_dir, name + ".tmp");
var final = Path.Combine(_dir, name);
File.WriteAllText(tmp, JsonSerializer.Serialize(report, WorkerOptions.JsonOpts));
File.Move(tmp, final);
_wake.Release();
}
public bool HasPending => Directory.EnumerateFiles(_dir, "*.json").Any();
/// <summary>Long-running replay loop; started once by the worker service.</summary>
public async Task RunAsync(CancellationToken ct)
{
var backoff = TimeSpan.FromSeconds(1);
while (!ct.IsCancellationRequested)
{
var next = Directory.EnumerateFiles(_dir, "*.json").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault();
if (next is null)
{
try { await _wake.WaitAsync(TimeSpan.FromSeconds(5), ct); } catch (OperationCanceledException) { break; }
continue;
}
bool done;
try
{
var report = JsonSerializer.Deserialize<SpooledReport>(File.ReadAllText(next), WorkerOptions.JsonOpts);
if (report is null || string.IsNullOrEmpty(report.JobId))
{
_log.LogWarning("Discarding unreadable spool file {File}", next);
done = true;
}
else
{
done = await _api.SendLifecycleAsync(report.Kind, report.JobId, report.Payload, ct);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Spool replay attempt failed for {File}", next);
done = false;
}
if (done)
{
try { File.Delete(next); } catch { /* re-sent next pass; server side is idempotent */ }
backoff = TimeSpan.FromSeconds(1);
}
else
{
try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { break; }
backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60));
}
}
}
}
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>VFXReviewWorker</RootNamespace>
<AssemblyName>VFXReviewWorker</AssemblyName>
<!-- Single self-contained exe for render nodes: dotnet publish -r win-x64 (see README) -->
<InvariantGlobalization>true</InvariantGlobalization>
<!--
The version is a property of the build, not of the machine it lands on:
it is stamped here and read back from the assembly at runtime, so a
workstation cannot report a version it is not actually running. Bump on
every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b".
-->
<Version>1.6.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="VFXReviewWorker.Tests" />
</ItemGroup>
<ItemGroup>
<!--
Headless AE build script ships beside the exe. CopyToPublishDirectory is
set explicitly: CopyToOutputDirectory alone governs the build folder, and
an incremental publish then leaves the script out of a freshly-cleaned
publish directory. publish.ps1 copies it again and verifies, because the
exe cannot run a preview job without it.
-->
<Content Include="..\scripts\vfxr_build_preview.jsx" Link="scripts\vfxr_build_preview.jsx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace VFXReviewWorker;
/// <summary>
/// `VFXReviewWorker.exe --validate &lt;renderDir&gt; [--plate &lt;plateFile&gt;] [--fps 24]`
///
/// Runs the validation engine (§8) over a folder of already-rendered EXRs and
/// prints the report, without a server, a job or a queue. Two uses: proving the
/// checks against real work before the stage goes live, and answering "is this
/// delivery good?" for a sequence that predates the pipeline.
/// </summary>
public static class ValidateCommand
{
public static int Run(string[] args)
{
var dir = args.SkipWhile(a => !a.Equals("--validate", StringComparison.OrdinalIgnoreCase)).Skip(1).FirstOrDefault();
if (string.IsNullOrWhiteSpace(dir) || dir.StartsWith('-'))
{
Console.Error.WriteLine("Usage: VFXReviewWorker.exe --validate <renderDir> [--plate <plateFirstFrame.exr>] [--fps 24] [--sample 25]");
return 2;
}
if (!Directory.Exists(dir))
{
Console.Error.WriteLine($"Not a directory: {dir}");
return 2;
}
var plate = ArgValue(args, "--plate");
var fps = double.TryParse(ArgValue(args, "--fps"), out var f) ? f : 24;
var sample = int.TryParse(ArgValue(args, "--sample"), out var s) ? s : 25;
var files = Directory.EnumerateFiles(dir, "*.exr").OrderBy(x => x, StringComparer.Ordinal).ToList();
if (files.Count == 0)
{
Console.Error.WriteLine($"No .exr files in {dir}");
return 2;
}
// Infer what the manifest would have said from the files themselves.
var firstName = Path.GetFileName(files[0]);
var frames = files.Select(x => SequencePattern.FrameFromName(Path.GetFileName(x))).Where(x => x is not null).Select(x => x!.Value).ToList();
var prefix = firstName.Contains('.') ? firstName[..firstName.IndexOf('.')] : Path.GetFileNameWithoutExtension(firstName);
var header = ExrHeader.TryRead(files[0]);
var manifest = new RenderManifest
{
ShotCode = prefix.Split("_cmp_")[0],
OutputDir = dir,
OutputPattern = $"{prefix}.[#####].exr",
FrameStart = frames.Count > 0 ? frames.Min() : 1,
FrameEnd = frames.Count > 0 ? frames.Max() : files.Count,
Fps = fps,
Width = header?.DataWidth ?? 0,
Height = header?.DataHeight ?? 0,
Source = plate is null ? null : new ManifestSource
{
TimingPlate = new PlateRef { Path = plate, FrameStart = SequencePattern.FrameFromName(Path.GetFileName(plate)) },
StartSource = "supplied on the command line",
},
};
var options = new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" };
var stage = new ValidationStage(options, NullLogger<ValidationStage>.Instance);
var outcome = stage.Run(manifest, dir, new ServerConfig { ValidationSampleEvery = sample });
Console.WriteLine();
Console.WriteLine($"{files.Count} files in {dir}");
Console.WriteLine($"inferred range {manifest.FrameStart}-{manifest.FrameEnd} at {fps} fps, pattern {manifest.OutputPattern}");
if (plate is not null) Console.WriteLine($"timing plate {plate}");
Console.WriteLine();
foreach (var c in outcome.Checks)
{
var mark = c.Status switch { "PASS" => " ok ", "FAIL" => " FAIL ", "WARN" => " warn ", _ => " skip " };
Console.WriteLine($"[{mark}] {c.CheckName,-24} {Describe(c)}");
}
Console.WriteLine();
Console.WriteLine($"checksum (xxh64): {outcome.Checksum ?? "(not computed)"}");
var failed = outcome.Checks.Count(c => c.Status == "FAIL");
Console.WriteLine(failed == 0 ? "PASSED" : $"FAILED — {failed} check(s)");
return failed == 0 ? 0 : 1;
}
private static string Describe(ValidationCheck c)
{
if (!string.IsNullOrWhiteSpace(c.Message)) return c.Message!;
if (c.Expected is null && c.Actual is null) return "";
return c.Expected == c.Actual ? c.Actual ?? "" : $"expected {c.Expected}, got {c.Actual}";
}
private static string? ArgValue(string[] args, string name)
{
var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
}
@@ -0,0 +1,659 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Validation engine (RenderPipeline2 §8). Runs on the worker straight after a
/// successful render, while it still holds the job and the files are local.
/// It only ever *reports* — the server decides what the results mean.
///
/// Full-sequence checks (count, gaps, names, sizes) touch every file; header
/// checks sample first/middle/last plus every Nth frame.
/// </summary>
public sealed class ValidationStage
{
private readonly WorkerOptions _options;
private readonly ILogger<ValidationStage> _log;
public ValidationStage(WorkerOptions options, ILogger<ValidationStage> log)
{
_options = options;
_log = log;
}
public ValidationOutcome Run(RenderManifest manifest, string outputDirLocal, ServerConfig config)
{
var checks = new List<ValidationCheck>();
var files = EnumerateSequence(manifest, outputDirLocal, checks, out var frameNumbers, out var totalBytes);
if (files.Count == 0)
{
// Nothing to inspect: the sequence checks already recorded the FAIL.
return new ValidationOutcome(checks, 0, 0, null);
}
var sampleEvery = Math.Max(1, config.ValidationSampleEvery);
var sampled = PickSamples(files, sampleEvery);
var headers = ReadHeaders(sampled, checks);
if (headers.Count > 0)
{
CheckResolution(manifest, headers, checks);
CheckBitDepthAndChannels(manifest, headers, checks);
CheckCompression(manifest, headers, checks);
CheckTimecode(manifest, headers, frameNumbers, config, checks);
}
var checksum = ComputeSequenceChecksum(files);
return new ValidationOutcome(checks, files.Count, totalBytes, checksum);
}
// ── Sequence-level checks ────────────────────────────────────────────────
private List<string> EnumerateSequence(
RenderManifest manifest, string outputDirLocal, List<ValidationCheck> checks,
out List<int> frameNumbers, out long totalBytes)
{
frameNumbers = new List<int>();
totalBytes = 0;
var expectedCount = manifest.TotalFrames;
if (!Directory.Exists(outputDirLocal))
{
checks.Add(ValidationCheck.Fail("file_count", expectedCount.ToString(), "0",
$"Output directory does not exist on this machine: {outputDirLocal}"));
return new List<string>();
}
var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern);
var matched = new List<(int Frame, string Path)>();
var nonConforming = new List<string>();
foreach (var path in Directory.EnumerateFiles(outputDirLocal))
{
var name = Path.GetFileName(path);
if (!name.EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase)) continue;
// Only files belonging to this render: a version folder can also hold
// the preview MOV/MP4, which are not part of the EXR sequence.
if (!name.StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)) continue;
var frame = pattern.FrameNumberOf(name);
if (frame is null) { nonConforming.Add(name); continue; }
matched.Add((frame.Value, path));
}
matched.Sort((a, b) => a.Frame.CompareTo(b.Frame));
var files = matched.Select(m => m.Path).ToList();
frameNumbers = matched.Select(m => m.Frame).ToList();
// file_count
checks.Add(files.Count == expectedCount
? ValidationCheck.Pass("file_count", expectedCount.ToString(), files.Count.ToString())
: ValidationCheck.Fail("file_count", expectedCount.ToString(), files.Count.ToString(),
$"Expected {expectedCount} frames ({manifest.FrameStart}-{manifest.FrameEnd}), found {files.Count} in {outputDirLocal}"));
// filename_consistency
checks.Add(nonConforming.Count == 0
? ValidationCheck.Pass("filename_consistency", pattern.Describe(), "all files match")
: ValidationCheck.Fail("filename_consistency", pattern.Describe(), $"{nonConforming.Count} nonconforming",
"Files not matching the output pattern: " + string.Join(", ", nonConforming.Take(10)),
new { files = nonConforming.Take(50) }));
if (files.Count == 0) return files;
// Output files are numbered by the output module's starting number
// (house convention: every sequence starts at 1001), which is a
// different coordinate system from the comp's frame range — a comp
// anchored to the plate's timecode starts at 1247792. Checking file
// numbers against comp frames compares the two and fails every render,
// so the sequence is checked for the right *count* and *contiguity*,
// and its absolute start only where the manifest states it.
var expectedFirst = manifest.OutputFrameStart;
var seen = new HashSet<int>();
var duplicates = new List<int>();
foreach (var f in frameNumbers)
{
if (!seen.Add(f)) duplicates.Add(f);
}
// Gaps are found relative to the run the render actually produced: a
// hole in the middle is a real defect regardless of where it starts.
var runStart = expectedFirst ?? frameNumbers[0];
var runEnd = runStart + expectedCount - 1;
var missing = new List<int>();
for (var f = runStart; f <= runEnd; f++)
{
if (!seen.Contains(f)) missing.Add(f);
}
checks.Add(missing.Count == 0
? ValidationCheck.Pass("missing_frames", "none", "none")
: ValidationCheck.Fail("missing_frames", "none", $"{missing.Count} missing",
$"Missing frames in {runStart}-{runEnd}: " + Summarise(missing),
new { missing = missing.Take(500), runStart, runEnd }));
checks.Add(duplicates.Count == 0
? ValidationCheck.Pass("duplicate_frames", "none", "none")
: ValidationCheck.Fail("duplicate_frames", "none", $"{duplicates.Count} duplicated",
"Duplicate frame numbers (differing padding?): " + Summarise(duplicates),
new { duplicates = duplicates.Take(100) }));
if (expectedFirst is int first)
{
checks.Add(frameNumbers[0] == first
? ValidationCheck.Pass("first_frame", first.ToString(), frameNumbers[0].ToString())
: ValidationCheck.Fail("first_frame", first.ToString(), frameNumbers[0].ToString(),
$"Sequence starts at {frameNumbers[0]}, expected {first}"));
var expectedLast = first + expectedCount - 1;
checks.Add(frameNumbers[^1] == expectedLast
? ValidationCheck.Pass("last_frame", expectedLast.ToString(), frameNumbers[^1].ToString())
: ValidationCheck.Fail("last_frame", expectedLast.ToString(), frameNumbers[^1].ToString(),
$"Sequence ends at {frameNumbers[^1]}, expected {expectedLast}"));
}
else
{
// Old manifests carry no output numbering; the count and contiguity
// checks above still hold, so the run is reported rather than
// measured against a number that was never sent.
checks.Add(ValidationCheck.Skipped("first_frame",
$"manifest carries no output frame numbering — sequence runs {frameNumbers[0]}-{frameNumbers[^1]}"));
}
// zero_byte — every file, it is only a directory stat
var zeroByte = new List<string>();
foreach (var path in files)
{
var len = new FileInfo(path).Length;
totalBytes += len;
if (len == 0) zeroByte.Add(Path.GetFileName(path));
}
checks.Add(zeroByte.Count == 0
? ValidationCheck.Pass("zero_byte", "none", "none")
: ValidationCheck.Fail("zero_byte", "none", $"{zeroByte.Count} empty",
"Zero-byte files: " + string.Join(", ", zeroByte.Take(10)), new { files = zeroByte.Take(50) }));
return files;
}
// ── Header checks (sampled) ──────────────────────────────────────────────
private static List<string> PickSamples(List<string> files, int sampleEvery)
{
var picks = new SortedSet<int> { 0, files.Count / 2, files.Count - 1 };
for (var i = 0; i < files.Count; i += sampleEvery) picks.Add(i);
return picks.Where(i => i >= 0 && i < files.Count).Select(i => files[i]).ToList();
}
private List<(string Path, ExrHeader Header)> ReadHeaders(List<string> sampled, List<ValidationCheck> checks)
{
var headers = new List<(string, ExrHeader)>();
var unreadable = new List<string>();
foreach (var path in sampled)
{
try
{
headers.Add((path, ExrHeader.Read(path)));
}
catch (Exception ex)
{
unreadable.Add($"{Path.GetFileName(path)}: {ex.Message}");
_log.LogWarning("Unreadable EXR header: {Path} ({Error})", path, ex.Message);
}
}
checks.Add(unreadable.Count == 0
? ValidationCheck.Pass("corrupt", "all sampled frames readable", $"{headers.Count} sampled")
: ValidationCheck.Fail("corrupt", "all sampled frames readable", $"{unreadable.Count} unreadable",
"Unreadable EXR headers: " + string.Join("; ", unreadable.Take(5)), new { files = unreadable.Take(50) }));
return headers;
}
private static void CheckResolution(RenderManifest m, List<(string Path, ExrHeader H)> headers, List<ValidationCheck> checks)
{
if (m.Width <= 0 || m.Height <= 0)
{
checks.Add(ValidationCheck.Skipped("resolution", "manifest carries no expected resolution"));
return;
}
var expected = $"{m.Width}x{m.Height}";
var bad = headers.Where(h => h.H.DataWidth != m.Width || h.H.DataHeight != m.Height).ToList();
checks.Add(bad.Count == 0
? ValidationCheck.Pass("resolution", expected, expected)
: ValidationCheck.Fail("resolution", expected, $"{bad[0].H.DataWidth}x{bad[0].H.DataHeight}",
$"{bad.Count} of {headers.Count} sampled frames are not {expected} (first: {Path.GetFileName(bad[0].Path)})"));
}
private static void CheckBitDepthAndChannels(RenderManifest m, List<(string Path, ExrHeader H)> headers, List<ValidationCheck> checks)
{
var expectedDepth = m.Expected?.BitDepth;
var actualDepth = headers[0].H.BitDepth ?? "unknown";
if (string.IsNullOrWhiteSpace(expectedDepth))
{
checks.Add(ValidationCheck.Skipped("bit_depth", $"no expected bit depth configured (rendered {actualDepth})"));
}
else
{
var mismatched = headers.Where(h => !string.Equals(h.H.BitDepth, expectedDepth, StringComparison.OrdinalIgnoreCase)).ToList();
checks.Add(mismatched.Count == 0
? ValidationCheck.Pass("bit_depth", expectedDepth, actualDepth)
: ValidationCheck.Fail("bit_depth", expectedDepth, mismatched[0].H.BitDepth ?? "unknown",
$"{mismatched.Count} of {headers.Count} sampled frames are not {expectedDepth}"));
}
var hasAlpha = headers[0].H.HasAlpha;
var channelList = string.Join(",", headers[0].H.Channels.Select(c => c.Name));
if (m.Expected?.Alpha is null)
{
checks.Add(ValidationCheck.Skipped("alpha_channel", $"no expectation configured (channels: {channelList})"));
}
else
{
checks.Add(hasAlpha == m.Expected.Alpha.Value
? ValidationCheck.Pass("alpha_channel", m.Expected.Alpha.Value ? "alpha present" : "no alpha", channelList)
: ValidationCheck.Fail("alpha_channel",
m.Expected.Alpha.Value ? "alpha present" : "no alpha",
hasAlpha ? "alpha present" : "no alpha",
$"Channel list is {channelList}"));
}
}
private static void CheckCompression(RenderManifest m, List<(string Path, ExrHeader H)> headers, List<ValidationCheck> checks)
{
var actual = headers[0].H.CompressionName;
if (headers[0].H.CompressionCode is null)
{
checks.Add(ValidationCheck.Fail("exr_type", "readable EXR", "no compression attribute",
"Sampled frame has no compression attribute — not a valid EXR header"));
return;
}
var expected = m.Expected?.ExrCompression;
if (string.IsNullOrWhiteSpace(expected))
{
checks.Add(ValidationCheck.Pass("exr_type", "valid EXR", $"{actual}{(headers[0].H.IsTiled ? ", tiled" : ", scanline")}"));
return;
}
// Compression differing but readable is a WARN, never a stop (§8.1).
checks.Add(string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)
? ValidationCheck.Pass("exr_type", expected, actual)
: ValidationCheck.Warn("exr_type", expected, actual,
$"Compression is {actual}, expected {expected} — readable, so not blocking"));
}
// ── Timecode: the primary check (§8.1a) ──────────────────────────────────
private void CheckTimecode(
RenderManifest m, List<(string Path, ExrHeader H)> headers, List<int> frameNumbers,
ServerConfig config, List<ValidationCheck> checks)
{
var fps = m.Fps > 0 ? m.Fps : 24;
var first = headers[0];
if (first.H.Timecode is null)
{
// AE's EXR output module writes timeCode; its absence means the
// output module changed, so this is a stop rather than a skip.
checks.Add(ValidationCheck.Fail("timecode_plate_match", "timecode present", "absent",
$"No timeCode attribute in {Path.GetFileName(first.Path)} — the EXR output module no longer writes timecode",
new { attributes = first.H.AttributeNames.Take(40) }));
return;
}
var renderTc = first.H.Timecode.Value;
var renderFirstFrame = frameNumbers.Count > 0 ? frameNumbers[0] : m.FrameStart;
// timecode_consistency — sampled frames must advance exactly one frame
// each. A constant value is the frame-derived-timecode defect.
var drift = new List<string>();
for (var i = 0; i < headers.Count; i++)
{
var h = headers[i].H;
if (h.Timecode is null) { drift.Add($"{Path.GetFileName(headers[i].Path)}: no timecode"); continue; }
var frameNo = SequencePattern.FrameFromName(Path.GetFileName(headers[i].Path)) ?? (renderFirstFrame + i);
var expectedTc = Timecode.FromFrames(renderTc.ToFrames(fps) + (frameNo - renderFirstFrame), fps);
if (h.Timecode.Value.ToFrames(fps) != expectedTc.ToFrames(fps))
{
drift.Add($"frame {frameNo}: {h.Timecode.Value} (expected {expectedTc})");
}
}
checks.Add(drift.Count == 0
? ValidationCheck.Pass("timecode_consistency", "monotonic, 1 frame per frame", $"{headers.Count} frames sampled")
: ValidationCheck.Fail("timecode_consistency", "monotonic, 1 frame per frame", $"{drift.Count} sampled frames off",
"Timecode does not advance with the frames: " + string.Join("; ", drift.Take(5)),
new { drift = drift.Take(50) }));
// timecode_fps
if (first.H.FramesPerSecond is double headerFps)
{
checks.Add(Math.Abs(headerFps - fps) < 0.01
? ValidationCheck.Pass("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"))
: ValidationCheck.Fail("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"),
$"EXR framesPerSecond is {headerFps:0.###} but the comp rendered at {fps:0.###}"));
}
else
{
checks.Add(ValidationCheck.Skipped("timecode_fps", "no framesPerSecond attribute in the rendered EXRs"));
}
// timecode_plate_match — reference precedence, rank 1 → 3.
var reference = ResolveTimecodeReference(m, config, renderFirstFrame);
if (reference is null)
{
checks.Add(ValidationCheck.Warn("timecode_plate_match", "plate timecode", renderTc.ToString(),
"No plate reference available: the manifest carries no timing plate, none was found on disk, " +
"and no API timecode was supplied. Render timecode is unverified.",
new { renderTimecode = renderTc.ToString() }));
return;
}
// plate_fps — the render's rate against the plate's own.
//
// timecode_fps only compares the render to the manifest, and both come
// from the comp: a shot built at the wrong rate agrees with itself and
// passes. The plate is the independent witness. A 25 fps show whose
// shot records defaulted to 24 renders every shot at 24, and this is
// what names that rather than leaving it to surface as an off-by-N
// timecode error.
if (reference.PlateFps is double plateFps && first.H.FramesPerSecond is double renderFps)
{
var ratio = renderFps > 0 ? plateFps / renderFps : 0;
var overcrank = ratio > 1.5 && Math.Abs(ratio - Math.Round(ratio)) < 0.01;
checks.Add(Math.Abs(plateFps - renderFps) < 0.01
? ValidationCheck.Pass("plate_fps", plateFps.ToString("0.###"), renderFps.ToString("0.###"))
// Never a FAIL: a plate deliberately shot at a multiple of the
// show rate is a legitimate over-crank, conformed on purpose.
// Anything else is a rate mistake, and the wording says which.
: ValidationCheck.Warn("plate_fps", plateFps.ToString("0.###"), renderFps.ToString("0.###"),
overcrank
? $"The plate is {plateFps:0.###} fps against a {renderFps:0.###} fps render — a {Math.Round(ratio)}x over-crank, conformed as expected."
: $"The plate is {plateFps:0.###} fps but this rendered at {renderFps:0.###} — not a whole-number over-crank, so the comp was probably built at the wrong rate (check the shot's fps in the database).",
new { platePath = reference.PlatePath, reference = reference.Kind, ratio }));
}
var expectedFrames = reference.PlateTimecode.ToFrames(fps) + (renderFirstFrame - reference.PlateFirstFrame);
var expected = Timecode.FromFrames(expectedFrames, fps);
var actualFrames = renderTc.ToFrames(fps);
var deltaFrames = actualFrames - expectedFrames;
var details = new
{
reference = reference.Kind,
platePath = reference.PlatePath,
plateTimecode = reference.PlateTimecode.ToString(),
plateFirstFrame = reference.PlateFirstFrame,
renderFirstFrame,
deltaFrames,
};
if (deltaFrames == 0)
{
// The API fallback proves nothing on its own (§8.1a): it is the
// editorial in-point minus a handle count that may not be this
// plate's, so a match is reported as a WARN, never a PASS.
checks.Add(reference.Kind == "api"
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
"Matches the API timecode less handles, but the plate itself could not be read — unverified.", details)
: ValidationCheck.Pass("timecode_plate_match", expected.ToString(), renderTc.ToString(),
$"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}", details));
return;
}
var message =
$"Render starts at {renderTc} but the {reference.Kind} plate says it should be {expected} " +
$"({(deltaFrames > 0 ? "+" : "")}{deltaFrames} frames). " +
(Math.Abs(deltaFrames) == reference.HandleFrames && reference.HandleFrames > 0
? $"That is exactly the {reference.HandleFrames}-frame handle: the comp was anchored to the editorial in-point instead of the plate's first frame."
: renderTc.Hours == 0 && renderTc.Minutes == 0
? "A near-zero timecode means the comp had no source timecode at all and AE derived it from the frame number."
: "");
checks.Add(reference.Kind == "api"
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
message + " (checked against the API value, not the plate — treat as advisory)", details)
: ValidationCheck.Fail("timecode_plate_match", expected.ToString(), renderTc.ToString(), message, details));
}
/// <summary>
/// Reference precedence (§8.1a): manifest timing plate → plate found under a
/// configured search root → API timecode minus handles (advisory only).
/// </summary>
private TimecodeReference? ResolveTimecodeReference(RenderManifest m, ServerConfig config, int renderFirstFrame)
{
var mapper = new PathMapper(_options.PathMappings);
var handleFrames = m.Source?.HandleFrames ?? config.ValidationHandleFrames;
// Rank 1 — the plate the comp was actually built from.
var timing = m.Source?.TimingPlate;
if (timing is not null && !string.IsNullOrWhiteSpace(timing.Path))
{
var local = mapper.Map(timing.Path);
var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
if (header?.Timecode is not null)
{
// The plate's own file numbering, never the comp's frame range:
// the render's first frame is in output numbering, so anchoring
// on a comp frame skews the comparison by the gap between them.
var frame = SequencePattern.FrameFromName(Path.GetFileName(local))
?? timing.FrameStart
?? renderFirstFrame;
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
header.FramesPerSecond);
}
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", local);
}
// Rank 2 — derive the plate from a configured search root.
var derived = FindPlateOnDisk(m, mapper);
if (derived is not null) return derived with { HandleFrames = handleFrames };
// Rank 3 — the API's editorial in-point, less handles. Advisory only.
//
// Shot.timecodeStart is the *edit* in-point; the plate is delivered with
// handleFrames of head handle, so the first delivered frame sits that
// much earlier. The anchor is the render's own first frame, which is in
// the same numbering as the files on disk — not the manifest's frame
// range, which is the comp's timeline and a different coordinate system
// entirely (mixing the two produced a nonsense million-frame offset).
var apiTc = Timecode.Parse(m.Expected?.TimecodeStart);
if (apiTc is not null)
{
var fps = m.Fps > 0 ? m.Fps : 24;
var plateStart = Timecode.FromFrames(apiTc.Value.ToFrames(fps) - handleFrames, fps);
return new TimecodeReference("api", null, plateStart, renderFirstFrame, handleFrames);
}
return null;
}
/// <summary>
/// Rank 2: look for the shot's plate folders under the configured roots,
/// mirroring the panel's two on-disk layouts ({shotCode}/{element}/ and
/// {shotCode}_{element}/). Ambiguity is resolved by frame count; still
/// ambiguous means no reference rather than a guess.
/// </summary>
private TimecodeReference? FindPlateOnDisk(RenderManifest m, PathMapper mapper)
{
if (_options.PlateSearchRoots.Count == 0 || string.IsNullOrWhiteSpace(m.ShotCode)) return null;
var candidates = new List<(string Dir, int Count)>();
foreach (var rootRaw in _options.PlateSearchRoots)
{
var root = mapper.Map(rootRaw);
if (!Directory.Exists(root)) continue;
// The show/episode level is unknown here, so scan the root and one
// level below it — bounded, and enough for both layouts.
var levels = new List<string> { root };
try { levels.AddRange(Directory.EnumerateDirectories(root)); } catch { }
foreach (var level in levels)
{
try
{
var grouped = Path.Combine(level, m.ShotCode);
if (Directory.Exists(grouped))
{
foreach (var element in Directory.EnumerateDirectories(grouped))
{
AddCandidate(candidates, element);
}
}
foreach (var element in Directory.EnumerateDirectories(level, m.ShotCode + "_*"))
{
AddCandidate(candidates, element);
}
}
catch { /* unreadable branch — keep scanning the rest */ }
}
}
if (candidates.Count == 0) return null;
// Prefer the element whose frame count matches the render: that is the
// one the comp's timing came from.
var exact = candidates.Where(c => c.Count == m.TotalFrames).ToList();
(string Dir, int Count)? chosen =
exact.Count == 1 ? exact[0]
: exact.Count == 0 && candidates.Count == 1 ? candidates[0]
: null;
if (chosen is null)
{
_log.LogWarning("Plate reference ambiguous for {Shot}: {Candidates}",
m.ShotCode, string.Join(", ", candidates.Select(c => Path.GetFileName(c.Dir))));
return null;
}
var firstFile = Directory.EnumerateFiles(chosen.Value.Dir, "*.exr").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault();
if (firstFile is null) return null;
var header = ExrHeader.TryRead(firstFile);
if (header?.Timecode is null) return null;
var frame = SequencePattern.FrameFromName(Path.GetFileName(firstFile)) ?? m.FrameStart;
return new TimecodeReference("derived", firstFile, header.Timecode.Value, frame, 0, header.FramesPerSecond);
}
private static void AddCandidate(List<(string Dir, int Count)> candidates, string dir)
{
try
{
var count = Directory.EnumerateFiles(dir, "*.exr").Count();
if (count > 0) candidates.Add((dir, count));
}
catch { }
}
// ── Checksums ────────────────────────────────────────────────────────────
/// <summary>
/// Sequence digest = xxHash64 over the per-file digests in frame order
/// (§8.2). Reused by the delivery builder to verify what it linked/copied.
/// </summary>
private string? ComputeSequenceChecksum(List<string> files)
{
try
{
var state = new XxHash64.State(0);
foreach (var f in files)
{
var digest = XxHash64.HashFile(f);
state.Update(System.Text.Encoding.ASCII.GetBytes(digest));
}
return state.Digest().ToString("x16");
}
catch (Exception ex)
{
_log.LogWarning(ex, "Sequence checksum failed");
return null;
}
}
private static string Summarise(IEnumerable<int> numbers)
{
var list = numbers.Take(12).ToList();
var text = string.Join(", ", list);
return numbers.Count() > 12 ? text + ", …" : text;
}
}
public sealed record TimecodeReference(
string Kind, string? PlatePath, Timecode PlateTimecode, int PlateFirstFrame, int HandleFrames,
double? PlateFps = null);
public sealed record ValidationOutcome(
List<ValidationCheck> Checks, int FileCount, long TotalBytes, string? Checksum)
{
public bool HasFailure => Checks.Any(c => c.Status == "FAIL");
}
/// <summary>One row of the E12 report.</summary>
public sealed class ValidationCheck
{
public string CheckName { get; set; } = "";
public string Status { get; set; } = "PASS";
public string? Expected { get; set; }
public string? Actual { get; set; }
public string? Message { get; set; }
public object? Details { get; set; }
public static ValidationCheck Pass(string name, string? expected = null, string? actual = null, string? message = null, object? details = null) =>
new() { CheckName = name, Status = "PASS", Expected = expected, Actual = actual, Message = message, Details = details };
public static ValidationCheck Fail(string name, string? expected, string? actual, string message, object? details = null) =>
new() { CheckName = name, Status = "FAIL", Expected = expected, Actual = actual, Message = message, Details = details };
public static ValidationCheck Warn(string name, string? expected, string? actual, string message, object? details = null) =>
new() { CheckName = name, Status = "WARN", Expected = expected, Actual = actual, Message = message, Details = details };
public static ValidationCheck Skipped(string name, string message) =>
new() { CheckName = name, Status = "SKIPPED", Message = message };
}
/// <summary>
/// Parses `{base}.[#####].exr` output patterns and the filenames they produce.
/// </summary>
public sealed class SequencePattern
{
public string Prefix { get; private init; } = "";
public string Extension { get; private init; } = ".exr";
public int Padding { get; private init; }
// Delivered plates separate the frame number with a dot
// ("..._BG01_TT_v001.01001.exr"); AE-written renders use an underscore
// ("..._PRERENDER_01001.exr"). Both appear as layers in a shot comp, so
// both have to parse — a plate whose number cannot be read gets anchored on
// a fallback, and a wrong anchor silently skews the timecode check.
private static readonly Regex FrameInName = new(@"[._](\d{2,9})\.[A-Za-z0-9]+$", RegexOptions.Compiled);
public static SequencePattern FromOutputPattern(string outputPattern)
{
var pattern = outputPattern ?? "";
var ext = Path.GetExtension(pattern);
if (string.IsNullOrEmpty(ext)) ext = ".exr";
var hashStart = pattern.IndexOf('[');
var prefix = hashStart > 0 ? pattern[..hashStart].TrimEnd('.') : Path.GetFileNameWithoutExtension(pattern);
var padding = 0;
foreach (var ch in pattern)
{
if (ch == '#') padding++;
}
return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding };
}
public int? FrameNumberOf(string fileName) => FrameFromName(fileName);
/// <summary>`UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.</summary>
public static int? FrameFromName(string fileName)
{
var m = FrameInName.Match(fileName);
if (!m.Success) return null;
return int.TryParse(m.Groups[1].Value, out var n) ? n : null;
}
public string Describe() => $"{Prefix}.[{new string('#', Math.Max(1, Padding))}]{Extension}";
}
@@ -0,0 +1,103 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace VFXReviewWorker;
/// <summary>
/// Machine-local config (RenderPipeline2 §7.8) — deliberately minimal;
/// everything tunable lives in server SystemConfig and arrives via E6.
/// Default path: C:\ProgramData\VFXReviewWorker\config.json
/// </summary>
public sealed class WorkerOptions
{
public string ServerUrl { get; set; } = "";
public string ApiKey { get; set; } = "";
public string MachineName { get; set; } = Environment.MachineName;
public string AerenderPath { get; set; } = @"C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\aerender.exe";
/// <summary>AfterFX.com — the console AE used for the headless preview build. Defaults beside aerender.</summary>
public string? AfterFxPath { get; set; }
/// <summary>vfxr_build_preview.jsx — defaults to scripts\ beside the worker exe.</summary>
public string? PreviewScriptPath { get; set; }
public string? FfmpegPath { get; set; }
/// <summary>
/// blender.exe. Its presence is what makes this machine eligible for
/// Blender jobs: the version is reported at registration and the server
/// only hands BLENDER_RENDER work to machines that have it.
/// </summary>
public string? BlenderPath { get; set; }
public string? OiiotoolPath { get; set; }
public List<PathMapping> PathMappings { get; set; } = new();
/// <summary>
/// Optional footage roots searched for a shot's plates when a manifest
/// carries no timing plate (rank 2 of §8.1a) — e.g. ["V:/_FOOTAGE/UNG"].
/// Leave empty and old manifests simply fall through to the API fallback.
/// </summary>
public List<string> PlateSearchRoots { get; set; } = new();
/// <summary>
/// Read from the assembly, not from config.json: a hand-maintained version
/// string is exactly the thing that goes stale, and a machine reporting a
/// version it is not running is worse than no version at all.
/// Includes the git SHA when publish.ps1 stamped one ("1.1.0+feb1c7b").
/// </summary>
public string WorkerVersion =>
typeof(WorkerOptions).Assembly
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? typeof(WorkerOptions).Assembly.GetName().Version?.ToString()
?? "unknown";
public string? AeVersion { get; set; }
public static string DataDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "VFXReviewWorker");
public static string DefaultConfigPath => Path.Combine(DataDir, "config.json");
public static string LogsDir => Path.Combine(DataDir, "logs");
public static string SpoolDir => Path.Combine(DataDir, "spool");
public static string ScratchDir => Path.Combine(DataDir, "scratch");
public static string StateFilePath => Path.Combine(DataDir, "current-job.json");
/// <summary>AfterFX.com sits beside aerender.exe in a standard AE install.</summary>
public string ResolveAfterFxPath()
{
if (!string.IsNullOrWhiteSpace(AfterFxPath)) return AfterFxPath;
var dir = Path.GetDirectoryName(AerenderPath);
return dir is null ? "AfterFX.com" : Path.Combine(dir, "AfterFX.com");
}
public string ResolvePreviewScriptPath()
{
if (!string.IsNullOrWhiteSpace(PreviewScriptPath)) return PreviewScriptPath;
return Path.Combine(AppContext.BaseDirectory, "scripts", "vfxr_build_preview.jsx");
}
public static WorkerOptions Load(string? path = null)
{
path ??= DefaultConfigPath;
if (!File.Exists(path))
{
throw new FileNotFoundException(
$"Worker config not found at {path}. Create it with serverUrl, apiKey and machineName (see RenderWorker/README.md).");
}
var json = File.ReadAllText(path);
var opts = JsonSerializer.Deserialize<WorkerOptions>(json, JsonOpts)
?? throw new InvalidOperationException($"Could not parse {path}");
if (string.IsNullOrWhiteSpace(opts.ServerUrl)) throw new InvalidOperationException("config.json: serverUrl is required");
if (string.IsNullOrWhiteSpace(opts.ApiKey)) throw new InvalidOperationException("config.json: apiKey is required");
if (string.IsNullOrWhiteSpace(opts.MachineName)) opts.MachineName = Environment.MachineName;
return opts;
}
public static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}
public sealed class PathMapping
{
public string From { get; set; } = "";
public string To { get; set; } = "";
}
@@ -0,0 +1,164 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Main loop (§7.2): register on startup, heartbeat on its own timer (the
/// cancel-signal channel), claim-poll only when idle, one job at a time.
/// Workers poll dumbly — all scheduling policy (render windows, Render Now,
/// urgent priority) lives server-side in the claim endpoint (§7.10).
/// </summary>
public sealed class WorkerService : BackgroundService
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly JobExecutor _executor;
private readonly ReportSpooler _spooler;
private readonly ILogger<WorkerService> _log;
private string? _machineId;
private ServerConfig _config = new();
private volatile string? _currentJobId;
private CancellationTokenSource? _cancelCurrentJob;
private bool? _lastAeOpen;
public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler, ILogger<WorkerService> log)
{
_options = options;
_api = api;
_executor = executor;
_spooler = spooler;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_log.LogInformation("VFXReview RenderWorker {Version} starting as \"{Name}\" → {Server}",
_options.WorkerVersion, _options.MachineName, _options.ServerUrl);
_ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken);
await RegisterWithRetryAsync(stoppingToken);
await _api.ReportVersionAsync(_options, stoppingToken);
await _executor.ReconcileAsync(_machineId!, stoppingToken);
_ = Task.Run(() => HeartbeatLoopAsync(stoppingToken), stoppingToken);
// Claim loop — only while idle
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Preview builds drive a full AE instance, which would hijack an
// artist's open session (§9.1). Simply don't ask for those jobs
// while AE is running — they stay queued for a quiet moment
// instead of being claimed and failed.
var aeOpen = AeSession.InteractiveRunning();
if (aeOpen != _lastAeOpen)
{
_log.LogInformation(aeOpen
? "Interactive After Effects detected — not claiming preview jobs until it closes"
: "No interactive After Effects — preview jobs enabled");
_lastAeOpen = aeOpen;
}
// Delivery builds are file work with no AE involvement, so they
// are always claimable — someone is usually waiting on the folder.
// Blender is asked for unconditionally; the server refuses it to
// machines whose capabilities do not include Blender.
var types = aeOpen
? new[] { "AE_RENDER", "DELIVERY_BUILD", "BLENDER_RENDER" }
: new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD", "BLENDER_RENDER" };
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
if (job is not null)
{
_log.LogInformation("Claimed job {JobId} (attempt {Attempt}/{Max}, export {ExportId})",
job.Id, job.Attempt, job.MaxAttempts, job.ExportId);
_cancelCurrentJob = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None);
_currentJobId = job.Id;
try
{
await _executor.RunAsync(job, _machineId!, _config, _cancelCurrentJob.Token, stoppingToken);
}
finally
{
_currentJobId = null;
_cancelCurrentJob.Dispose();
_cancelCurrentJob = null;
}
continue; // immediately look for the next job
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Claim poll failed (server unreachable?) — retrying on normal cadence");
}
try { await Task.Delay(TimeSpan.FromSeconds(_config.PollSeconds), stoppingToken); }
catch (OperationCanceledException) { break; }
}
_log.LogInformation("RenderWorker stopping");
}
private async Task RegisterWithRetryAsync(CancellationToken ct)
{
var backoff = TimeSpan.FromSeconds(1);
while (!ct.IsCancellationRequested)
{
try
{
var reg = await _api.RegisterAsync(_options, ct);
_machineId = reg.Machine.Id;
_config = reg.Config;
_log.LogInformation(
"Registered as machine {Id} ({Name}); poll={Poll}s heartbeat={Hb}s lease={Lease}s stall={Stall}s enabled={Enabled}",
reg.Machine.Id, reg.Machine.Name, _config.PollSeconds, _config.HeartbeatSeconds,
_config.LeaseSeconds, _config.StallTimeoutSeconds, reg.Machine.Enabled);
return;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_log.LogWarning("Registration failed ({Error}); retrying in {Backoff}s", ex.Message, backoff.TotalSeconds);
try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { return; }
backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60));
}
}
}
private async Task HeartbeatLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var resp = await _api.HeartbeatAsync(_machineId!, _currentJobId, ct);
foreach (var cmd in resp.Commands)
{
if (cmd.Type == "CANCEL_JOB" && cmd.JobId is not null && cmd.JobId == _currentJobId)
{
_log.LogWarning("Heartbeat carried CANCEL_JOB for current job {JobId}", cmd.JobId);
_cancelCurrentJob?.Cancel();
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_log.LogDebug(ex, "Heartbeat failed (non-fatal)");
}
try { await Task.Delay(TimeSpan.FromSeconds(_config.HeartbeatSeconds), ct); }
catch (OperationCanceledException) { return; }
}
}
}
+158
View File
@@ -0,0 +1,158 @@
namespace VFXReviewWorker;
/// <summary>
/// xxHash64 (§8.2 `checksumAlgo: "xxh64"`). Implemented here rather than taken
/// from System.IO.Hashing so the worker stays a dependency-free single-file
/// publish for offline workstation installs. Verified against the reference
/// test vectors in XxHash64Tests.
/// </summary>
public static class XxHash64
{
private const ulong Prime1 = 11400714785074694791UL;
private const ulong Prime2 = 14029467366897019727UL;
private const ulong Prime3 = 1609587929392839161UL;
private const ulong Prime4 = 9650029242287828579UL;
private const ulong Prime5 = 2870177450012600261UL;
public static ulong Hash(ReadOnlySpan<byte> data, ulong seed = 0)
{
var state = new State(seed);
state.Update(data);
return state.Digest();
}
public static string HashFile(string path, int bufferSize = 1 << 20)
{
var state = new State(0);
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize, FileOptions.SequentialScan);
var buffer = new byte[bufferSize];
int read;
while ((read = fs.Read(buffer, 0, buffer.Length)) > 0)
{
state.Update(new ReadOnlySpan<byte>(buffer, 0, read));
}
return state.Digest().ToString("x16");
}
/// <summary>Streaming state so multi-GB sequences hash without being held in memory.</summary>
public struct State
{
private ulong _v1, _v2, _v3, _v4;
private readonly ulong _seed;
private ulong _length;
private readonly byte[] _buffer;
private int _bufferLen;
public State(ulong seed)
{
_seed = seed;
_v1 = seed + Prime1 + Prime2;
_v2 = seed + Prime2;
_v3 = seed;
_v4 = seed - Prime1;
_length = 0;
_buffer = new byte[32];
_bufferLen = 0;
}
public void Update(ReadOnlySpan<byte> data)
{
_length += (ulong)data.Length;
if (_bufferLen > 0)
{
var need = Math.Min(32 - _bufferLen, data.Length);
data[..need].CopyTo(_buffer.AsSpan(_bufferLen));
_bufferLen += need;
data = data[need..];
if (_bufferLen < 32) return;
ProcessStripe(_buffer);
_bufferLen = 0;
}
while (data.Length >= 32)
{
ProcessStripe(data[..32]);
data = data[32..];
}
if (data.Length > 0)
{
data.CopyTo(_buffer.AsSpan(0));
_bufferLen = data.Length;
}
}
private void ProcessStripe(ReadOnlySpan<byte> stripe)
{
_v1 = Round(_v1, BitConverter.ToUInt64(stripe[..8]));
_v2 = Round(_v2, BitConverter.ToUInt64(stripe.Slice(8, 8)));
_v3 = Round(_v3, BitConverter.ToUInt64(stripe.Slice(16, 8)));
_v4 = Round(_v4, BitConverter.ToUInt64(stripe.Slice(24, 8)));
}
public ulong Digest()
{
ulong h;
if (_length >= 32)
{
h = RotateLeft(_v1, 1) + RotateLeft(_v2, 7) + RotateLeft(_v3, 12) + RotateLeft(_v4, 18);
h = MergeRound(h, _v1);
h = MergeRound(h, _v2);
h = MergeRound(h, _v3);
h = MergeRound(h, _v4);
}
else
{
h = _seed + Prime5;
}
h += _length;
var tail = new ReadOnlySpan<byte>(_buffer, 0, _bufferLen);
int i = 0;
while (i + 8 <= tail.Length)
{
h ^= Round(0, BitConverter.ToUInt64(tail.Slice(i, 8)));
h = RotateLeft(h, 27) * Prime1 + Prime4;
i += 8;
}
if (i + 4 <= tail.Length)
{
h ^= BitConverter.ToUInt32(tail.Slice(i, 4)) * Prime1;
h = RotateLeft(h, 23) * Prime2 + Prime3;
i += 4;
}
while (i < tail.Length)
{
h ^= tail[i] * Prime5;
h = RotateLeft(h, 11) * Prime1;
i++;
}
h ^= h >> 33;
h *= Prime2;
h ^= h >> 29;
h *= Prime3;
h ^= h >> 32;
return h;
}
private static ulong Round(ulong acc, ulong input)
{
acc += input * Prime2;
acc = RotateLeft(acc, 31);
acc *= Prime1;
return acc;
}
private static ulong MergeRound(ulong acc, ulong val)
{
acc ^= Round(0, val);
acc = acc * Prime1 + Prime4;
return acc;
}
private static ulong RotateLeft(ulong value, int bits) => (value << bits) | (value >> (64 - bits));
}
}
+74
View File
@@ -0,0 +1,74 @@
<#
.SYNOPSIS
Packages the RenderWorker for installation on another machine.
.DESCRIPTION
Produces a self-contained single-file build (no .NET runtime needed on the
target) plus the headless AE build script, and zips it for copying.
.EXAMPLE
.\publish.ps1
.\publish.ps1 -Zip
#>
[CmdletBinding()]
param(
[string]$OutDir = "",
[switch]$Zip
)
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
if (-not $OutDir) { $OutDir = Join-Path $root "publish\win-x64" }
# Stamp the commit into the version so a deployed exe can be traced back to
# source ("1.1.0+feb1c7b"). Absent git, the plain version still publishes.
$sha = ""
try { $sha = (& git -C $root rev-parse --short HEAD 2>$null) } catch { }
if ($LASTEXITCODE -ne 0) { $sha = "" }
Write-Host "Publishing self-contained worker..." -ForegroundColor Cyan
$publishArgs = @(
(Join-Path $root "VFXReviewWorker\VFXReviewWorker.csproj")
"-c"; "Release"; "-r"; "win-x64"
"--self-contained"; "true"
"-p:PublishSingleFile=true"
"-o"; $OutDir
)
if ($sha) { $publishArgs += "-p:SourceRevisionId=$sha" }
& dotnet publish @publishArgs
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
# The exe reads scripts\vfxr_build_preview.jsx from its own folder at runtime,
# so it is copied here unconditionally rather than trusted to the SDK: an
# incremental publish into a cleaned output directory silently skips content
# copying, which produced a package that looked fine and could not build a
# preview. Copy, then verify.
$scriptSrc = Join-Path $PSScriptRoot "vfxr_build_preview.jsx"
$scriptOut = Join-Path $OutDir "scripts\vfxr_build_preview.jsx"
if (-not (Test-Path $scriptSrc)) { throw "Preview build script not found in source: $scriptSrc" }
New-Item -ItemType Directory -Force (Split-Path $scriptOut) | Out-Null
Copy-Item $scriptSrc $scriptOut -Force
if (-not (Test-Path $scriptOut)) { throw "Preview build script missing from output: $scriptOut" }
Remove-Item (Join-Path $OutDir "*.pdb") -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "Package contents:" -ForegroundColor Green
Get-ChildItem $OutDir -Recurse -File | ForEach-Object {
Write-Host (" {0,-40} {1,8:N0} KB" -f $_.FullName.Substring($OutDir.Length).TrimStart('\'), ($_.Length / 1KB))
}
if ($Zip) {
$zipPath = Join-Path $root ("VFXReviewWorker_{0}.zip" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $zipPath) { Remove-Item $zipPath }
Compress-Archive -Path (Join-Path $OutDir "*") -DestinationPath $zipPath
Write-Host ""
Write-Host "Zipped to: $zipPath" -ForegroundColor Green
}
Write-Host ""
Write-Host "Next on the target machine:" -ForegroundColor Cyan
Write-Host " 1. Copy this folder to e.g. C:\pipeline\VFXReviewWorker"
Write-Host " 2. Create C:\ProgramData\VFXReviewWorker\config.json (see README)"
Write-Host " 3. Verify: .\VFXReviewWorker.exe --check"
Write-Host " 4. Install: sc.exe create VFXReviewRenderWorker binPath= ... start= auto obj= ... password= ..."
+138
View File
@@ -0,0 +1,138 @@
<#
.SYNOPSIS
Headless AE spike / debug tool for the preview build stage.
.DESCRIPTION
Runs vfxr_build_preview.jsx exactly the way the RenderWorker does, but
standalone, so the risky part (AfterFX.com -noui -r under a service
account) can be proven before wiring the whole pipeline together.
It does NOT render - it builds the preview comp, queues the MOV + MP4
output modules and saves a temp AEP, then prints the build result.
IMPORTANT: close After Effects first. `AfterFX.com -r` hands the script to
an already-running instance, which would open the template in your session
and then quit it.
.EXAMPLE
.\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG_S1\111\UNG_111_001_030\v002" `
-ShotCode "UNG_111_001_030" -Version "v002"
.EXAMPLE
# then render what it built, exactly as the worker would:
& "C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\aerender.exe" -project "$env:ProgramData\VFXReviewWorker\scratch\spike_preview.aep"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$ExrDir,
[Parameter(Mandatory = $true)][string]$ShotCode,
[string]$Version = "v001",
[string]$TemplateAep = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep",
[string]$TemplateComp = "UNG_EXPORT_TEMPLATE",
[string]$OverlayComp = "UNG_VFX_OVERLAY",
[string]$LutComp = "_SHOW LUT",
[string]$MovTemplate = "4444 Tri",
[string]$Mp4Template = "REVIEW_PREVIEW",
[string]$AfterFx = "C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\AfterFX.com",
[int]$FrameStart = 1001,
[double]$Fps = 24,
[int]$TimeoutSeconds = 300
)
$ErrorActionPreference = "Stop"
$running = Get-Process AfterFX -ErrorAction SilentlyContinue
if ($running) {
Write-Warning "After Effects is running (PID $($running.Id -join ', '))."
Write-Warning "AfterFX.com -r would target that instance and quit it. Close AE first."
return
}
$scratch = Join-Path $env:ProgramData "VFXReviewWorker\scratch"
if (-not (Test-Path $scratch)) { New-Item -ItemType Directory -Force -Path $scratch | Out-Null }
$scriptPath = Join-Path $PSScriptRoot "vfxr_build_preview.jsx"
if (-not (Test-Path $scriptPath)) { throw "Build script not found: $scriptPath" }
if (-not (Test-Path $AfterFx)) { throw "AfterFX.com not found: $AfterFx" }
if (-not (Test-Path $ExrDir)) { throw "EXR folder not found: $ExrDir" }
$contextPath = Join-Path $scratch "spike_context.json"
$resultPath = Join-Path $scratch "spike_result.json"
$wrapperPath = Join-Path $scratch "spike_run.jsx"
$tempAep = Join-Path $scratch "spike_preview.aep"
$base = "${ShotCode}_cmp_TT_${Version}"
Remove-Item $resultPath -ErrorAction SilentlyContinue
$context = [ordered]@{
jobId = "spike"
exportId = "spike"
shotCode = $ShotCode
versionString = $Version
outputDirLocal = $ExrDir
templateAep = $TemplateAep
templateComp = $TemplateComp
overlayComp = $OverlayComp
lutComp = $LutComp
movTemplate = $MovTemplate
mp4Template = $Mp4Template
movOutput = (Join-Path $ExrDir "$base.mov")
mp4Output = (Join-Path $ExrDir "$base.mp4")
tempAep = $tempAep
resultPath = $resultPath
frameStart = $FrameStart
fps = $Fps
slate = [ordered]@{
versionName = $base
date = (Get-Date -Format "yyyy/MM/dd")
description = "Spike test"
notes = ""
shotCode = $ShotCode
episode = ""
scene = ""
}
}
$context | ConvertTo-Json -Depth 6 | Out-File -FilePath $contextPath -Encoding utf8
$ctxJs = ($contextPath -replace '\\', '/') | ConvertTo-Json
$scriptJs = ($scriptPath -replace '\\', '/') | ConvertTo-Json
$wrapper = @(
"// generated by test-preview-build.ps1",
("var VFXR_CONTEXT_PATH = {0};" -f $ctxJs),
('$.evalFile({0});' -f $scriptJs)
) -join "`r`n"
$wrapper | Out-File -FilePath $wrapperPath -Encoding utf8
Write-Host "Launching headless AE build..." -ForegroundColor Cyan
$proc = Start-Process -FilePath $AfterFx -ArgumentList @("-noui", "-r", $wrapperPath) -PassThru -NoNewWindow
if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) {
Write-Warning "Timed out after $TimeoutSeconds s - killing AE."
try { $proc.Kill($true) } catch {}
}
if (-not (Test-Path $resultPath)) {
Write-Host "FAILED: no result file produced." -ForegroundColor Red
Write-Host "AE could not run the script headlessly under this account. See RenderWorker/README.md fallbacks." -ForegroundColor Red
return
}
$result = Get-Content $resultPath -Raw | ConvertFrom-Json
if ($result.ok) {
Write-Host "BUILD OK" -ForegroundColor Green
Write-Host " temp AEP : $($result.tempAep)"
Write-Host " preview comp: $($result.previewComp)"
Write-Host (" frames : {0} ({1} x {2})" -f $result.frameCount, $result.width, $result.height)
Write-Host (" queued : {0} output(s)" -f @($result.queued).Count)
foreach ($q in @($result.queued)) {
Write-Host (" - {0}: {1} -> {2}" -f $q.label, $q.template, $q.output)
}
Write-Host ""
Write-Host "Render it with:" -ForegroundColor Cyan
Write-Host (' "{0}\aerender.exe" -project "{1}"' -f (Split-Path $AfterFx), $result.tempAep)
} else {
Write-Host "BUILD FAILED: $($result.error)" -ForegroundColor Red
}
if ($result.warnings -and $result.warnings.Count -gt 0) {
Write-Host "Warnings:" -ForegroundColor Yellow
$result.warnings | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
}
+579
View File
@@ -0,0 +1,579 @@
/*
vfxr_build_preview.jsx — VFXReview render pipeline, preview build stage.
Runs headlessly: AfterFX.com -noui -r <wrapper>.jsx
where the wrapper sets var VFXR_CONTEXT_PATH = "...json"; then
$.evalFile()s this script.
It does NOT render. It opens the studio slate/overlay template, rebuilds
the shot around the *rendered* EXR sequence (OCIO → show LUT → overlay),
duplicates the export template into a preview comp with the slate filled
in, queues the MOV and MP4 output modules, and saves a throwaway AEP.
The worker then runs `aerender -project <that aep>` with no -comp, which
renders the whole queue in one launch.
Build logic mirrors the VFXReviewConnector panel functions
buildShotFromCode / updateOverlayForComp / buildPreviewForShot so the
farm and the artist produce identical slates and burn-ins.
Everything is reported back through the result JSON — never a dialog.
*/
(function vfxrBuildPreview() {
var ctx = null;
var result = {
ok: false,
error: null,
warnings: [],
tempAep: null,
previewComp: null,
queued: [],
frameCount: null,
width: null,
height: null,
// Seconds of slate ahead of picture in the preview comp — the SHOT
// layer's in-point. The worker needs it to grab a thumbnail from
// picture rather than from the slate card.
pictureStartSeconds: null
};
var resultPath = null;
// ── tiny helpers (ES3 / ExtendScript safe) ───────────────────────────────
function readTextFile(path) {
var f = new File(path);
var text;
if (!f.exists) {
return null;
}
f.encoding = "UTF-8";
if (!f.open("r")) {
return null;
}
text = f.read();
f.close();
return text;
}
function writeTextFile(path, text) {
var f = new File(path);
try {
f.encoding = "UTF-8";
if (!f.open("w")) {
return false;
}
f.write(text);
f.close();
return true;
} catch (writeError) {
return false;
}
}
function jsonEscape(value) {
var text = String(value);
var out = "";
var i;
var ch;
var code;
for (i = 0; i < text.length; i += 1) {
ch = text.charAt(i);
code = text.charCodeAt(i);
if (ch === "\"") {
out += "\\\"";
} else if (ch === "\\") {
out += "\\\\";
} else if (ch === "\n") {
out += "\\n";
} else if (ch === "\r") {
out += "\\r";
} else if (ch === "\t") {
out += "\\t";
} else if (code < 32) {
out += "\\u" + ("000" + code.toString(16)).slice(-4);
} else {
out += ch;
}
}
return out;
}
function toJSON(value) {
var parts = [];
var i;
var key;
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "number") {
return isFinite(value) ? String(value) : "null";
}
if (typeof value === "boolean") {
return String(value);
}
if (typeof value === "string") {
return "\"" + jsonEscape(value) + "\"";
}
if (value instanceof Array) {
for (i = 0; i < value.length; i += 1) {
parts.push(toJSON(value[i]));
}
return "[" + parts.join(",") + "]";
}
for (key in value) {
if (value.hasOwnProperty(key) && value[key] !== undefined) {
parts.push("\"" + jsonEscape(key) + "\":" + toJSON(value[key]));
}
}
return "{" + parts.join(",") + "}";
}
function warn(message) {
result.warnings.push(String(message));
}
function findComp(name) {
var i;
var item;
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof CompItem && item.name === name) {
return item;
}
}
return null;
}
function findFolder(name) {
var i;
var item;
for (i = 1; i <= app.project.numItems; i += 1) {
item = app.project.item(i);
if (item instanceof FolderItem && item.name === name) {
return item;
}
}
return null;
}
// Mirrors the panel's addOCIOToLayer: effect added but disabled, so the
// artist/farm chain matches exactly.
function addOCIOToLayer(layer) {
var ocio;
var outputProp;
try {
ocio = layer.property("Effects").addProperty("OCIO Color Space Transform");
ocio.enabled = false;
} catch (ocioError) {
warn("OCIO effect unavailable: " + ocioError.toString());
return;
}
try {
outputProp = ocio.property("Output Color Space");
} catch (byNameError) {
outputProp = null;
}
if (!outputProp) {
try {
outputProp = ocio.property(2);
} catch (byIndexError) {
outputProp = null;
}
}
if (outputProp) {
try {
outputProp.setValue(93);
} catch (setError) {
warn("Could not set OCIO output colour space: " + setError.toString());
}
}
}
function setEssentialProperty(layer, propName, value) {
if (value === null || value === undefined || value === "") {
return false;
}
try {
layer.property("Essential Properties").property(propName).setValue(value);
return true;
} catch (setError) {
return false;
}
}
function setEssentialPropertyByIndex(layer, index, value) {
try {
layer.property("Essential Properties").property(index).setValue(value);
return true;
} catch (setError) {
return false;
}
}
function listEssentialPropertyNames(layer) {
var names = [];
var group;
var i;
try {
group = layer.property("Essential Properties");
for (i = 1; i <= group.numProperties; i += 1) {
try {
names.push(i + ":" + group.property(i).name);
} catch (oneError) {
}
}
} catch (groupError) {
}
return names;
}
// Slate identity fields (version name, shot name). Current templates expose
// these under a stable name, older ones only positionally, and the two do
// not always line up — so write both. Setting the same property twice is
// harmless; the panel uses the same name-with-index-fallback pattern.
function setSlateIdentity(layer, propName, index, value, label) {
var byName;
var byIndex;
if (value === null || value === undefined || value === "") {
return; // leave the template's own default
}
byName = setEssentialProperty(layer, propName, value);
byIndex = setEssentialPropertyByIndex(layer, index, value);
if (!byName && !byIndex) {
warn(label + ": neither Essential Property \"" + propName + "\" nor index " +
index + " could be set. Available: " +
listEssentialPropertyNames(layer).join(", "));
}
}
// Per-submission fields (VFX Scope / Submission Note). The property names
// come from config; if one does not exist, report the template's actual
// property names so it can be corrected without another build.
function setSlateField(layer, propName, value, label) {
if (!propName) {
return;
}
if (value === null || value === undefined || value === "") {
return; // nothing to write; leave the template's own default
}
if (!setEssentialProperty(layer, propName, value)) {
warn(label + ": Essential Property \"" + propName + "\" not found or not settable. " +
"Available: " + listEssentialPropertyNames(layer).join(", "));
}
}
// The production name, burned into both the slate and the overlay. The
// slate's other fields are title case ("Date", "Scene") while the overlay's
// are upper ("DATE", "SHOT NAME"), so both spellings are attempted rather
// than assuming which template uses which — and a template that exposes
// neither reports its actual property names instead of failing silently.
function setShowField(layer, value, label) {
var candidates = ["SHOW", "Show"];
var i;
if (value === null || value === undefined || value === "") {
return;
}
for (i = 0; i < candidates.length; i += 1) {
if (setEssentialProperty(layer, candidates[i], value)) {
return;
}
}
warn(label + ": no Essential Property named " + candidates.join(" or ") +
". Available: " + listEssentialPropertyNames(layer).join(", "));
}
function setCompStartFrame(comp, frameNumber) {
try {
comp.displayStartFrame = frameNumber;
return;
} catch (frameError) {
}
try {
comp.displayStartTime = frameNumber / comp.frameRate;
} catch (timeError) {
}
}
function applyOutputModuleTemplate(outputModule, templateName) {
var templates;
var i;
try {
templates = outputModule.templates;
for (i = 0; i < templates.length; i += 1) {
if (templates[i] === templateName) {
outputModule.applyTemplate(templateName);
return true;
}
}
} catch (templateError) {
}
return false;
}
function importExrSequence(dirPath) {
var folder = new Folder(dirPath);
var files;
var importOptions;
var footage;
if (!folder.exists) {
throw new Error("Render output folder not found: " + dirPath);
}
files = folder.getFiles("*.exr");
if (!files || files.length < 1) {
throw new Error("No EXR files found in " + dirPath);
}
files.sort();
importOptions = new ImportOptions(files[0]);
importOptions.sequence = true;
footage = app.project.importFile(importOptions);
try {
footage.mainSource.conformFrameRate = ctx.fps || 24;
} catch (conformError) {
warn("Could not conform frame rate: " + conformError.toString());
}
return footage;
}
function queueOutput(comp, templateName, outputPath, label) {
var item;
var outputModule;
if (!templateName || !outputPath) {
warn("Skipping " + label + ": no template or output path configured");
return false;
}
try {
item = app.project.renderQueue.items.add(comp);
outputModule = item.outputModule(1);
if (!applyOutputModuleTemplate(outputModule, templateName)) {
item.remove();
throw new Error("Output module template not found: \"" + templateName + "\"");
}
outputModule.file = new File(outputPath);
result.queued.push({ label: label, template: templateName, output: outputPath });
return true;
} catch (queueError) {
try {
if (item) {
item.remove();
}
} catch (removeError) {
}
throw queueError;
}
}
// ── main ─────────────────────────────────────────────────────────────────
try {
if (typeof VFXR_CONTEXT_PATH === "undefined" || !VFXR_CONTEXT_PATH) {
throw new Error("VFXR_CONTEXT_PATH was not set by the wrapper script");
}
var contextText = readTextFile(VFXR_CONTEXT_PATH);
if (!contextText) {
throw new Error("Could not read job context: " + VFXR_CONTEXT_PATH);
}
ctx = eval("(" + contextText + ")");
resultPath = ctx.resultPath;
try {
app.beginSuppressDialogs();
} catch (suppressError) {
}
// 1. Open the studio template (slate, overlay, show LUT, export template)
var templateFile = new File(ctx.templateAep);
if (!templateFile.exists) {
throw new Error("Template project not found: " + ctx.templateAep);
}
app.open(templateFile);
var templateComp = findComp(ctx.templateComp);
if (!templateComp) {
throw new Error("Export template comp \"" + ctx.templateComp + "\" not found in template project");
}
// 2. Import the rendered EXR sequence
var footage = importExrSequence(ctx.outputDirLocal);
var footageFolder = findFolder("_FOOTAGE_4K");
if (footageFolder) {
footage.parentFolder = footageFolder;
}
footage.name = ctx.shotCode + "_" + ctx.versionString;
var width = footage.width;
var height = footage.height;
var duration = footage.duration;
var fps = ctx.fps || 24;
result.width = width;
result.height = height;
result.frameCount = Math.round(duration * fps);
// 3. Footage precomp (mirrors buildShotFromCode)
var precompFolder = findFolder("_PRECOMPS");
var footageComp = app.project.items.addComp(
ctx.shotCode + "_FOOTAGE", width, height, 1, duration, fps);
if (precompFolder) {
footageComp.parentFolder = precompFolder;
}
if (ctx.frameStart !== null && ctx.frameStart !== undefined) {
setCompStartFrame(footageComp, ctx.frameStart);
}
addOCIOToLayer(footageComp.layers.add(footage));
// 4. Shot comp: footage + show LUT + overlay
var shotComp = app.project.items.addComp(ctx.shotCode, width, height, 1, duration, fps);
if (ctx.frameStart !== null && ctx.frameStart !== undefined) {
setCompStartFrame(shotComp, ctx.frameStart);
}
shotComp.layers.add(footageComp);
if (ctx.lutComp) {
var showLut = findComp(ctx.lutComp);
if (showLut) {
var lutLayer = shotComp.layers.add(showLut);
lutLayer.collapseTransformation = true;
} else {
warn("Show LUT comp \"" + ctx.lutComp + "\" not found — rendering without it");
}
}
if (ctx.overlayComp) {
var overlayComp = findComp(ctx.overlayComp);
if (overlayComp) {
var overlayLayer = shotComp.layers.add(overlayComp);
overlayLayer.moveToBeginning();
overlayLayer.enabled = true;
setEssentialProperty(overlayLayer, "DATE", ctx.slate.date);
setEssentialProperty(overlayLayer, "SHOT NAME", ctx.slate.versionName);
setShowField(overlayLayer, ctx.slate.show, "Overlay SHOW");
} else {
warn("Overlay comp \"" + ctx.overlayComp + "\" not found — rendering without burn-ins");
}
}
// 5. Preview comp from the export template (mirrors buildPreviewForShot)
var previewsFolder = findFolder("_PREVIEWS");
var previewComp = templateComp.duplicate();
previewComp.name = ctx.shotCode + "_PREVIEW";
if (previewsFolder) {
previewComp.parentFolder = previewsFolder;
}
previewComp.layer("SHOT").replaceSource(shotComp, false);
try {
previewComp.layer("THUMBNAIL").replaceSource(shotComp, false);
} catch (thumbError) {
warn("THUMBNAIL layer not updated: " + thumbError.toString());
}
var slateLayer = previewComp.layer("NETFLIX_SLATE");
if (slateLayer) {
// Identity fields match the panel's indices (3 = version name,
// 10 = shot name) and are also written by name; the rest are
// addressed by name only.
// VersionName carries the full "{shotCode}_cmp_TT_{version}";
// ShotName is the bare shot code, with no version suffix.
setSlateIdentity(slateLayer, "VersionName", 3, ctx.slate.versionName, "Version Name");
setSlateIdentity(slateLayer, "ShotName", 10, ctx.slate.shotCode, "Shot Name");
setEssentialProperty(slateLayer, "Date", ctx.slate.date);
setEssentialProperty(slateLayer, "Desc", ctx.slate.description);
setEssentialProperty(slateLayer, "Episode", ctx.slate.episode);
setEssentialProperty(slateLayer, "Scene", ctx.slate.scene);
setEssentialProperty(slateLayer, "Frames", result.frameCount);
setShowField(slateLayer, ctx.slate.show, "Slate SHOW");
setSlateField(slateLayer, ctx.slateScopeProp, ctx.slate.vfxScope, "VFX Scope");
// The slate's Notes field carries this submission's note; when the
// artist left it blank, fall back to the shot's own notes so the
// slate looks the same as an artist-built preview.
setSlateField(slateLayer, ctx.slateSubmissionProp,
(ctx.slate.submissionNote !== null &&
ctx.slate.submissionNote !== undefined &&
ctx.slate.submissionNote !== "")
? ctx.slate.submissionNote
: ctx.slate.notes,
"Submission Note");
} else {
warn("NETFLIX_SLATE layer not found in template — slate fields not filled");
}
var shotLayer = previewComp.layer("SHOT");
var targetOutPoint = shotLayer.inPoint + shotComp.duration;
// Everything before this is slate; the thumbnail has to start after it.
result.pictureStartSeconds = shotLayer.inPoint;
if (targetOutPoint > previewComp.duration) {
previewComp.duration = targetOutPoint;
}
shotLayer.outPoint = targetOutPoint;
previewComp.duration = targetOutPoint;
result.previewComp = previewComp.name;
// 6. Queue both outputs against the same preview comp. aerender will
// render the whole queue in one launch.
app.project.renderQueue.showWindow(false);
while (app.project.renderQueue.numItems > 0) {
app.project.renderQueue.item(1).remove();
}
queueOutput(previewComp, ctx.movTemplate, ctx.movOutput, "mov");
queueOutput(previewComp, ctx.mp4Template, ctx.mp4Output, "mp4");
if (result.queued.length < 1) {
throw new Error("Nothing was queued — check output module templates");
}
// 7. Save the throwaway project the worker will hand to aerender
var tempFile = new File(ctx.tempAep);
var tempParent = tempFile.parent;
if (tempParent && !tempParent.exists) {
tempParent.create();
}
app.project.save(tempFile);
result.tempAep = ctx.tempAep;
result.ok = true;
} catch (buildError) {
result.ok = false;
result.error = buildError && buildError.toString ? buildError.toString() : String(buildError);
try {
if (buildError && buildError.line) {
result.error += " (line " + buildError.line + ")";
}
} catch (lineError) {
}
}
try {
app.endSuppressDialogs(false);
} catch (endSuppressError) {
}
if (!resultPath && ctx && ctx.resultPath) {
resultPath = ctx.resultPath;
}
if (resultPath) {
writeTextFile(resultPath, toJSON(result));
}
// -noui leaves the app running otherwise; the worker waits on exit.
try {
app.quit();
} catch (quitError) {
}
}());
@@ -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.
File diff suppressed because it is too large Load Diff
+357 -24
View File
@@ -6,7 +6,161 @@ A dockable ScriptUI panel for Adobe After Effects 2024+ that integrates with the
## Configuration
At the top of the script, four global variables control how the panel connects to your project:
Settings are stored per-machine via the **⚙ Settings** button in the panel (persisted in After Effects' built-in preferences — no need to edit the JSX file).
| Setting | Purpose |
|---|---|
| **Server URL** | Base URL of the VFXReview server (e.g. `https://review.twotalesvfx.com`) |
| **API Token** | Bearer token matching `API_SECRET_KEY` in the server `.env` |
| **Export Root** | Local path where renders and deliverables are written |
| **Footage Root** | Local path where EXR plate folders are read from |
| **Banner Path** | Path to the panel banner image |
| **Artist Email** | Used for render attribution in the pipeline |
| **Handle Frames** | Plate handle frames prepended before the shot's source TC in-point (default: 8) |
| **Path Mappings** | *Optional.* Rewrites paths the **server** supplies — see below |
### Server paths vs. this machine
The server addresses render folders from its own root (`render.outputRoot`,
e.g. `//SAN`), which a workstation may not resolve — the render worker
translates them with its own `pathMappings` because it runs as a service
account. The panel needs the same translation to open a render for QC.
It resolves this itself in most cases, trying in order:
1. **Path Mappings**, if configured — format `//SAN/=V:/_EXPORTS/`, several
separated by `;`.
2. **The server path as given** — correct when `render.outputRoot` is already
a path this machine can reach.
3. **Re-based on Export Root** — the server's export layout mirrors the panel's
from the show folder down (`{root}/{showId}/{episode}/{shot}/{version}`), so
the tail is simply re-attached to Export Root.
Step 3 is why this usually needs no configuration. **Diagnostics** checks the
whole thing against a real render folder from the QC queue and names every path
it tried when it fails.
### More than one on-disk convention
Every candidate is tested for existence and the first that resolves wins, so a
studio whose output root changed at some point can map the **same prefix more
than once** and let the panel work out which applies per export:
```
//SAN/=V:/_EXPORTS/;//SAN/=V:/_EXPORTS/UNG/
```
Older exports keep whatever path was stored on them when they were queued —
changing `render.outputRoot` on the server only affects new ones — so mixed
conventions in one database are normal and permanent. Add a mapping per
convention rather than trying to make one rewrite cover them all.
---
## Pipeline Folder Structure
### Export Root
All outputs from the connector are written under **`<Export Root>/{SHOW_ID}/`** (e.g. `V:/_EXPORTS`). The show ID is appended automatically from the selected project — configure `exportRoot` as the bare root without a show ID.
```
<Export Root>/
└── {SHOW_ID}/ # e.g. UNG/
├── {SHOT_CODE}/ # e.g. UNG_108_001_010/
│ ├── {shot}_cmp_TT_{ver}.[#####].exr # Queue EXR / Queue EXR (Review)
│ └── {shot}_cmp_TT_{ver}.mp4 / .mov # Queue MP4 / Queue MOV
│
├── DELIVERY/
│ └── {EPISODE_OR_SCENE}/ # e.g. UNG_108/
│ └── {YYYYMMDD}_Delivery/
│ └── {SHOT_CODE}/
│ └── {shot}_cmp_TT_{ver}.[#####].exr
│
└── (render farm outputs — managed by the server)
└── {PROJECT_CODE}/{EPISODE}/{SHOT_CODE}/{ver}/
└── {shot}_cmp_TT_{ver}.[#####].exr
```
**Version naming:** `{SHOT_CODE}_cmp_TT_{version}` — e.g. `UNG_108_001_010_cmp_TT_v003.[01001].exr`
Versions are set manually via **Increment Shot Version** or the shot detail page. Queuing a render does **not** auto-increment the version.
---
### Footage Root
Source EXR plates are read from **`<Footage Root>/{SHOW_ID}/`**. Configure `footageRoot` as the bare root (e.g. `V:/_FOOTAGE`).
```
<Footage Root>/
└── {SHOW_ID}/ # e.g. UNG/ or DH2_VFX/
├── {EPISODE_CODE}/ # episodic only: e.g. UNG_108/
│ └── {SHOT_CODE}/ # standard layout: plates grouped per shot
│ └── {SHOT_CODE}_{ELEM}_{VER}/
│ └── *.exr
│
└── _PICLOCKS/
└── {EPISODE_CODE}_*.mov # matched by episode prefix
```
### Frame rate
Comps are built at the **shot record's `fps`**, and plates are conformed to it.
That value is the show's delivery rate and the authority; a plate's own rate is
whatever happens to be on disk.
`fps` defaults to 24 when a shot is created, so **check it after importing a
show that is not 24** — every shot carries its own value, and a wrong one
produces comps at the wrong rate, plates conformed to the wrong speed, and
timecode-to-frame sums that are quietly off.
A plate at a whole-number multiple of the show rate (50 fps on a 25 fps show) is
a deliberate over-crank and is conformed as intended. Validation reports the
difference either way, saying which of the two it looks like.
**Scenes are not folders.** Standard projects have no level between the show
folder and the shot folder — the scene picker filters the shot list only.
**Two layouts are supported, and neither is migrated.** The connector prefers the
grouped layout above; where a `{SHOT_CODE}/` folder does not exist it falls back
to scanning the parent for folders named `{SHOT_CODE}_*` (the original UNG
layout, where plates sit directly in the episode folder). All matching folders
are imported as separate footage layers. If a `{SHOT_CODE}/` folder exists but
holds no plate subfolders, that is reported as no footage — the fallback scan is
not attempted, so stray plate folders sitting beside the shot cannot be picked up
as its footage. Underscore-prefixed folders (`_ARCHIVE`, `_GFX`, `_PULLS`, …) are
never treated as shots or plates.
---
### Episodic vs. Standard productions
| | Episodic | Standard |
|---|---|---|
| Shot code format | `{SHOW}_{EP}_{SCENE}_{NUM}` | `{SHOW}_{SCENE}_{NUM}` |
| Shot Builder | Episode dropdown → Shot dropdown | Scene dropdown → Shot dropdown |
| Footage path | `{footageRoot}/{showId}/{EPISODE}/` | `{footageRoot}/{showId}/{SCENE}/` |
| Export path | `{exportRoot}/{showId}/{SHOT_CODE}/` | same |
| Delivery subfolder | `DELIVERY/{EPISODE}/…` | `DELIVERY/{SCENE}/…` |
---
## Migrating an existing project (e.g. UNG)
If you previously set `exportRoot = V:/_EXPORTS/UNG`, update it to `V:/_EXPORTS`. The code now appends `/{showId}/` automatically, so `V:/_EXPORTS/UNG/…` is still produced — no files need to move.
Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`.
---
## New production checklist
1. Create `<Footage Root>/{SHOW_ID}/[{EPISODE_CODE}/]{SHOT_CODE}/` — episode level for episodic shows only, never a scene level — and place each plate in its own `{SHOT_CODE}_{ELEM}_{VER}/` folder inside it.
2. `<Export Root>/{SHOW_ID}/` is created automatically on first render.
3. Add the project to the VFXReview server with the correct **Show ID** set.
4. Open the panel in After Effects, click **⚙**, enter the Server URL, API Token, bare `exportRoot`, and bare `footageRoot`, then click **Save**.
5. The **Project** dropdown will populate automatically. Select the production and the episode/scene list will load.
| Variable | Default | Description |
|---|---|---|
@@ -21,13 +175,15 @@ At the top of the script, four global variables control how the panel connects t
## Panel Layout
The panel is divided into five sections:
The panel is divided into seven sections:
1. **Shot Builder** — Episode/shot selection and comp creation
2. **Actions** — Per-shot and batch operations
3. **Workspace** — One-time project initialisation
4. **Color Space** — OCIO effect management
5. **Status** — Live feedback on selected comps and last action
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
3. **QC** — Technical QC queue for rendered exports
4. **Actions** — Per-shot and batch operations
5. **Workspace** — One-time project initialisation
6. **Color Space** — OCIO effect management
7. **Status** — Live feedback, plus Diagnostics and the last report
---
@@ -45,12 +201,78 @@ Constructs a full shot composite in the open AE project from the episode and sho
**Process:**
1. Calls the shot lookup API to retrieve metadata (description, notes, episode, scene, etc.).
2. If a comp with the shot code already exists, updates its overlay instead of rebuilding.
3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence at 24 fps.
3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence, conformed to the shot's frame rate.
4. Creates a `{shotCode}_FOOTAGE` precomp containing all imported sequences, each with an OCIO Color Space Transform effect added (disabled by default).
5. Creates the main `{shotCode}` comp and places the footage precomp inside it.
6. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top.
7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders.
6. Anchors both comps on the plate's start timecode — see [Start timecode](#start-timecode).
7. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top.
8. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
9. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders.
If any step fails, the status line names the stage and a copyable report opens —
see [Diagnostics and logging](#diagnostics-and-logging).
#### Start timecode
Neither comp starts at `00:00:00:00`. Both are anchored on the plate's own start
timecode, taken from the first source that has one:
| Rank | Source | Notes |
|---|---|---|
| 1 | The first EXR's header | The `timeCode` attribute, or `timeCodeString` where the packed one is absent. Read straight off disk — no pixel decode, and no dependency on what AE chose to expose. |
| 2 | AE's footage metadata | What "New Comp from Selection" would have inherited. |
| 3 | API `timecodeStart` less `handleFrames` | The convention **Set Start Timecode** uses. Last resort: the API stores the *editorial* in-point, which is a handle-length ahead of the plate by design. |
This is the same ranking the render worker's validation uses (RenderPipeline2
§8.1a), so a comp is anchored on exactly the value its EXRs are later checked
against — a shot built from rank 3 and validated against rank 1 is how a
timecode desync gets into a delivery.
The status line and the diagnostics log both name the rank that was used, e.g.
`start frame 864108 from EXR timecode 10:00:04:12`. A plate carrying no timecode
in any rank leaves the comp at `00:00:00:00` and says so.
---
## Render Pipeline
### Queue Export
Saves the project, builds the render manifest from the selected comp and the
shot record, and submits it to the farm.
| Option | Effect |
|---|---|
| **Urgent** | Priority 20 — claimable outside the render window, ahead of normal jobs |
| **No preview** | Renders the EXRs but builds no preview, inheriting the previous export's MOV, review MP4 and thumbnail |
**No preview** exists for deliverables framed differently from the review —
unscaled or un-punched-in EXRs. A preview rebuilt from those frames would be
the wrong picture, while the earlier export's MOV is already correct, so it
carries over instead. The export still goes through QC, and the delivery
package still gets a MOV.
Leave it unticked for an ordinary submission: the preview is what QC and the
client review see.
---
## QC
Technical QC on the **rendered EXRs**, not the H.264 preview — the point is
catching mask slips, export glitches and metadata faults at full resolution.
QC is per-export: a new export always needs new QC.
| Control | Behaviour |
|---|---|
| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. |
| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. |
| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. |
| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. |
| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. |
Passing an export is what makes it eligible for a delivery package on the web
app's **Pipeline → Deliveries** page.
---
@@ -64,7 +286,7 @@ Updates the **Status** panel to show the current count of selected `CompItem`s.
### Fix Comp Names
Renames selected comps so their names match the shot code extracted from their current name.
- Shot codes follow the pattern `XXX_000_000_000` (e.g. `UNG_106_010_020`).
- Shot codes are read by anchoring on the selected project's showId — see [Shot Code Convention](#shot-code-convention).
- If a comp name contains a valid shot code but has extra text, it is trimmed to the code only.
- Wrapped in an undo group.
@@ -160,7 +382,7 @@ Imports a picture-lock (offline cut) video file and trims it to the correct sequ
**Process:**
1. Calls the API to retrieve `seqTimecodeStart` and `seqTimecodeEnd` for the shot.
2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix (first 7 characters of the shot code, e.g. `UNG_106`).
2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix — `{showId}_{episode}` on episodic shows (e.g. `UNG_106`), plain `{showId}` on standard ones.
3. If not found, scans `FOOTAGE_ROOT/_PICLOCKS/` for a file matching `{episodePrefix}_*` and imports it.
4. Calculates the in/out offsets from the sequence timecodes relative to the piclock's start (assumed `01:00:00:00` / 3590 s unless embedded timecode is available), with **±8 frame handles**.
5. Adds the piclock footage as a layer at the top of the comp, scales it to fill the frame, and sets `startTime`, `inPoint`, and `outPoint` to isolate the correct range.
@@ -172,17 +394,37 @@ Imports a picture-lock (offline cut) video file and trims it to the correct sequ
## Workspace
### Per-project templates
Each show has its own template AEP — its overlay, slate/export template and,
most importantly, its **show LUT**. These are configured per project on the web
app under **Project → Settings → Pipeline**, not in the panel, because the
render farm's headless preview build reads the same values: if the two
disagreed, a farm preview would be quietly the wrong colour.
| Setting | Purpose |
|---|---|
| **Template project (.aep)** | Imported by Initialise Workspace and by every headless preview build |
| **Renders root** | Where per-shot pre-renders live; the panel appends the shot code (Import Renders) |
| **Overlay / Slate / LUT comp** | Only needed when a template departs from the naming convention |
Comp names default to a convention derived from the show ID:
`{showId}_VFX_OVERLAY`, `{showId}_EXPORT_TEMPLATE`, `_SHOW LUT`. A show whose
template follows the pattern needs no comp configuration at all — leave the
fields blank and they follow the convention, including any later change to it.
### Initialise Workspace
One-time setup that creates the standard project folder hierarchy, imports the slate/overlay template AEP, and configures color settings.
Works for any project: the episode folders and the template both come from the
selected project rather than being fixed to one show.
**Folder structure created:**
```
__SHOTS/
UNG_101/
UNG_102/
…
UNG_117/
{EPISODE_CODE}/ ← one per episode, from the project's episode list
… (episodic shows only; standard shows get none)
_PREVIEWS/
_PRECOMPS/
_PICLOCKS/
@@ -192,8 +434,15 @@ _RENDERS/
_UTILITIES/
```
Episodes come from `GET /api/ext/projects/{code}/episodes`. If a show has none
entered yet, the folders found under its footage root are used instead, so the
button still does something useful on day one.
**Template import:**
Imports `X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep` as a project merge.
Imports the project's **Template project (.aep)** as a project merge — see
[Per-project templates](#per-project-templates). If none is configured the
folders and colour settings are still applied, and the status line says the
template was not set.
**Color settings (applied outside the undo group):**
- Sets project bit depth to **32-bit**.
@@ -246,15 +495,19 @@ Accept: application/json
## Shot Code Convention
Shot codes follow the pattern `AAA_NNN_NNN_NNN` where:
- `AAA` = three-letter show prefix (e.g. `UNG`)
- First `NNN` = episode number (e.g. `106`)
- Second `NNN` = sequence number (e.g. `010`)
- Third `NNN` = shot number (e.g. `020`)
Shot codes are the project's **showId** followed by three-digit tokens:
Example: `UNG_106_010_020`
| Project type | Pattern | Example |
|---|---|---|
| Episodic | `{showId}_{episode}_{scene}_{shot}` | `UNG_106_010_020` (showId `UNG`) |
| Standard | `{showId}_{scene}_{shot}` | `DH2_VFX_027_010` (showId `DH2_VFX`) |
Episode codes follow the pattern `AAA_NNN` (e.g. `UNG_106`).
The showId is **not** always three letters and may itself contain underscores, so
codes are parsed by stripping the known showId prefix — never by counting
underscores or matching a fixed `AAA_` pattern. Everything after the showId is
three digits per token.
Episode codes follow `{showId}_{episode}` (e.g. `UNG_106`).
---
@@ -296,3 +549,83 @@ The **Status** panel at the bottom of the UI shows two lines:
- **Line 2** — Result or progress message from the last operation.
Both lines are updated throughout batch operations so progress is visible in real time.
Below them are two buttons:
- **Diagnostics** — runs the environment self-test described below.
- **Show Last Report** — re-opens the trace from the last button pressed.
---
## Versions
The panel's version is the `PANEL_VERSION` constant at the top of
`VFXReviewConnector.jsx`. It appears in the panel title, in every diagnostics
report, and is sent to the server when the panel loads.
**Pipeline → Machines → Deployed tool versions** then shows which version each
workstation is running, alongside the render workers. Anything behind the newest
version seen for that tool is flagged `→ rebuild to vX` — so updating one
machine is what surfaces the ones still to do. No expected-version list to keep
in sync; the newest thing reporting in is the benchmark.
**Bump `PANEL_VERSION` whenever you copy the .jsx to a workstation.** If two
machines report the same version but behave differently, one of them was copied
without the bump.
---
## Diagnostics and logging
Every button press is traced: what it tried, in what order, with the paths and
HTTP codes it was working with. The trace goes to an in-memory buffer and to a
log file, and it is shown automatically when something fails.
**Log file:** `%APPDATA%/VFXReviewConnector/connector.log` (next to `config.json`).
It is truncated once it passes 1 MB, so it can always be pasted into a message.
The API token is never written to it — only whether one is set.
### When Build Shot fails
The status line now names the stage it failed at, and a scrollable report opens
with the whole trace. The stages, and what each one means:
| Stage | Meaning | Usual fix |
|---|---|---|
| `project` | No project selected in the panel | The project dropdown is empty because the server URL or token is wrong |
| `shot lookup` | The server returned no shot record | Report shows the HTTP code: `0` = server unreachable, `401/403` = bad token, `404` = shot not in that project |
| `configuration` | Footage Root is blank in Settings | Settings are **per-machine**; a fresh workstation starts blank |
| `footage folder` | The show/episode folder does not exist here | Report names the deepest folder that *does* exist — usually an unmapped drive or a different drive letter/UNC on that machine |
| `plate folders` | Folder exists, no plates for that shot code | Report lists the folders that *are* there, so a naming mismatch is visible |
| `plate import` | Plate folders matched but held no importable EXRs | Report lists each folder's contents (`n .exr, n other files`) |
| `comp creation` | After Effects threw while building the comps | Not a path or server problem — the AE error text is in the report |
| `unexpected error` | Anything thrown that was not anticipated | Report carries the error text, line number and stack |
Non-fatal conditions are traced too rather than passing silently: a missing
`_SHOW LUT`, an overlay that could not be applied, a missing `__SHOTS/{group}`
folder.
### Diagnostics button
Checks everything Build Shot depends on **without building anything**, so a
workstation can be cleared before an artist is blocked. It verifies:
1. `curl.exe` is present and runnable (every server call goes through it).
2. Server URL and API token are set, and `GET /api/ext/projects` succeeds.
3. Footage Root resolves to a folder that exists, and lists the plate folders
found for the currently selected shot.
4. Export Root resolves to a folder that exists.
5. The shot lookup succeeds for the currently selected shot.
6. The open AE project has `UNG_VFX_OVERLAY`, `UNG_EXPORT_TEMPLATE`,
`_SHOW LUT`, and the `__SHOTS` / `_FOOTAGE_4K` / `_PRECOMPS` / `_PREVIEWS`
folders.
Each failure is reported as a `PROBLEM` line with the offending path or HTTP
response, and the report ends with a problem count.
### Errors that used to be invisible
ScriptUI silently swallows exceptions thrown inside a button handler, so a hard
failure looked like a button that did nothing at all. Every handler is now
wrapped: anything thrown is caught, logged with its line number and stack, shown
in the status line, and surfaced in the report dialog.
+82 -72
View File
@@ -5,7 +5,7 @@ import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { notifyApprovalChange } from "@/lib/notifications";
import { slackNotifyApproval } from "@/lib/slack";
import { versionLabel } from "@/lib/utils";
@@ -29,57 +29,62 @@ export async function submitApproval(data: z.infer<typeof approvalSchema>) {
throw new Error("Insufficient permissions to review versions");
}
// Create the approval record
const approval = await db.approval.create({
data: {
versionId: parsed.versionId,
userId: session.user.id,
status: parsed.status,
notes: parsed.notes,
},
});
// The verdict, the version, the task and the derived shot status are one
// decision and are written as one: a failure partway through used to leave the
// three layers disagreeing, with no way to tell which one was right.
const { approval, version } = await db.$transaction(async (tx) => {
const approval = await tx.approval.create({
data: {
versionId: parsed.versionId,
userId: session.user.id,
status: parsed.status,
notes: parsed.notes,
},
});
// Update version approval status
await db.version.update({
where: { id: parsed.versionId },
data: { approvalStatus: parsed.status },
});
await tx.version.update({
where: { id: parsed.versionId },
data: { approvalStatus: parsed.status },
});
// Load version + task + shot + project for downstream effects
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
task: {
include: {
shot: true,
project: true,
// Load version + task + shot + project for downstream effects
const version = await tx.version.findUnique({
where: { id: parsed.versionId },
include: {
task: {
include: {
shot: true,
project: true,
},
},
},
},
});
if (!version) throw new Error("Version not found");
// Update task status based on approval result
if (version.task && parsed.status !== "PENDING_REVIEW") {
if (parsed.status === "APPROVED") {
await tx.task.update({
where: { id: version.task.id },
data: { status: "DONE" },
});
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
await tx.task.update({
where: { id: version.task.id },
data: { status: "CHANGES" },
});
}
// Recalculate shot status from updated task states
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id, tx);
}
}
return { approval, version };
});
if (!version) throw new Error("Version not found");
// Update task status based on approval result
if (version.task && parsed.status !== "PENDING_REVIEW") {
if (parsed.status === "APPROVED") {
await db.task.update({
where: { id: version.task.id },
data: { status: "DONE" },
});
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
await db.task.update({
where: { id: version.task.id },
data: { status: "CHANGES" },
});
}
// Recalculate shot status from updated task states
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
}
// Notifications
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
const reviewerName = reviewer?.name ?? "Reviewer";
@@ -166,34 +171,39 @@ export async function unapproveVersion(versionId: string) {
throw new Error("Only approved versions can be unapproved");
}
// Create an audit record for the unapproval
await db.approval.create({
data: {
versionId,
userId: session.user.id,
status: "PENDING_REVIEW",
notes: "Approval undone",
},
});
// Reset version approval status
await db.version.update({
where: { id: versionId },
data: { approvalStatus: "PENDING_REVIEW" },
});
// Reset task back to INTERNAL_REVIEW if it was moved to DONE
if (version.task && version.task.status === "DONE") {
await db.task.update({
where: { id: version.task.id },
data: { status: "INTERNAL_REVIEW" },
// One decision, one write — same reasoning as submitApproval.
await db.$transaction(async (tx) => {
// Create an audit record for the unapproval
await tx.approval.create({
data: {
versionId,
userId: session.user.id,
status: "PENDING_REVIEW",
notes: "Approval undone",
},
});
}
// Recalculate shot status
if (version.task?.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
// Reset version approval status
await tx.version.update({
where: { id: versionId },
data: { approvalStatus: "PENDING_REVIEW" },
});
// Reset task back to INTERNAL_REVIEW if it was moved to DONE
if (version.task && version.task.status === "DONE") {
await tx.task.update({
where: { id: version.task.id },
data: { status: "INTERNAL_REVIEW" },
});
}
// Undoing a version approval also invalidates any shot-level sign-off that
// rested on it, otherwise the shot keeps reading COMPLETE / READY_FOR_CLIENT.
if (version.task?.shot) {
await clearStaleShotApproval(tx, version.task.shot.id);
await recalcShotStatus(version.task.shot.id, tx);
}
});
revalidatePath(`/review/${versionId}`);
if (version.task) {
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { PipelineError } from "@/lib/render-pipeline/errors";
import {
createMovReviewPackage,
previewMovReviewPackage,
} from "@/lib/render-pipeline/reviews";
/**
* Server actions behind "Build MOV Review Package" on the shot-status page
* (RenderPipeline2 §11.6). The heavy lifting lives in lib/render-pipeline;
* these only carry the session and turn service errors into messages a toast
* can show.
*/
function message(err: unknown): string {
if (err instanceof PipelineError) return err.message;
return err instanceof Error ? err.message : "Unknown error";
}
/** What the confirm dialog shows: the folder, the resolved versions, the gaps. */
export async function previewMovReview(shotIds: string[]) {
try {
await requirePipelineAdmin();
const preview = await previewMovReviewPackage(shotIds);
return { ok: true as const, ...preview };
} catch (err) {
return { ok: false as const, error: message(err) };
}
}
export async function buildMovReview(shotIds: string[]) {
try {
const user = await requirePipelineAdmin();
const result = await createMovReviewPackage({ shotIds }, user.id);
revalidatePath("/pipeline/deliveries");
return { ok: true as const, ...result };
} catch (err) {
return { ok: false as const, error: message(err) };
}
}
+17 -1
View File
@@ -4,7 +4,8 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ProjectStatus } from "@prisma/client";
import { Prisma, ProjectStatus } from "@prisma/client";
import { pipelineConfigSchema } from "@/lib/render-pipeline/project-templates";
const createProjectSchema = z.object({
name: z.string().min(1).max(100),
@@ -76,6 +77,8 @@ const updateProjectSchema = z.object({
startDate: z.string().optional().nullable(),
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
slackChannel: z.string().optional().nullable(),
/** Per-production AE workspace: template AEP, renders root, comp overrides. */
pipelineConfig: pipelineConfigSchema.optional().nullable(),
});
export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
@@ -103,6 +106,19 @@ export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
...(rest.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }),
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }),
// Blank fields are dropped rather than stored as empty strings, so a comp
// name left blank keeps following the convention instead of pinning "".
// Booleans are kept as-is: `false` is a meaningful stored value, and the
// blank-string test would otherwise discard every non-string setting.
...(rest.pipelineConfig !== undefined && {
pipelineConfig: rest.pipelineConfig
? (Object.fromEntries(
Object.entries(rest.pipelineConfig).filter(([, v]) =>
typeof v === "boolean" ? true : typeof v === "string" && !!v.trim()
)
) as Prisma.InputJsonValue)
: Prisma.DbNull,
}),
},
});
+405 -47
View File
@@ -4,8 +4,8 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ShotStatus, ShotPriority, ShotApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
const createShotSchema = z.object({
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
@@ -341,6 +341,15 @@ export async function importShotsFromCsv(
return { success: true, created, errors };
}
/**
* Manual override of the shot's status.
*
* Deliberately the one place that writes Shot.status without deriving it — but
* note the override is transient: Shot.status is a derived field, so the next
* recalc (any task move, approval, upload or render) recomputes it from the
* tasks and approval flags and discards whatever was set here. Change the
* underlying task states if the new status needs to stick.
*/
export async function updateShotStatus(
shotId: string,
status: ShotStatus
@@ -489,6 +498,42 @@ export async function updateShotNotes(shotId: string, notes: string) {
return { success: true };
}
// ── Update Shot Slate Fields ──────────────────────────────────────────────────
export async function updateShotSlate(
shotId: string,
data: {
slateType?: string | null;
slateDescription?: string | null;
slateVfxScope?: string | null;
slateSubmissionNote?: string | null;
clientCode?: string | null;
}
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } });
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: {
slateType: data.slateType !== undefined ? (data.slateType?.trim() || null) : undefined,
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
clientCode: data.clientCode !== undefined ? (data.clientCode?.trim() || null) : undefined,
},
});
revalidatePath(`/projects/${shot.projectId}`);
return { success: true };
}
// ── Toggle Key Shot ───────────────────────────────────────────────────────────
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
@@ -514,6 +559,34 @@ export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
return { success: true };
}
/**
* Scaled in the edit: the plate is not used at its native size, which changes
* how the shot is comped and reviewed. Same shape as toggleKeyShot — a standing
* property of the shot, flipped from the shot-status board.
*/
export async function toggleScaledShot(shotId: string, isScaled: boolean) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { isScaled },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
export async function deleteShot(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
@@ -579,6 +652,59 @@ export async function bulkUpdateDueDates(
return { success: true, updated: shots.length };
}
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
export async function bulkDeleteShots(shotIds: string[]) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
if (!shotIds.length) return { success: true, deleted: 0, failed: [] as { shotId: string; shotCode: string; reason: string }[] };
const shots = await db.shot.findMany({
where: { id: { in: shotIds } },
select: { id: true, shotCode: true, projectId: true },
});
const projectIds = [...new Set(shots.map((s) => s.projectId))];
const failed: { shotId: string; shotCode: string; reason: string }[] = [];
let deleted = 0;
// One at a time, not deleteMany: a shot with renders or deliveries is
// protected at the database level (exports_shotId_fkey is ON DELETE
// RESTRICT, deliberately — delivery history must never be destroyable by
// deleting the shot underneath it). A single bulk statement would abort
// entirely on the first shot it hits; deleting individually means every
// shot that *can* go still does, and the ones that can't are reported by
// name instead of failing the whole selection.
for (const shot of shots) {
try {
await db.shot.delete({ where: { id: shot.id } });
deleted += 1;
} catch (e) {
failed.push({
shotId: shot.id,
shotCode: shot.shotCode,
reason:
e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2003"
? "has renders or deliveries — delete those first"
: e instanceof Error
? e.message
: "unknown error",
});
}
}
for (const pid of projectIds) {
revalidatePath(`/projects/${pid}`);
}
revalidatePath(`/shot-status`);
return { success: true, deleted, failed };
}
export async function renameFootagePlate(plateId: string, label: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
@@ -620,13 +746,19 @@ export async function internallyApproveShot(shotId: string) {
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: {
shotApprovalStatus: "INTERNALLY_APPROVED",
sharedWithClient: false,
status: "READY_FOR_CLIENT",
},
// Set the approval inputs and let the derivation pick the status. Writing
// READY_FOR_CLIENT directly skipped the rule that an outstanding CHANGES task
// outranks any approval, so a shot could be pushed past open rework and then
// snap back to REVISIONS on the next unrelated recalc.
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: {
shotApprovalStatus: "INTERNALLY_APPROVED",
sharedWithClient: false,
},
});
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
@@ -669,7 +801,7 @@ export async function shareWithClient(shotId: string) {
// Mark shot as shared
await tx.shot.update({
where: { id: shotId },
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
data: { sharedWithClient: true },
});
// For each task, make the latest version client-visible
@@ -689,6 +821,8 @@ export async function shareWithClient(shotId: string) {
});
}
}
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
@@ -721,7 +855,7 @@ export async function unshareFromClient(shotId: string) {
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
data: { sharedWithClient: false },
});
// Hide all client-visible versions on this shot's tasks
@@ -732,6 +866,8 @@ export async function unshareFromClient(shotId: string) {
data: { isClientVisible: false },
});
}
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
@@ -760,9 +896,12 @@ export async function clientApproveShot(shotId: string) {
if (!shot) throw new Error("Shot not found");
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
await db.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED" },
});
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
@@ -792,14 +931,15 @@ export async function unapproveShot(shotId: string) {
throw new Error("Shot is not client-approved");
}
const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
await db.shot.update({
where: { id: shotId },
data: {
shotApprovalStatus: "INTERNALLY_APPROVED",
status: newStatus,
},
// The derivation already encodes "CLIENT_REVIEW if still shared, else
// READY_FOR_CLIENT" for an INTERNALLY_APPROVED shot, and additionally honours
// any open CHANGES task.
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "INTERNALLY_APPROVED" },
});
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
@@ -951,10 +1091,161 @@ export async function updateShotsSeqTimecodes(
return { updated, skipped, errors };
}
// ── Simple CSV shot import ────────────────────────────────────────────────────
export interface SimpleCsvRow {
shotCode: string;
seqTimecodeStart: string;
seqTimecodeEnd: string;
description: string;
group: string;
action: "create" | "update" | "skip";
}
export async function importShotsFromSimpleCsv(
projectId: string,
rows: SimpleCsvRow[]
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
try {
const existing = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (existing) {
const shotGroupId = row.group?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.group.trim() } },
create: { projectId, name: row.group.trim() },
update: {},
})).id
: undefined;
await db.shot.update({
where: { id: existing.id },
data: {
description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
...(shotGroupId ? { shotGroupId } : {}),
},
});
updated.push(row.shotCode);
} else if (row.action === "create") {
const parts = row.shotCode.split("_");
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
const episode = parts.length >= 4 ? parts[1] : null;
const maxNum = await db.shot.findFirst({
where: { projectId, scene, episode },
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
const shotGroupId = row.group?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.group.trim() } },
create: { projectId, name: row.group.trim() },
update: {},
})).id
: undefined;
await db.shot.create({
data: {
projectId,
shotCode: row.shotCode,
scene,
episode,
shotNumber,
description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
shotGroupId,
},
});
created.push(row.shotCode);
} else {
// action === "update" but shot not found
skipped.push(row.shotCode);
}
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { created, updated, skipped, errors };
}
// ── Group Assignment ──────────────────────────────────────────────────────────────────
export interface GroupAssignRow {
shotCode: string;
groupName: string;
exists: boolean;
}
export async function assignShotGroups(
projectId: string,
rows: GroupAssignRow[]
): Promise<{ assigned: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const assigned: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
if (!row.exists) { skipped.push(row.shotCode); continue; }
try {
const shot = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (!shot) { skipped.push(row.shotCode); continue; }
const group = await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.groupName.trim() } },
create: { projectId, name: row.groupName.trim() },
update: {},
});
await db.shot.update({
where: { id: shot.id },
data: { shotGroupId: group.id },
});
assigned.push(row.shotCode);
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { assigned, skipped, errors };
}
/**
* Internal: handle client requesting changes on a shot.
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
* Session-authenticated wrapper for requesting changes on a shot — a supervisor
* or producer relaying a client note from inside the app.
*
* The client portal cannot use this: it runs on a review token, not a session,
* so it calls markShotForRevisions directly from its route. Both go through that
* one helper so the two entry points cannot drift apart again.
*/
export async function clientRequestShotChanges(shotId: string, taskId?: string) {
const session = await auth();
@@ -969,28 +1260,7 @@ export async function clientRequestShotChanges(shotId: string, taskId?: string)
});
if (!shot) throw new Error("Shot not found");
await db.$transaction(async (tx) => {
// Reset shot approval
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
});
// If a specific task is called out, mark it CHANGES; otherwise mark all non-DONE tasks
if (taskId) {
await tx.task.update({
where: { id: taskId },
data: { status: "CHANGES" },
});
} else {
await tx.task.updateMany({
where: { shotId, status: { not: "DONE" } },
data: { status: "CHANGES" },
});
}
await recalcShotStatus(shotId, tx);
});
await db.$transaction((tx) => markShotForRevisions(tx, shotId, taskId));
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
@@ -1029,3 +1299,91 @@ export async function updateShotVersion(shotId: string, version: string) {
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true, shotVersion: version };
}
// ── Generated (render-derived) shot thumbnails ───────────────────────────────
//
// A shot can carry two stills: `thumbnailUrl`, the reference frame that came
// with the brief, and `generatedThumbnailUrl`, a frame promoted from a render
// export. The reference always wins on display (lib/shot-thumbnail.ts), so
// these actions can only ever fill a gap — a promotion never overwrites the
// brief, and clearing one always falls back to it.
//
// Shows with `pipelineConfig.autoShotThumbnail` promote on every finished
// export automatically (lib/render-pipeline/preview.ts). These actions are the
// manual equivalent, and work regardless of that flag: a supervisor can pull a
// frame onto one shot on a project that is otherwise hands-off.
/**
* Promote a render export's thumbnail onto its shot.
*
* With no `exportId`, uses the newest export of that shot which actually has a
* thumbnail — "use the latest render frame".
*/
export async function promoteExportThumbnail(shotId: string, exportId?: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { id: true, projectId: true },
});
if (!shot) throw new Error("Shot not found");
const exportRow = await db.export.findFirst({
where: {
shotId,
thumbnailKey: { not: null },
...(exportId ? { id: exportId } : {}),
},
orderBy: { versionNumber: "desc" },
select: { id: true, thumbnailKey: true, versionString: true },
});
if (!exportRow?.thumbnailKey) {
throw new Error(
exportId
? "That export has no thumbnail — its preview may have been skipped, or the worker had no ffmpeg configured"
: "No render of this shot has produced a thumbnail yet"
);
}
const generatedThumbnailUrl = `/api/files/${exportRow.thumbnailKey}`;
await db.shot.update({
where: { id: shotId },
data: { generatedThumbnailUrl, generatedThumbnailFrom: exportRow.versionString },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return {
success: true,
generatedThumbnailUrl,
generatedThumbnailFrom: exportRow.versionString,
};
}
/** Drop the render-derived still. The reference frame, if any, comes back. */
export async function clearGeneratedThumbnail(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { id: true, projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { generatedThumbnailUrl: null, generatedThumbnailFrom: null },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true };
}
+10 -3
View File
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { recalcShotStatus } from "@/lib/shot-status";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack";
@@ -50,9 +50,16 @@ export async function createVersion(data: z.infer<typeof createVersionSchema>) {
data: { status: "INTERNAL_REVIEW" },
});
// Recalculate shot status
// Recalculate shot status — clearing any stale sign-off first, so a new
// version does not land under a shot still reading COMPLETE.
if (task.shot) {
await recalcShotStatus(task.shot.id).catch(() => {});
const shotId = task.shot.id;
await db
.$transaction(async (tx) => {
await clearStaleShotApproval(tx, shotId);
await recalcShotStatus(shotId, tx);
})
.catch(() => {});
}
// Get the next version number for this task
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw } from "lucide-react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// After a redeployment the old chunk URLs 404 — force a hard reload to pick up new manifest
if (error?.name === "ChunkLoadError") {
window.location.reload();
}
}, [error]);
if (error?.name === "ChunkLoadError") {
return null;
}
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-zinc-400">
<AlertTriangle className="w-10 h-10 text-amber-500" />
<p className="text-lg font-medium text-zinc-200">Something went wrong</p>
{error?.message && (
<p className="text-sm text-zinc-500 max-w-md text-center">{error.message}</p>
)}
<Button variant="outline" onClick={reset} className="gap-2">
<RefreshCw className="w-4 h-4" />
Try again
</Button>
</div>
);
}
@@ -0,0 +1,403 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import {
EXPORT_STATUS_STYLES,
FAILED_STATUSES,
ACTIVE_STATUSES,
statusLabel,
formatEta,
} from "./status-colors";
interface QueueExport {
id: string;
shotCode: string;
episode: string | null;
projectName: string;
projectCode: string;
versionString: string;
status: string;
statusChangedAt: string;
createdAt: string;
outputDir: string;
job: {
id: string;
attempt: number;
status: string;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
etaSeconds: number | null;
priority: number;
machineName: string | null;
errorMessage: string | null;
} | null;
}
interface Props {
projects: { id: string; name: string; code: string }[];
}
export function PipelineQueueClient({ projects }: Props) {
const { data: session } = useSession();
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["pipeline-queue", projectId, statusFilter],
queryFn: async () => {
const params = new URLSearchParams({ limit: "200" });
if (projectId !== "all") params.set("projectId", projectId);
if (statusFilter !== "all") params.set("status", statusFilter);
const res = await fetch(`/api/render/queue?${params}`);
if (!res.ok) throw new Error("Failed to load queue");
return res.json() as Promise<{ exports: QueueExport[]; pagination: { total: number } }>;
},
refetchInterval: 5000,
});
const exports = data?.exports ?? [];
const counts = {
queued: exports.filter((e) => e.status === "QUEUED").length,
rendering: exports.filter((e) => e.status === "RENDERING").length,
failed: exports.filter((e) => FAILED_STATUSES.includes(e.status)).length,
readyForQc: exports.filter((e) => e.status === "READY_FOR_QC").length,
readyForDelivery: exports.filter((e) => e.status === "READY_FOR_DELIVERY").length,
};
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({ title: `Export ${verb === "mark-done" ? "marked done" : verb === "retry" ? "requeued" : "cancelled"}` });
}
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
}
const cards: { label: string; value: number; className?: string }[] = [
{ label: "Queued", value: counts.queued },
{ label: "Rendering", value: counts.rendering, className: "text-blue-400" },
{ label: "Failed", value: counts.failed, className: "text-red-400" },
{ label: "Ready for QC", value: counts.readyForQc, className: "text-amber-400" },
{ label: "Ready for Delivery", value: counts.readyForDelivery, className: "text-green-400" },
];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
<p className="text-sm text-zinc-400 mt-1">
Exports queued from the AE panel — live status, retries and history
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
Deliveries
</Button>
</Link>
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
Deliveries
</Button>
</Link>
<Link href="/pipeline/machines">
<Button variant="outline" size="sm">
<Server className="h-4 w-4 mr-2" />
Machines
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{cards.map((c) => (
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
</div>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
<SelectItem key={s} value={s}>
{statusLabel(s)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Version</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
<th className="px-4 py-2.5 font-medium">Machine</th>
<th className="px-4 py-2.5 font-medium">Attempt</th>
<th className="px-4 py-2.5 font-medium">Queued</th>
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
Loading…
</td>
</tr>
) : exports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
No exports yet — queue one from the After Effects panel
</td>
</tr>
) : (
exports.map((e) => (
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="px-4 py-2.5">
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
{e.shotCode}
</Link>
<div className="text-xs text-zinc-500">
{e.projectCode}
{e.episode ? ` · ep ${e.episode}` : ""}
</div>
</td>
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
<td className="px-4 py-2.5">
<span
className={cn(
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
title={e.job?.errorMessage ?? undefined}
>
{statusLabel(e.status)}
</span>
</td>
<td className="px-4 py-2.5">
{e.status === "RENDERING" && e.job ? (
<div className="space-y-1">
<Progress value={e.job.progress * 100} className="h-1.5" />
<div className="text-xs text-zinc-500">
{e.job.currentFrame != null && e.job.totalFrames != null
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
: ""}
ETA {formatEta(e.job.etaSeconds)}
</div>
</div>
) : (
<span className="text-zinc-600 text-xs">—</span>
)}
</td>
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
<td className="px-4 py-2.5 text-zinc-400">
{e.job ? `${e.job.attempt}` : "—"}
</td>
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{FAILED_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
<Button
variant="ghost"
size="icon-sm"
title="Mark done manually (rendered outside the pipeline)"
onClick={() => action(e.id, "mark-done")}
>
<CheckCircle2 className="h-4 w-4 text-green-400" />
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<BlenderQueue projectId={projectId} />
</div>
);
}
interface BlenderJobRow {
id: string;
label: string | null;
status: string;
attempt: number;
maxAttempts: number;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
etaSeconds: number | null;
errorMessage: string | null;
machineName: string | null;
submittedBy: string | null;
projectCode: string | null;
blendFile: string | null;
frameStart: number | null;
frameEnd: number | null;
createdAt: string;
}
const BLENDER_STATUS_STYLES: Record<string, string> = {
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
CLAIMED: "bg-blue-500/15 text-blue-400 border-blue-500/30",
RUNNING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
COMPLETED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
EXPIRED: "bg-red-500/15 text-red-400 border-red-500/30",
};
/**
* Standalone Blender renders. They have no Export behind them, so they cannot
* appear in the table above — but they share the queue, and a render nobody can
* see is a render nobody can retry.
*/
function BlenderQueue({ projectId }: { projectId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["blender-jobs", projectId],
queryFn: async () => {
const qs = projectId && projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await fetch(`/api/render/blender${qs}`);
if (!res.ok) throw new Error("Failed to load Blender jobs");
return res.json() as Promise<{ jobs: BlenderJobRow[] }>;
},
refetchInterval: 5000,
});
async function jobAction(id: string, verb: "cancel" | "retry") {
const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
}
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
}
const jobs = data?.jobs ?? [];
if (jobs.length === 0) return null;
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Blender renders
</div>
<div className="divide-y divide-zinc-800/60">
{jobs.map((j) => (
<div key={j.id} className="px-4 py-3 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-zinc-200">{j.label ?? "(untitled)"}</span>
<span className={cn("rounded-full border px-2 py-0.5 text-xs", BLENDER_STATUS_STYLES[j.status] ?? "")}>
{j.status.toLowerCase()}
</span>
{j.projectCode && <span className="text-xs text-zinc-500">{j.projectCode}</span>}
{j.frameStart != null && (
<span className="text-xs text-zinc-500">
frames {j.frameStart}–{j.frameEnd}
</span>
)}
{j.status === "RUNNING" && (
<span className="text-xs text-blue-400">
{Math.round(j.progress * 100)}%
{j.currentFrame != null ? ` · frame ${j.currentFrame}` : ""}
{j.etaSeconds ? ` · ETA ${formatEta(j.etaSeconds)}` : ""}
</span>
)}
{j.machineName && <span className="text-xs text-zinc-500">{j.machineName}</span>}
{j.attempt > 1 && (
<span className="text-xs text-zinc-500">
attempt {j.attempt}/{j.maxAttempts}
</span>
)}
<span className="ml-auto flex items-center gap-1">
{["QUEUED", "CLAIMED", "RUNNING"].includes(j.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => jobAction(j.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{["FAILED", "CANCELLED", "EXPIRED"].includes(j.status) && (
<Button variant="ghost" size="icon-sm" title="Requeue" onClick={() => jobAction(j.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
</span>
</div>
{j.blendFile && <div className="mt-1 text-xs text-zinc-600 break-all">{j.blendFile}</div>}
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,336 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
interface ReadyExport {
exportId: string;
shotCode: string;
episode: string;
versionString: string;
fileCount: number | null;
totalBytes: number | null;
deliveryMovPath: string | null;
hasChecksum: boolean;
qc: { result: string; reviewer: string | null; at: string } | null;
}
interface ReadyResponse {
episodes: { episode: string; count: number; exports: ReadyExport[] }[];
}
interface PackageRow {
id: string;
packageNumber: number;
name: string;
kind: "EXR_DELIVERY" | "MOV_REVIEW";
episode: string;
status: string;
rootPath: string | null;
errorMessage: string | null;
projectName: string;
createdAt: string;
builtAt: string | null;
deliveredAt: string | null;
itemCount: number;
verifiedCount: number;
items: {
id: string;
shotCode: string;
versionString: string;
status: string;
fileCount: number | null;
linkMode: string | null;
errorMessage: string | null;
}[];
}
interface ProjectOption {
id: string;
name: string;
code: string;
}
function formatBytes(bytes: number | null): string {
if (bytes == null) return "—";
const gb = bytes / 1_073_741_824;
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
}
export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState<string>("");
const [episode, setEpisode] = useState<string>("");
const [building, setBuilding] = useState(false);
const { data: projects } = useQuery({
queryKey: ["pipeline-projects"],
queryFn: async () => {
const res = await fetch("/api/projects");
if (!res.ok) throw new Error("Failed to load projects");
const body = await res.json();
return (body.projects ?? body) as ProjectOption[];
},
});
const { data: ready, isLoading: readyLoading } = useQuery({
queryKey: ["delivery-ready", projectId],
queryFn: async () => {
const res = await fetch(`/api/deliveries/ready?projectId=${encodeURIComponent(projectId)}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load ready exports");
return res.json() as Promise<ReadyResponse>;
},
enabled: Boolean(projectId),
});
const { data: packages } = useQuery({
queryKey: ["delivery-packages", projectId],
queryFn: async () => {
const res = await fetch(`/api/deliveries${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
if (!res.ok) throw new Error("Failed to load packages");
return res.json() as Promise<{ packages: PackageRow[] }>;
},
// A build is a queued job like any other — poll while one is in flight.
refetchInterval: 5000,
});
async function build(ep: string) {
if (!projectId) return;
setBuilding(true);
try {
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, episode: ep || null }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({
title: `Package #${body.package.packageNumber} queued`,
description: `${body.package.itemCount} shots → ${body.package.rootPath}`,
});
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
}
} finally {
setBuilding(false);
}
}
async function packageAction(id: string, verb: "delivered" | "cancel") {
const res = await fetch(`/api/deliveries/${id}/${verb}`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
}
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
}
const episodes = ready?.episodes ?? [];
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
return (
<div className="p-6 space-y-6 max-w-6xl">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-xl font-semibold text-white">Delivery packages</h1>
</div>
{/* Picker */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={projectId}
onChange={(e) => {
setProjectId(e.target.value);
setEpisode("");
}}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
<option value="">Select a project…</option>
{(projects ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.code})
</option>
))}
</select>
{episodes.length > 0 && (
<select
value={episode}
onChange={(e) => setEpisode(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
<option value="">All episodes</option>
{episodes.map((e) => (
<option key={e.episode} value={e.episode}>
{e.episode || "(no episode)"} — {e.count} ready
</option>
))}
</select>
)}
</div>
{/* Ready to build */}
{projectId && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Ready for delivery
</div>
{readyLoading && <div className="p-4 text-sm text-zinc-500">Loading…</div>}
{!readyLoading && shown.length === 0 && (
<div className="p-4 text-sm text-zinc-500">
Nothing has passed QC yet. Exports appear here once QC passes them.
</div>
)}
{shown.map((ep) => (
<div key={ep.episode} className="border-b border-zinc-800/60 last:border-0">
<div className="flex items-center justify-between gap-4 px-4 py-3 flex-wrap">
<div>
<span className="text-white text-sm font-medium">
{ep.episode ? `Episode ${ep.episode}` : "No episode"}
</span>
<span className="ml-3 text-sm text-zinc-400">Ready: {ep.count} shots</span>
{ep.exports.some((x) => !x.hasChecksum) && (
<span className="ml-3 text-xs text-amber-400">
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
</span>
)}
</div>
{canBuild && (
<Button size="sm" onClick={() => build(ep.episode)} disabled={building}>
<Package className="h-4 w-4 mr-2" />
Build delivery
</Button>
)}
</div>
<div className="px-4 pb-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{ep.exports.map((x) => (
<div key={x.exportId} className="flex items-center gap-2 text-xs">
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
{x.shotCode}
</Link>
<span className="text-zinc-500">{x.versionString}</span>
<span className="text-zinc-600">{x.fileCount ?? "?"} frames</span>
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
</div>
))}
</div>
</div>
))}
</div>
)}
{/* History — packages are permanent (§11.5) */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Packages</div>
<div className="divide-y divide-zinc-800/60">
{(packages?.packages ?? []).map((p) => (
<details key={p.id} className="group">
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
<span className="text-sm text-zinc-300">{p.name}</span>
{p.kind === "MOV_REVIEW" && (
<span className="rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-xs text-violet-300">
MOV review
</span>
)}
<span
className={cn(
"rounded-full border px-2 py-0.5 text-xs",
DELIVERY_STATUS_STYLES[p.status] ?? ""
)}
>
{statusLabel(p.status)}
</span>
<span className="text-xs text-zinc-500">
{p.episode ? `episode ${p.episode} · ` : ""}
{p.verifiedCount}/{p.itemCount} shots
</span>
<span className="text-xs text-zinc-600">{new Date(p.createdAt).toLocaleDateString()}</span>
{p.status === "BUILDING" && <RefreshCw className="h-3.5 w-3.5 text-blue-400 animate-spin" />}
<span className="ml-auto flex items-center gap-2">
{canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "delivered");
}}
>
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
Mark delivered
</Button>
)}
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "cancel");
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
Cancel
</Button>
)}
</span>
</summary>
<div className="px-4 pb-4 space-y-2">
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
{p.errorMessage && <div className="text-xs text-red-400">{p.errorMessage}</div>}
{p.deliveredAt && (
<div className="text-xs text-emerald-400">
Delivered {new Date(p.deliveredAt).toLocaleString()}
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{p.items.map((item) => (
<div key={item.id} className="flex items-center gap-2 text-xs">
<span
className={cn(
item.status === "VERIFIED"
? "text-emerald-400"
: item.status === "FAILED"
? "text-red-400"
: "text-zinc-500"
)}
>
{item.status === "VERIFIED" ? "✓" : item.status === "FAILED" ? "✕" : "·"}
</span>
<span className="text-zinc-300">{item.shotCode}</span>
<span className="text-zinc-500">{item.versionString}</span>
{p.kind === "EXR_DELIVERY" && item.fileCount != null && (
<span className="text-zinc-600">{item.fileCount}f</span>
)}
{item.linkMode && <span className="text-zinc-600">{item.linkMode}</span>}
{item.errorMessage && <span className="text-red-400">{item.errorMessage}</span>}
</div>
))}
</div>
</div>
</details>
))}
{(packages?.packages ?? []).length === 0 && (
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,12 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { DeliveriesClient } from "./DeliveriesClient";
export const dynamic = "force-dynamic";
export default async function DeliveriesPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
return <DeliveriesClient canBuild={["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)} />;
}
@@ -0,0 +1,476 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import {
EXPORT_STATUS_STYLES,
FAILED_STATUSES,
ACTIVE_STATUSES,
statusLabel,
} from "../../status-colors";
interface ExportDetail {
export: {
id: string;
status: string;
versionString: string;
statusChangedAt: string;
createdAt: string;
aepPath: string;
compName: string;
outputDir: string;
outputPattern: string;
frameStart: number;
frameEnd: number;
fps: number;
width: number;
height: number;
colorspace: string | null;
exrFileCount: number | null;
exrTotalBytes: number | null;
vfxScope: string | null;
submissionNote: string | null;
submittedByName: string | null;
shot: {
id: string;
shotCode: string;
episode: string | null;
project: { id: string; name: string; code: string };
};
renderJobs: {
id: string;
attempt: number;
status: string;
priority: number;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
errorMessage: string | null;
logTail: string | null;
exitCode: number | null;
claimedAt: string | null;
startedAt: string | null;
finishedAt: string | null;
machine: { id: string; name: string } | null;
}[];
events: {
id: string;
fromStatus: string | null;
toStatus: string;
actorType: string;
actorId: string | null;
note: string | null;
createdAt: string;
}[];
validations: {
id: string;
renderJobId: string;
checkName: string;
status: string;
expected: string | null;
actual: string | null;
message: string | null;
details: unknown;
createdAt: string;
}[];
qcReviews: {
id: string;
result: string;
notes: string | null;
reviewerName: string | null;
source: string;
createdAt: string;
}[];
deliveryItems: {
id: string;
status: string;
exrPath: string | null;
linkMode: string | null;
package: { id: string; name: string; episode: string; status: string; packageNumber: number };
}[];
};
}
const CHECK_STYLES: Record<string, string> = {
PASS: "text-emerald-400",
FAIL: "text-red-400",
WARN: "text-amber-400",
SKIPPED: "text-zinc-500",
};
export function ExportDetailClient({ exportId }: { exportId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [overrideOpen, setOverrideOpen] = useState(false);
const [overrideReason, setOverrideReason] = useState("");
const [overrideSkipPreview, setOverrideSkipPreview] = useState(false);
const [overriding, setOverriding] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["pipeline-export", exportId],
queryFn: async () => {
const res = await fetch(`/api/render/exports/${exportId}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load export");
return res.json() as Promise<ExportDetail>;
},
refetchInterval: 5000,
});
async function action(verb: "retry" | "cancel") {
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
}
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
}
async function overrideValidation() {
setOverriding(true);
try {
const res = await fetch(`/api/render/exports/${exportId}/override-validation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: overrideReason, skipPreview: overrideSkipPreview }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Override failed", description: body.error ?? res.statusText, variant: "destructive" });
return;
}
toast({
title: "Validation overridden",
description: `${(body.overridden ?? []).join(", ") || "No failed checks recorded"} — continuing to preview and QC`,
});
setOverrideOpen(false);
setOverrideReason("");
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
} finally {
setOverriding(false);
}
}
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
if (error || !data) {
return (
<div className="p-6 text-red-400">
{(error as Error)?.message ?? "Export not found"}
</div>
);
}
const e = data.export;
const manifestRows: [string, string][] = [
["Project", `${e.shot.project.name} (${e.shot.project.code})`],
["Shot", e.shot.shotCode],
["Comp", e.compName],
["AEP", e.aepPath],
["Output dir", e.outputDir],
["Pattern", e.outputPattern],
["Frames", `${e.frameStart}–${e.frameEnd} @ ${e.fps} fps`],
["Resolution", `${e.width}×${e.height}`],
["Colorspace", e.colorspace ?? "—"],
["EXR files", e.exrFileCount != null ? String(e.exrFileCount) : "—"],
["Submitted by", e.submittedByName ?? "—"],
];
return (
<div className="p-6 space-y-6 max-w-5xl">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-semibold text-white">
{e.shot.shotCode} · {e.versionString}
</h1>
<span
className={cn(
"inline-block mt-1 rounded-full border px-2 py-0.5 text-xs",
EXPORT_STATUS_STYLES[e.status] ?? ""
)}
>
{statusLabel(e.status)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{FAILED_STATUSES.includes(e.status) && (
<Button variant="outline" size="sm" onClick={() => action("retry")}>
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
</Button>
)}
{e.status === "VALIDATION_FAILED" && !overrideOpen && (
<Button variant="outline" size="sm" onClick={() => setOverrideOpen(true)}>
<ShieldAlert className="h-4 w-4 mr-2 text-amber-400" /> Override validation
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
</Button>
)}
</div>
</div>
{/* Override — a human overruling the checks, so the reason is required
and kept on the export's timeline. */}
{overrideOpen && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-3">
<div className="text-sm text-amber-200">
Override the failed validation for {e.shot.shotCode} {e.versionString}?
</div>
<p className="text-xs text-zinc-400">
The render continues to preview and then QC — it is not delivered directly, and the failed
checks stay on record and still show up in the QC queue. Say why, so the next person
reading this export knows what was waved through.
</p>
<textarea
value={overrideReason}
onChange={(ev) => setOverrideReason(ev.target.value)}
rows={2}
autoFocus
placeholder="e.g. plate unreachable from the render node; timecode checked by hand against the EDL"
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600"
/>
<label className="flex items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={overrideSkipPreview}
onChange={(ev) => setOverrideSkipPreview(ev.target.checked)}
className="mt-0.5"
/>
<span>
Skip the preview build and reuse the previous export&apos;s review media.
<span className="block text-zinc-500">
For EXRs framed differently from the review — unscaled or un-punched-in — where a
preview rebuilt from these frames would be the wrong picture.
</span>
</span>
</label>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={overrideValidation}
disabled={overrideReason.trim().length < 3 || overriding}
>
{overriding ? "Overriding…" : "Override and continue"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOverrideOpen(false);
setOverrideReason("");
setOverrideSkipPreview(false);
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Manifest */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Manifest</div>
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-2 p-4 text-sm">
{manifestRows.map(([k, v]) => (
<div key={k} className="flex gap-3">
<dt className="w-28 shrink-0 text-zinc-500">{k}</dt>
<dd className="text-zinc-300 break-all">{v}</dd>
</div>
))}
</dl>
</div>
{/* Per-submission slate fields */}
{(e.vfxScope || e.submissionNote) && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Submission
</div>
<dl className="p-4 space-y-3 text-sm">
{e.vfxScope && (
<div>
<dt className="text-zinc-500 mb-0.5">VFX Scope</dt>
<dd className="text-zinc-300 whitespace-pre-wrap">{e.vfxScope}</dd>
</div>
)}
{e.submissionNote && (
<div>
<dt className="text-zinc-500 mb-0.5">Submission Note</dt>
<dd className="text-zinc-300 whitespace-pre-wrap">{e.submissionNote}</dd>
</div>
)}
</dl>
</div>
)}
{/* Validation results — newest run first */}
{e.validations.length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
Validation
{/* A waved-through failure must stay visible long after the event
scrolls down the timeline. */}
{e.events.some((ev) => ev.note?.startsWith("Validation overridden")) && (
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-400">
overridden
</span>
)}
{(() => {
const latestRun = e.validations[e.validations.length - 1].renderJobId;
const rows = e.validations.filter((v) => v.renderJobId === latestRun);
const failed = rows.filter((v) => v.status === "FAIL").length;
const warned = rows.filter((v) => v.status === "WARN").length;
return (
<span className={cn("text-xs", failed ? "text-red-400" : warned ? "text-amber-400" : "text-emerald-400")}>
{failed ? `${failed} failed` : warned ? `${warned} warning${warned > 1 ? "s" : ""}` : "all checks passed"}
</span>
);
})()}
</div>
<div className="divide-y divide-zinc-800/60">
{(() => {
const latestRun = e.validations[e.validations.length - 1].renderJobId;
// Failures first: the reason the export stopped should not need scrolling for.
const order = { FAIL: 0, WARN: 1, PASS: 2, SKIPPED: 3 } as Record<string, number>;
return e.validations
.filter((v) => v.renderJobId === latestRun)
.slice()
.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9))
.map((v) => (
<div key={v.id} className="px-4 py-2 text-sm flex items-start gap-3">
<span className={cn("w-16 shrink-0 text-xs font-medium", CHECK_STYLES[v.status] ?? "")}>
{v.status}
</span>
<span className="w-48 shrink-0 text-zinc-300 font-mono text-xs pt-0.5">{v.checkName}</span>
<span className="text-zinc-400 text-xs pt-0.5">
{v.message ??
(v.expected === v.actual ? v.actual : `expected ${v.expected ?? "—"}, got ${v.actual ?? "—"}`)}
</span>
</div>
));
})()}
</div>
</div>
)}
{/* QC history — latest decision is the authoritative one */}
{e.qcReviews.length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">QC</div>
<div className="divide-y divide-zinc-800/60">
{e.qcReviews.map((q) => (
<div key={q.id} className="px-4 py-2.5 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className={cn("text-xs font-medium", q.result === "PASS" ? "text-emerald-400" : "text-red-400")}>
QC {q.result}
</span>
<span className="text-xs text-zinc-400">{q.reviewerName ?? "unknown reviewer"}</span>
<span className="text-xs text-zinc-500">{q.source === "AE_PANEL" ? "AE panel" : "web"}</span>
<span className="text-xs text-zinc-500">{new Date(q.createdAt).toLocaleString()}</span>
</div>
{q.notes && <div className="mt-1 text-xs text-zinc-400 whitespace-pre-wrap">{q.notes}</div>}
</div>
))}
</div>
</div>
)}
{/* Delivery membership */}
{e.deliveryItems.length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Deliveries</div>
<div className="divide-y divide-zinc-800/60">
{e.deliveryItems.map((d) => (
<div key={d.id} className="px-4 py-2.5 text-sm flex items-center gap-3 flex-wrap">
<Link href="/pipeline/deliveries" className="text-zinc-200 hover:text-white">
#{d.package.packageNumber} · {d.package.name}
</Link>
<span className="text-xs text-zinc-500">episode {d.package.episode || "—"}</span>
<span className="text-xs text-zinc-400">{d.package.status}</span>
<span className="text-xs text-zinc-500">
item {d.status.toLowerCase()}
{d.linkMode ? ` · ${d.linkMode}` : ""}
</span>
</div>
))}
</div>
</div>
)}
{/* Render attempts */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Render attempts
</div>
<div className="divide-y divide-zinc-800/60">
{e.renderJobs.map((j) => (
<div key={j.id} className="p-4 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-white font-medium">Attempt {j.attempt}</span>
<span className="text-xs text-zinc-400">{j.status}</span>
<span className="text-xs text-zinc-500">{j.machine?.name ?? "unclaimed"}</span>
{j.exitCode != null && <span className="text-xs text-zinc-500">exit {j.exitCode}</span>}
{j.startedAt && (
<span className="text-xs text-zinc-500">
started {new Date(j.startedAt).toLocaleString()}
</span>
)}
{j.finishedAt && (
<span className="text-xs text-zinc-500">
finished {new Date(j.finishedAt).toLocaleString()}
</span>
)}
</div>
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
{j.logTail && (
<details className="mt-2">
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
Log tail
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-3 text-xs text-zinc-400 whitespace-pre-wrap">
{j.logTail}
</pre>
</details>
)}
</div>
))}
{e.renderJobs.length === 0 && (
<div className="p-4 text-sm text-zinc-500">No attempts yet</div>
)}
</div>
</div>
{/* Event timeline */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Timeline</div>
<ol className="p-4 space-y-2">
{e.events.map((ev) => (
<li key={ev.id} className="flex items-start gap-3 text-sm">
<span className="text-xs text-zinc-500 w-40 shrink-0 whitespace-nowrap">
{new Date(ev.createdAt).toLocaleString()}
</span>
<span className="text-zinc-300">
{ev.fromStatus ? `${statusLabel(ev.fromStatus)} → ` : ""}
{statusLabel(ev.toStatus)}
<span className="text-zinc-500"> · {ev.actorType.toLowerCase()}</span>
{ev.note && <span className="block text-xs text-zinc-500">{ev.note}</span>}
</span>
</li>
))}
</ol>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ExportDetailClient } from "./ExportDetailClient";
export const dynamic = "force-dynamic";
export default async function ExportDetailPage({
params,
}: {
params: Promise<{ exportId: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { exportId } = await params;
return <ExportDetailClient exportId={exportId} />;
}
@@ -0,0 +1,277 @@
"use client";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { ArrowLeft, Zap, Power, Cpu, HardDrive, MemoryStick } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
interface MachineRow {
id: string;
name: string;
hostname: string;
enabled: boolean;
status: "ONLINE" | "OFFLINE" | "DISABLED";
lastSeenAt: string | null;
workerVersion: string | null;
aeVersion: string | null;
availability: { mode?: string } | null;
renderNowUntil: string | null;
latestHeartbeat: {
createdAt: string;
cpuPercent: number | null;
memPercent: number | null;
diskFreeGb: number | null;
} | null;
currentJob: {
id: string;
exportId: string | null;
shotCode: string | null;
versionString: string | null;
progress: number;
etaSeconds: number | null;
} | null;
}
const STATUS_STYLES: Record<string, string> = {
ONLINE: "bg-green-500/15 text-green-400 border-green-500/30",
OFFLINE: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
DISABLED: "bg-red-500/15 text-red-400 border-red-500/30",
};
export function MachinesClient() {
const { data: session } = useSession();
const { toast } = useToast();
const queryClient = useQueryClient();
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
const { data, isLoading } = useQuery({
queryKey: ["pipeline-machines"],
queryFn: async () => {
const res = await fetch("/api/machines");
if (!res.ok) throw new Error("Failed to load machines");
return res.json() as Promise<{ machines: MachineRow[] }>;
},
refetchInterval: 10000,
});
async function patch(machineId: string, body: Record<string, unknown>, okMsg: string) {
const res = await fetch(`/api/machines/${machineId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const resBody = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Update failed", description: resBody.error ?? res.statusText, variant: "destructive" });
} else {
toast({ title: okMsg });
}
queryClient.invalidateQueries({ queryKey: ["pipeline-machines"] });
}
const machines = data?.machines ?? [];
return (
<div className="p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-semibold text-white">Render Machines</h1>
<p className="text-sm text-zinc-400 mt-1">
Worker status, render windows and the Render Now override
</p>
</div>
</div>
{isLoading ? (
<div className="text-zinc-500">Loading…</div>
) : machines.length === 0 ? (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-10 text-center text-zinc-500">
No machines registered yet — install the RenderWorker service on a workstation and it will
appear here after its first registration.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{machines.map((m) => {
const renderNowActive = m.renderNowUntil && new Date(m.renderNowUntil) > new Date();
return (
<div key={m.id} className="rounded-lg border border-zinc-800 bg-zinc-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-white font-medium">{m.name}</div>
<div className="text-xs text-zinc-500">{m.hostname}</div>
</div>
<span
className={cn("rounded-full border px-2 py-0.5 text-xs", STATUS_STYLES[m.status])}
>
{m.status}
</span>
</div>
<div className="text-xs text-zinc-500 space-y-0.5">
<div>Worker {m.workerVersion ?? "?"} · AE {m.aeVersion ?? "?"}</div>
<div>
Availability: {m.availability?.mode ?? "ALWAYS"}
{renderNowActive && (
<span className="text-amber-400">
{" "}· Render Now until {new Date(m.renderNowUntil!).toLocaleTimeString()}
</span>
)}
</div>
{m.lastSeenAt && <div>Last seen {new Date(m.lastSeenAt).toLocaleString()}</div>}
</div>
{m.latestHeartbeat && (
<div className="flex items-center gap-4 text-xs text-zinc-400">
<span className="flex items-center gap-1">
<Cpu className="h-3.5 w-3.5" />
{m.latestHeartbeat.cpuPercent != null ? `${Math.round(m.latestHeartbeat.cpuPercent)}%` : "—"}
</span>
<span className="flex items-center gap-1">
<MemoryStick className="h-3.5 w-3.5" />
{m.latestHeartbeat.memPercent != null ? `${Math.round(m.latestHeartbeat.memPercent)}%` : "—"}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3.5 w-3.5" />
{m.latestHeartbeat.diskFreeGb != null ? `${Math.round(m.latestHeartbeat.diskFreeGb)} GB free` : "—"}
</span>
</div>
)}
{m.currentJob ? (
<div className="rounded bg-zinc-800/60 p-2.5 space-y-1.5">
<div className="text-xs text-zinc-300">
Rendering{" "}
{m.currentJob.exportId ? (
<Link
href={`/pipeline/exports/${m.currentJob.exportId}`}
className="text-amber-400 hover:underline"
>
{m.currentJob.shotCode} {m.currentJob.versionString}
</Link>
) : (
"job"
)}
</div>
<Progress value={m.currentJob.progress * 100} className="h-1.5" />
</div>
) : (
<div className="text-xs text-zinc-600">Idle</div>
)}
<div className="flex items-center gap-2 pt-1">
<Button
variant="outline"
size="sm"
disabled={!m.enabled}
onClick={() =>
patch(
m.id,
{ renderNowHours: renderNowActive ? 0 : 4 },
renderNowActive ? "Render Now cleared" : "Render Now active for 4 h"
)
}
>
<Zap className={cn("h-4 w-4 mr-1.5", renderNowActive ? "text-amber-400" : "")} />
{renderNowActive ? "Stop Render Now" : "Render Now (4 h)"}
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
onClick={() =>
patch(m.id, { enabled: !m.enabled }, m.enabled ? "Machine disabled" : "Machine enabled")
}
>
<Power className={cn("h-4 w-4 mr-1.5", m.enabled ? "text-red-400" : "text-green-400")} />
{m.enabled ? "Disable" : "Enable"}
</Button>
)}
</div>
</div>
);
})}
</div>
)}
<ToolVersions />
</div>
);
}
interface ToolRow {
id: string;
tool: string;
toolLabel: string;
machineName: string;
version: string;
buildRef: string | null;
hostUser: string | null;
hostApp: string | null;
lastSeenAt: string;
newestVersion: string;
outdated: boolean;
}
/**
* What is deployed where. Each tool reports its own version on startup, and
* anything behind the newest version seen for that tool is flagged — so
* updating one machine is what surfaces the ones still to do.
*/
function ToolVersions() {
const { data } = useQuery({
queryKey: ["tool-versions"],
queryFn: async () => {
const res = await fetch("/api/tools");
if (!res.ok) throw new Error("Failed to load tool versions");
return res.json() as Promise<{ tools: ToolRow[] }>;
},
refetchInterval: 30000,
});
const tools = data?.tools ?? [];
const stale = tools.filter((t) => t.outdated).length;
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
Deployed tool versions
{stale > 0 && (
<span className="text-xs text-amber-400">
{stale} machine{stale > 1 ? "s" : ""} behind
</span>
)}
</div>
<div className="divide-y divide-zinc-800/60">
{tools.map((t) => (
<div key={t.id} className="px-4 py-2.5 text-sm flex items-center gap-3 flex-wrap">
<span className="w-44 shrink-0 text-zinc-300">{t.toolLabel}</span>
<span className="w-40 shrink-0 text-zinc-400 text-xs font-mono">{t.machineName}</span>
<span className={cn("text-xs font-mono", t.outdated ? "text-amber-400" : "text-emerald-400")}>
v{t.version}
</span>
{t.outdated && <span className="text-xs text-amber-400/80">→ rebuild to v{t.newestVersion}</span>}
{t.buildRef && <span className="text-xs text-zinc-600 font-mono">{t.buildRef}</span>}
{t.hostUser && <span className="text-xs text-zinc-600">{t.hostUser}</span>}
<span className="ml-auto text-xs text-zinc-600">
seen {new Date(t.lastSeenAt).toLocaleString()}
</span>
</div>
))}
{tools.length === 0 && (
<div className="p-4 text-sm text-zinc-500">
Nothing has reported yet. Versions appear as each panel and worker starts up.
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,12 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { MachinesClient } from "./MachinesClient";
export const dynamic = "force-dynamic";
export default async function MachinesPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
return <MachinesClient />;
}
+20
View File
@@ -0,0 +1,20 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { PipelineQueueClient } from "./PipelineQueueClient";
export const dynamic = "force-dynamic";
export default async function PipelinePage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const projects = await db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
});
return <PipelineQueueClient projects={projects} />;
}
+43
View File
@@ -0,0 +1,43 @@
/** Shared status→style maps for the pipeline pages. */
export const EXPORT_STATUS_STYLES: Record<string, string> = {
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
RENDERING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
RENDER_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
VALIDATING: "bg-sky-500/15 text-sky-400 border-sky-500/30",
VALIDATION_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
GENERATING_PREVIEW: "bg-indigo-500/15 text-indigo-400 border-indigo-500/30",
PREVIEW_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
READY_FOR_QC: "bg-amber-500/15 text-amber-400 border-amber-500/30",
QC_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
READY_FOR_DELIVERY: "bg-green-500/15 text-green-400 border-green-500/30",
PACKAGED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
SUPERSEDED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
ARCHIVED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
};
export const DELIVERY_STATUS_STYLES: Record<string, string> = {
DRAFT: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
BUILDING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
READY: "bg-green-500/15 text-green-400 border-green-500/30",
DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
};
export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
export function statusLabel(status: string): string {
return status.replaceAll("_", " ");
}
export function formatEta(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return "—";
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
@@ -58,6 +58,14 @@ interface ProjectTabsClientProps {
startDate: Date | null;
slackWebhook: string | null;
slackChannel: string | null;
pipelineConfig: {
templateAep?: string | null;
rendersRoot?: string | null;
overlayComp?: string | null;
previewComp?: string | null;
lutComp?: string | null;
autoShotThumbnail?: boolean | null;
} | null;
};
clients: Client[];
teamMembers: TeamMember[];
@@ -17,10 +17,11 @@ import {
FilePlus2,
Pencil,
Film,
FileSpreadsheet,
} from "lucide-react";
import { parseEdlCsv, parsePictureTrackerCsv } from "@/lib/edl-utils";
import type { EdlImportRow, PictureTrackerRow } from "@/lib/edl-utils";
import { importShotsFromEdl, updateShotsSeqTimecodes } from "@/actions/shots";
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv, parseGroupAssignCsv } from "@/lib/edl-utils";
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow, GroupAssignRow } from "@/lib/edl-utils";
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv, assignShotGroups } from "@/actions/shots";
interface EdlImportClientProps {
projectId: string;
@@ -54,6 +55,24 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Simple CSV state ────────────────────────────────────────────────────────
const scFileInputRef = useRef<HTMLInputElement>(null);
const [scStep, setScStep] = useState<"input" | "preview" | "result">("input");
const [scCsvText, setScCsvText] = useState("");
const [scRows, setScRows] = useState<SimpleCsvRow[]>([]);
const [scErrors, setScErrors] = useState<string[]>([]);
const [scImporting, setScImporting] = useState(false);
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Group Assignment state ───────────────────────────────────────────────
const gaFileInputRef = useRef<HTMLInputElement>(null);
const [gaStep, setGaStep] = useState<"input" | "preview" | "result">("input");
const [gaCsvText, setGaCsvText] = useState("");
const [gaRows, setGaRows] = useState<GroupAssignRow[]>([]);
const [gaErrors, setGaErrors] = useState<string[]>([]);
const [gaAssigning, setGaAssigning] = useState(false);
const [gaResult, setGaResult] = useState<{ assigned: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Picture Tracker state ─────────────────────────────────────────────────
const ptFileInputRef = useRef<HTMLInputElement>(null);
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
@@ -128,6 +147,102 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
setResult(null);
};
// ── Simple CSV handlers ─────────────────────────────────────────────────────
const handleScFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setScCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handleScParse = useCallback(() => {
const { rows: parsed, errors } = parseSimpleCsv(scCsvText, existingShotCodes);
setScErrors(errors);
setScRows(parsed);
if (parsed.length > 0) setScStep("preview");
}, [scCsvText, existingShotCodes]);
const toggleScAction = (idx: number) => {
setScRows((prev) =>
prev.map((r, i) => {
if (i !== idx) return r;
const cycle: SimpleCsvRow["action"][] = ["create", "update", "skip"];
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
return { ...r, action: next };
})
);
};
const handleScImport = async () => {
setScImporting(true);
try {
const res = await importShotsFromSimpleCsv(projectId, scRows);
setScResult(res);
setScStep("result");
if (res.created.length + res.updated.length > 0) {
toast({
title: "Import complete",
description: `${res.created.length} created, ${res.updated.length} updated`,
});
}
} catch (e) {
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setScImporting(false);
}
};
const handleScReset = () => {
setScStep("input");
setScCsvText("");
setScRows([]);
setScErrors([]);
setScResult(null);
};
// ── Group Assignment handlers ────────────────────────────────────────────
const handleGaFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setGaCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handleGaParse = useCallback(() => {
const { rows: parsed, errors } = parseGroupAssignCsv(gaCsvText, existingShotCodes);
setGaErrors(errors);
setGaRows(parsed);
if (parsed.length > 0) setGaStep("preview");
}, [gaCsvText, existingShotCodes]);
const handleGaAssign = async () => {
setGaAssigning(true);
try {
const res = await assignShotGroups(projectId, gaRows);
setGaResult(res);
setGaStep("result");
if (res.assigned.length > 0) {
toast({ title: "Groups assigned", description: `${res.assigned.length} shot${res.assigned.length !== 1 ? "s" : ""} updated` });
}
} catch (e) {
toast({ title: "Assignment failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setGaAssigning(false);
}
};
const handleGaReset = () => {
setGaStep("input");
setGaCsvText("");
setGaRows([]);
setGaErrors([]);
setGaResult(null);
};
// ── Picture Tracker handlers ──────────────────────────────────────────────
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -426,6 +541,355 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
</div>
)}
{/* ── SIMPLE CSV PANEL ──────────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Import Shots from Simple CSV</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Create or update shots from a CSV with <span className="font-mono text-zinc-300">Shot Name</span>, <span className="font-mono text-zinc-300">Time Code In</span>, <span className="font-mono text-zinc-300">Time Code Out</span>, <span className="font-mono text-zinc-300">Description</span>.
</p>
</div>
{scStep === "input" && (
<div className="space-y-4">
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
<div className="flex items-center gap-2">
<input ref={scFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleScFileUpload} />
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => scFileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={scCsvText}
onChange={(e) => setScCsvText(e.target.value)}
placeholder={`Shot Name,Time Code In,Time Code Out,Description\nUNG_108_001_010,01:00:10:00,01:00:20:00,Hero wide shot`}
className="font-mono text-xs min-h-[200px] bg-zinc-950 border-zinc-700 resize-y"
spellCheck={false}
/>
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
<p className="font-medium text-zinc-300">Expected format</p>
<p>Required: <span className="font-mono text-amber-400">Shot Name</span></p>
<p>Optional: <span className="font-mono text-zinc-400">Time Code In, Time Code Out, Description</span></p>
<p>Existing shots will be set to <span className="font-mono text-blue-400">update</span>; new shots to <span className="font-mono text-emerald-400">create</span>. Toggle per row in the preview.</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleScParse} disabled={!scCsvText.trim()} className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{scStep === "preview" && (() => {
const scCreate = scRows.filter((r) => r.action === "create").length;
const scUpdate = scRows.filter((r) => r.action === "update").length;
const scSkip = scRows.filter((r) => r.action === "skip").length;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
<FilePlus2 className="h-3.5 w-3.5" />
{scCreate} to create
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
<Pencil className="h-3.5 w-3.5" />
{scUpdate} to update
</div>
{scSkip > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
<SkipForward className="h-3.5 w-3.5" />
{scSkip} to skip
</div>
)}
</div>
{scErrors.length > 0 && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5" /> {scErrors.length} parse warning{scErrors.length !== 1 ? "s" : ""}
</p>
{scErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
</div>
)}
<div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
<span>Action</span>
<span>Shot Code</span>
<span>Seq TC In</span>
<span>Seq TC Out</span>
<span>Group</span>
<span>Description</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{scRows.map((row, i) => {
const ActionIcon = ACTION_ICONS[row.action];
return (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] items-center gap-4 px-4 py-3 text-sm">
<button
onClick={() => toggleScAction(i)}
title="Click to cycle: create → update → skip"
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
ACTION_STYLES[row.action]
)}
>
<ActionIcon className="h-3 w-3" />
{row.action}
</button>
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeStart || "—"}</span>
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span>
<span className="text-xs text-zinc-300 truncate">{row.group || <span className="text-zinc-600">none</span>}</span>
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleScReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handleScImport}
disabled={scImporting || (scCreate + scUpdate === 0)}
className="gap-2"
>
{scImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{scImporting ? "Importing…" : `Import ${scCreate + scUpdate} shot${scCreate + scUpdate !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
);
})()}
{scStep === "result" && scResult && (
<div className="space-y-4">
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
Import complete
</h2>
{scResult.created.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">Created ({scResult.created.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.created.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
))}
</div>
</div>
)}
{scResult.updated.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Updated ({scResult.updated.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.updated.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
))}
</div>
</div>
)}
{scResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped ({scResult.skipped.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.skipped.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
))}
</div>
</div>
)}
{scResult.errors.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5" /> Errors ({scResult.errors.length})
</p>
{scResult.errors.map((e, i) => (
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleScReset}>Import Another</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
</div>
</div>
)}
</div>
{/* ── GROUP ASSIGNMENT PANEL ─────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Assign Shots to Groups</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Bulk-assign existing shots to groups from a CSV with <span className="font-mono text-zinc-300">Shot Name</span> and <span className="font-mono text-zinc-300">Group</span> columns. Groups are created automatically if they don’t exist.
</p>
</div>
{gaStep === "input" && (
<div className="space-y-4">
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
<div className="flex items-center gap-2">
<input ref={gaFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleGaFileUpload} />
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => gaFileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={gaCsvText}
onChange={(e) => setGaCsvText(e.target.value)}
placeholder={`Shot Name,Group\nUNG_108_001_010,Action Sequences\nUNG_108_002_020,Compositing`}
className="font-mono text-xs min-h-[180px] bg-zinc-950 border-zinc-700 resize-y"
spellCheck={false}
/>
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
<p className="font-medium text-zinc-300">Expected format</p>
<p>Required: <span className="font-mono text-amber-400">Shot Name</span>, <span className="font-mono text-amber-400">Group</span></p>
<p>Only shots that already exist in this project are updated. Unknown shots are skipped.</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleGaParse} disabled={!gaCsvText.trim()} className="gap-2">
<Film className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{gaStep === "preview" && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
<Pencil className="h-3.5 w-3.5" />
{gaRows.filter((r) => r.exists).length} to assign
</div>
{gaRows.filter((r) => !r.exists).length > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
<SkipForward className="h-3.5 w-3.5" />
{gaRows.filter((r) => !r.exists).length} not found (will skip)
</div>
)}
</div>
{gaErrors.length > 0 && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5" /> {gaErrors.length} parse warning{gaErrors.length !== 1 ? "s" : ""}
</p>
{gaErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
</div>
)}
<div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[1fr_1fr_auto] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
<span>Shot Code</span>
<span>Group</span>
<span>Status</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{gaRows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm">
<span className="font-mono text-xs text-zinc-200">{row.shotCode}</span>
<span className="text-xs text-zinc-300">{row.groupName}</span>
{row.exists ? (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-blue-500/10 text-blue-400 border-blue-500/20">
<Pencil className="h-3 w-3" /> assign
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-zinc-500/10 text-zinc-400 border-zinc-500/20">
<SkipForward className="h-3 w-3" /> skip
</span>
)}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleGaReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handleGaAssign}
disabled={gaAssigning || gaRows.filter((r) => r.exists).length === 0}
className="gap-2"
>
{gaAssigning ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{gaAssigning
? "Assigning…"
: `Assign ${gaRows.filter((r) => r.exists).length} shot${gaRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
)}
{gaStep === "result" && gaResult && (
<div className="space-y-4">
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
Group assignment complete
</h2>
{gaResult.assigned.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Assigned ({gaResult.assigned.length})</p>
<div className="flex flex-wrap gap-1.5">
{gaResult.assigned.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
))}
</div>
</div>
)}
{gaResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped / not found ({gaResult.skipped.length})</p>
<div className="flex flex-wrap gap-1.5">
{gaResult.skipped.map((c) => (
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
))}
</div>
</div>
)}
{gaResult.errors.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5" /> Errors ({gaResult.errors.length})
</p>
{gaResult.errors.map((e, i) => (
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleGaReset}>Import Another</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
</div>
</div>
)}
</div>
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
@@ -41,7 +41,7 @@ export default async function KanbanPage({
where: { projectId: id },
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
+9 -1
View File
@@ -60,7 +60,7 @@ async function getProject(id: string) {
tasks: {
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
@@ -201,6 +201,14 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
startDate: project.startDate,
slackWebhook: project.slackWebhook,
slackChannel: project.slackChannel,
pipelineConfig: project.pipelineConfig as {
templateAep?: string | null;
rendersRoot?: string | null;
overlayComp?: string | null;
previewComp?: string | null;
lutComp?: string | null;
autoShotThumbnail?: boolean | null;
} | null,
}}
clients={clients}
teamMembers={teamMembers}
@@ -30,10 +30,13 @@ import {
FileVideo,
Pencil,
Check,
LayoutList,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
import { ShotSlateTab } from "@/components/shots/ShotSlateTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
@@ -73,6 +76,7 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
};
import { useToast } from "@/components/ui/use-toast";
import { shotThumbnail } from "@/lib/shot-thumbnail";
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
@@ -89,7 +93,7 @@ export default function ShotDetailPage() {
const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
const [activeTab, setActiveTab] = useState<"tasks" | "slate" | "reviews" | "footage" | "exports" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false);
@@ -248,11 +252,11 @@ export default function ShotDetailPage() {
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{shot.thumbnailUrl && (
{shotThumbnail(shot) && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shot.thumbnailUrl}
src={shotThumbnail(shot)!}
alt={shot.shotCode}
fill
className="object-cover"
@@ -519,6 +523,18 @@ export default function ShotDetailPage() {
<ListTodo className="h-4 w-4" />
Tasks
</button>
<button
onClick={() => setActiveTab("slate")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "slate"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<LayoutList className="h-4 w-4" />
Slate
</button>
<button
onClick={() => setActiveTab("reviews")}
className={cn(
@@ -543,6 +559,18 @@ export default function ShotDetailPage() {
<Video className="h-4 w-4" />
Footage
</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"
)}
>
<Film className="h-4 w-4" />
Exports
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
@@ -570,6 +598,10 @@ export default function ShotDetailPage() {
/>
)}
{activeTab === "slate" && (
<ShotSlateTab shot={shot} projectName={projectName} onSaved={fetchShot} />
)}
{activeTab === "reviews" && (
<div className="space-y-3">
{tasks.length === 0 ? (
@@ -658,6 +690,8 @@ export default function ShotDetailPage() {
/>
)}
{activeTab === "exports" && <ShotExportsTab shotId={shot.id} />}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
+12 -1
View File
@@ -7,10 +7,16 @@ import { Input } from "@/components/ui/input";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
import { Plus, Search, Loader2 } from "lucide-react";
import { HIDE_ARCHIVED_KEY } from "@/components/settings/HideArchivedToggle";
export default function ProjectsPage() {
const [search, setSearch] = useState("");
const [showNew, setShowNew] = useState(false);
const [hideArchived, setHideArchived] = useState(false);
useEffect(() => {
setHideArchived(localStorage.getItem(HIDE_ARCHIVED_KEY) === "1");
}, []);
const { data, isLoading } = useQuery({
queryKey: ["projects", search],
@@ -35,6 +41,8 @@ export default function ProjectsPage() {
const projects = data?.projects ?? [];
const clients = clientsData?.clients ?? [];
const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects;
const SCROLL_KEY = 'projects-scroll';
useEffect(() => {
@@ -56,6 +64,9 @@ export default function ProjectsPage() {
<h1 className="text-3xl font-bold text-white">Projects</h1>
<p className="text-zinc-400 mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
{hideArchived && projects.length !== visibleProjects.length && (
<span className="text-zinc-600 ml-1">({projects.length - visibleProjects.length} archived hidden)</span>
)}
</p>
</div>
<Button onClick={() => setShowNew(true)} className="gap-2">
@@ -94,7 +105,7 @@ export default function ProjectsPage() {
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map((project) => (
{visibleProjects.map((project: any) => (
<div key={project.id} onClick={saveScroll}>
<ProjectCard project={project} />
</div>
@@ -53,7 +53,12 @@ export interface ScheduleTask {
email: string;
image: string | null;
} | null;
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
shot: {
id: string;
shotCode: string;
thumbnailUrl: string | null;
generatedThumbnailUrl: string | null;
} | null;
asset: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
}
+2 -2
View File
@@ -31,7 +31,7 @@ export default async function SchedulePage({
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
@@ -49,7 +49,7 @@ export default async function SchedulePage({
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
+10
View File
@@ -5,6 +5,7 @@ import { getInitials } from "@/lib/utils";
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
import { HideArchivedToggle } from "@/components/settings/HideArchivedToggle";
import { getHetznerConfig } from "@/actions/settings";
export const metadata = { title: "Settings" };
@@ -42,6 +43,15 @@ export default async function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Display Preferences</CardTitle>
</CardHeader>
<CardContent>
<HideArchivedToggle />
</CardContent>
</Card>
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
{isAdmin && hetznerConfig && (
+537 -20
View File
@@ -18,11 +18,19 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { updateShotNotes, bulkUpdateDueDates, toggleKeyShot } from "@/actions/shots";
import { updateShotNotes, bulkUpdateDueDates, bulkDeleteShots, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
import { useToast } from "@/components/ui/use-toast";
import {
Film,
@@ -38,8 +46,13 @@ import {
ShieldCheck,
Eye,
Star,
Trash2,
ChevronsUpDown,
Clapperboard,
Scaling,
} from "lucide-react";
import { format } from "date-fns";
import { shotThumbnail } from "@/lib/shot-thumbnail";
type ShotRow = {
id: string;
@@ -51,10 +64,14 @@ type ShotRow = {
priority: string;
dueDate: Date | string | null;
thumbnailUrl: string | null;
generatedThumbnailUrl: string | null;
notes: string | null;
description: string | null;
isKeyShot: boolean;
isScaled: boolean;
shotVersion: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
shotGroup: { id: string; name: string } | null;
};
interface Project {
@@ -208,6 +225,203 @@ function BulkDueDateDialog({
);
}
function BulkDeleteDialog({
open,
onClose,
onConfirm,
count,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
count: number;
}) {
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
startTransition(async () => {
await onConfirm();
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-red-400">Delete {count} shot{count !== 1 ? "s" : ""}?</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-300">
This will permanently delete <span className="font-semibold text-white">{count}</span>{" "}
shot{count !== 1 ? "s" : ""}, including all tasks, versions, footage, and annotations.
This cannot be undone.
</p>
<p className="text-xs text-zinc-500">
A shot with renders or deliveries already on record is protected and will be skipped
rather than deleted — you'll see which ones and why.
</p>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{isPending ? "Deleting…" : `Yes, delete ${count} shot${count !== 1 ? "s" : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── MovReviewDialog ───────────────────────────────────────────────────────────
type ReviewPreview = Awaited<ReturnType<typeof previewMovReview>>;
/**
* Confirm what a MOV review package would contain before building it
* (RenderPipeline2 §11.6).
*
* The version each shot resolves to is the newest one that actually has a
* preview MOV, which is not always the newest one full stop — so the dialog
* names the version per shot rather than making anyone guess, and lists the
* shots with no MOV at all instead of quietly building a smaller folder.
*/
function MovReviewDialog({
open,
onClose,
onBuilt,
shotIds,
}: {
open: boolean;
onClose: () => void;
onBuilt: () => void;
shotIds: string[];
}) {
const { toast } = useToast();
const [preview, setPreview] = useState<ReviewPreview | null>(null);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!open) {
setPreview(null);
return;
}
let cancelled = false;
setLoading(true);
previewMovReview(shotIds)
.then((result) => {
if (!cancelled) setPreview(result);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const ready = preview?.ok ? preview.candidates : [];
const skipped = preview?.ok ? preview.skipped : [];
const handleBuild = () => {
startTransition(async () => {
const result = await buildMovReview(shotIds);
if (!result.ok) {
toast({ title: "Could not build review package", description: result.error, variant: "destructive" });
return;
}
toast({
title: `${result.package.name} queued`,
description: `${result.package.itemCount} MOV${result.package.itemCount !== 1 ? "s" : ""} → ${result.package.rootPath}`,
});
onBuilt();
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Build MOV review package</DialogTitle>
</DialogHeader>
{loading && (
<p className="text-sm text-zinc-400 flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Working out what each shot would contribute…
</p>
)}
{!loading && preview && !preview.ok && (
<p className="text-sm text-red-400">{preview.error}</p>
)}
{!loading && preview?.ok && (
<div className="space-y-4">
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 px-3 py-2">
<div className="text-xs text-zinc-500">Folder</div>
<div className="text-sm text-white font-medium break-all">{preview.packageName}</div>
<div className="text-xs text-zinc-500 break-all">{preview.packageDir}</div>
</div>
{ready.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-zinc-400">
{ready.length} MOV{ready.length !== 1 ? "s" : ""}
</div>
<div className="max-h-52 overflow-y-auto rounded-md border border-zinc-800 divide-y divide-zinc-800/60">
{ready.map((c) => (
<div key={c.shotId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<span className="text-zinc-200">{c.shotCode}</span>
<span className="text-zinc-500">{c.versionString}</span>
<span className="ml-auto text-zinc-600 truncate">{c.deliveredName}</span>
</div>
))}
</div>
</div>
)}
{skipped.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-amber-400">
{skipped.length} shot{skipped.length !== 1 ? "s" : ""} left out
</div>
<div className="max-h-32 overflow-y-auto rounded-md border border-amber-500/20 bg-amber-500/5 divide-y divide-amber-500/10">
{skipped.map((sk) => (
<div key={sk.shotId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<span className="text-zinc-300">{sk.shotCode}</span>
<span className="text-amber-400/80">{sk.reason}</span>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-zinc-500">
A render machine builds the folder. Nothing about these shots changes — a review is
not a delivery, so the versions stay exactly where they are in the pipeline.
</p>
</div>
)}
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleBuild} disabled={isPending || loading || ready.length === 0}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{isPending ? "Queueing…" : `Build with ${ready.length} MOV${ready.length !== 1 ? "s" : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── NotesCell ─────────────────────────────────────────────────────────────────
function NotesCell({
@@ -263,10 +477,30 @@ function NotesCell({
);
}
// ── KeyShotToggle ─────────────────────────────────────────────────────────────
// ── Shot flag toggles ─────────────────────────────────────────────────────────
function KeyShotToggle({ shot }: { shot: ShotRow }) {
const [optimistic, setOptimistic] = useState(shot.isKeyShot);
/**
* A standing yes/no property of a shot, flipped straight from the board.
* Optimistic: the switch moves immediately and rolls back if the save fails,
* because a producer ticking twenty shots should not wait on a round trip each
* time.
*/
function FlagToggle({
value,
onSet,
onLabel,
offLabel,
failureTitle,
tone,
}: {
value: boolean;
onSet: (next: boolean) => Promise<unknown>;
onLabel: string;
offLabel: string;
failureTitle: string;
tone: { on: string; ring: string };
}) {
const [optimistic, setOptimistic] = useState(value);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
@@ -275,10 +509,10 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
setOptimistic(next);
startTransition(async () => {
try {
await toggleKeyShot(shot.id, next);
await onSet(next);
} catch {
setOptimistic(!next);
toast({ title: "Failed to update key shot", variant: "destructive" });
toast({ title: failureTitle, variant: "destructive" });
}
});
};
@@ -288,12 +522,13 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
type="button"
role="switch"
aria-checked={optimistic}
aria-label={optimistic ? "Remove key shot" : "Mark as key shot"}
aria-label={optimistic ? offLabel : onLabel}
disabled={isPending}
onClick={handle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 disabled:opacity-50",
optimistic ? "bg-amber-500" : "bg-zinc-600 hover:bg-zinc-500"
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 disabled:opacity-50",
tone.ring,
optimistic ? tone.on : "bg-zinc-600 hover:bg-zinc-500"
)}
>
<span
@@ -306,6 +541,32 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
);
}
function KeyShotToggle({ shot }: { shot: ShotRow }) {
return (
<FlagToggle
value={shot.isKeyShot}
onSet={(next) => toggleKeyShot(shot.id, next)}
onLabel="Mark as key shot"
offLabel="Remove key shot"
failureTitle="Failed to update key shot"
tone={{ on: "bg-amber-500", ring: "focus-visible:ring-amber-400" }}
/>
);
}
function ScaledShotToggle({ shot }: { shot: ShotRow }) {
return (
<FlagToggle
value={shot.isScaled}
onSet={(next) => toggleScaledShot(shot.id, next)}
onLabel="Mark as scaled in the edit"
offLabel="Remove scaled flag"
failureTitle="Failed to update scaled flag"
tone={{ on: "bg-cyan-500", ring: "focus-visible:ring-cyan-400" }}
/>
);
}
function ShotTable({
shots,
canManage,
@@ -348,6 +609,12 @@ function ShotTable({
Key
</span>
</th>
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
<span className="flex items-center gap-1 justify-center">
<Scaling className="h-3 w-3" />
Scaled
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
@@ -381,9 +648,9 @@ function ShotTable({
{/* Thumbnail */}
<td className="py-2 px-3">
<div className="w-24 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
{shot.thumbnailUrl ? (
{shotThumbnail(shot) ? (
<Image
src={shot.thumbnailUrl}
src={shotThumbnail(shot)!}
alt={shot.shotCode}
width={96}
height={40}
@@ -408,6 +675,15 @@ function ShotTable({
>
{shot.shotCode}
</Link>
{shot.shotVersion && (
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
)}
{shot.isScaled && (
<span className="flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300 shrink-0">
<Scaling className="h-2.5 w-2.5" />
Scaled
</span>
)}
</div>
{shot.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
@@ -450,6 +726,11 @@ function ShotTable({
<td className="py-2 px-3 text-center">
<KeyShotToggle shot={shot} />
</td>
{/* Scaled-in-edit toggle */}
<td className="py-2 px-3 text-center">
<ScaledShotToggle shot={shot} />
</td>
</tr>
);
})}
@@ -490,6 +771,9 @@ export function ShotStatusClient({
}, []);
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Load saved episode expand state when project changes
@@ -501,8 +785,16 @@ export function ShotStatusClient({
} catch {
setExpandedEpisodes(new Set());
}
try {
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
} catch {
setGroupMode("flat");
}
}, [selectedProjectId]);
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
const toggleEpisode = (ep: string) =>
setExpandedEpisodes((prev) => {
@@ -516,6 +808,29 @@ export function ShotStatusClient({
return next;
});
const toggleScene = (sc: string) =>
setExpandedScenes((prev) => {
const next = new Set(prev);
next.has(sc) ? next.delete(sc) : next.add(sc);
return next;
});
const toggleGroup = (g: string) =>
setExpandedGroups((prev) => {
const next = new Set(prev);
next.has(g) ? next.delete(g) : next.add(g);
return next;
});
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
setGroupMode(mode);
if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene)));
if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? "")));
if (selectedProjectId) {
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
}
};
const handleProjectChange = (id: string) => {
localStorage.setItem("shotStatus:lastProjectId", id);
setSelectedIds(new Set());
@@ -553,6 +868,26 @@ export function ShotStatusClient({
}
};
const handleBulkDelete = async () => {
try {
const result = await bulkDeleteShots([...selectedIds]);
if (result.failed.length > 0) {
toast({
title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}, ${result.failed.length} skipped`,
description: result.failed.map((f) => `${f.shotCode}: ${f.reason}`).join("; "),
variant: "destructive",
});
} else {
toast({ title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}` });
}
setDeleteDialogOpen(false);
clearSelection();
router.refresh();
} catch (e) {
toast({ title: "Failed to delete shots", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
}
};
// Group by episode for episodic projects
const isEpisodic = selectedProject?.projectType === "EPISODIC";
const episodeGroups: [string, ShotRow[]][] = isEpisodic
@@ -567,6 +902,31 @@ export function ShotStatusClient({
})()
: [];
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
const key = shot.scene || "(No Scene)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
? (() => {
const map = new Map<string, [string, ShotRow[]]>();
for (const shot of shots) {
const id = shot.shotGroup?.id ?? "";
const name = shot.shotGroup?.name ?? "(No Group)";
if (!map.has(id)) map.set(id, [name, []]);
map.get(id)![1].push(shot);
}
return Array.from(map.entries()).map(([id, [name, s]]) => [id, name, s] as [string, string, ShotRow[]]);
})()
: [];
const episodeDueDateMap = new Map(
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
);
@@ -605,6 +965,25 @@ export function ShotStatusClient({
))}
</SelectContent>
</Select>
{selectedProject && !isEpisodic && (
<>
<span className="text-sm text-zinc-500">Group by:</span>
{(["flat", "scene", "group"] as const).map((mode) => (
<button
key={mode}
onClick={() => handleGroupMode(mode)}
className={cn(
"px-3 py-1 rounded text-xs font-medium transition-colors",
groupMode === mode
? "bg-zinc-700 text-zinc-100"
: "text-zinc-500 hover:text-zinc-300"
)}
>
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
</button>
))}
</>
)}
</div>
{/* No project selected */}
@@ -645,15 +1024,36 @@ export function ShotStatusClient({
<span className="text-sm font-medium text-blue-300">
{selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected
</span>
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
onClick={() => setDueDateDialogOpen(true)}
>
<CalendarDays className="h-3.5 w-3.5" />
Change Due Date
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
>
Bulk Actions
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setDueDateDialogOpen(true)}>
<CalendarDays className="h-3.5 w-3.5 mr-2" />
Change Due Date
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMovReviewDialogOpen(true)}>
<Clapperboard className="h-3.5 w-3.5 mr-2" />
Build MOV Review Package
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
className="text-red-400 focus:text-red-300"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Delete Shots
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={clearSelection}
className="text-zinc-500 hover:text-zinc-300 transition-colors"
@@ -727,6 +1127,104 @@ export function ShotStatusClient({
);
})}
</div>
) : groupMode === "scene" ? (
<div className="divide-y divide-zinc-800">
{sceneGroups.map(([scene, sceneShots]) => {
const collapsed = !expandedScenes.has(scene);
const sceneIds = sceneShots.map((s) => s.id);
const allScSelected = sceneIds.every((id) => selectedIds.has(id));
const someScSelected = sceneIds.some((id) => selectedIds.has(id));
return (
<div key={scene}>
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
{canManage && (
<IndeterminateCheckbox
checked={allScSelected}
indeterminate={someScSelected && !allScSelected}
onChange={(e) => toggleMany(sceneIds, e.target.checked)}
className="shrink-0"
/>
)}
<button
onClick={() => toggleScene(scene)}
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">Scene {scene}</span>
<span className="text-xs text-zinc-500 font-normal">
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={sceneShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : groupMode === "group" ? (
<div className="divide-y divide-zinc-800">
{shotGroupGroups.map(([groupId, groupName, groupShots]) => {
const collapsed = !expandedGroups.has(groupId);
const groupIds = groupShots.map((s) => s.id);
const allGrSelected = groupIds.every((id) => selectedIds.has(id));
const someGrSelected = groupIds.some((id) => selectedIds.has(id));
return (
<div key={groupId}>
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
{canManage && (
<IndeterminateCheckbox
checked={allGrSelected}
indeterminate={someGrSelected && !allGrSelected}
onChange={(e) => toggleMany(groupIds, e.target.checked)}
className="shrink-0"
/>
)}
<button
onClick={() => toggleGroup(groupId)}
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">{groupName}</span>
<span className="text-xs text-zinc-500 font-normal">
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={groupShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto">
<ShotTable
@@ -750,6 +1248,25 @@ export function ShotStatusClient({
onApply={handleBulkDueDate}
count={selectionCount}
/>
{/* Bulk delete dialog */}
<BulkDeleteDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={handleBulkDelete}
count={selectionCount}
/>
{/* MOV review package (§11.6) */}
<MovReviewDialog
open={movReviewDialogOpen}
onClose={() => setMovReviewDialogOpen(false)}
onBuilt={() => {
setMovReviewDialogOpen(false);
clearSelection();
}}
shotIds={[...selectedIds]}
/>
</div>
);
}
+6
View File
@@ -37,10 +37,16 @@ async function getShotsForProject(projectId: string) {
priority: true,
dueDate: true,
thumbnailUrl: true,
generatedThumbnailUrl: true,
notes: true,
description: true,
isKeyShot: true,
isScaled: true,
// The shot-level version the panel burns into overlays and queueExport
// renders under — not the count of review Versions uploaded.
shotVersion: true,
artist: { select: { id: true, name: true, image: true, email: true } },
shotGroup: { select: { id: true, name: true } },
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]);
export interface ThumbnailPreviewItem {
fileName: string;
stemName: string;
shotCode: string | null;
shotId: string | null;
status: "match" | "no-match";
currentThumbnailUrl: string | null;
}
function stem(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return (dot > 0 ? fileName.slice(0, dot) : fileName).toLowerCase();
}
/**
* POST /api/batch-upload/thumbnails
* Body: { projectId: string; fileNames: string[] }
*/
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let body: { projectId?: string; fileNames?: string[] };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { projectId, fileNames } = body;
if (!projectId || !Array.isArray(fileNames)) {
return NextResponse.json({ error: "projectId and fileNames are required" }, { status: 400 });
}
// Only image files
const imageFiles = fileNames.filter((f) => {
const ext = f.split(".").pop()?.toLowerCase() ?? "";
return IMAGE_EXTS.has(ext);
});
const shots = await db.shot.findMany({
where: { projectId },
select: { id: true, shotCode: true, thumbnailUrl: true },
});
// Build a lowercase map for case-insensitive matching
const shotMap = new Map(shots.map((s) => [s.shotCode.toLowerCase(), s]));
const items: ThumbnailPreviewItem[] = fileNames.map((fileName) => {
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
if (!IMAGE_EXTS.has(ext)) {
return { fileName, stemName: stem(fileName), shotCode: null, shotId: null, status: "no-match" as const, currentThumbnailUrl: null };
}
const s = stem(fileName);
const shot = shotMap.get(s) ?? null;
return {
fileName,
stemName: s,
shotCode: shot?.shotCode ?? null,
shotId: shot?.id ?? null,
status: shot ? "match" : "no-match",
currentThumbnailUrl: shot?.thumbnailUrl ?? null,
};
});
return NextResponse.json({ items });
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadToHetzner } from "@/lib/storage";
import { revalidatePath } from "next/cache";
/**
* POST /api/batch-upload/thumbnails/upload
* FormData: { projectId, shotId, file (image) }
*/
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
const shotId = formData.get("shotId") as string | null;
const projectId = formData.get("projectId") as string | null;
if (!file || !shotId || !projectId) {
return NextResponse.json({ error: "file, shotId and projectId are required" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
}
const shot = await db.shot.findFirst({ where: { id: shotId, projectId }, select: { id: true } });
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
const buffer = Buffer.from(await file.arrayBuffer());
const { key } = await uploadToHetzner(buffer, file.name, file.type, "image");
const thumbnailUrl = `/api/files/${key}`;
await db.shot.update({ where: { id: shotId }, data: { thumbnailUrl } });
revalidatePath(`/projects/${projectId}`);
return NextResponse.json({ success: true, thumbnailUrl });
}
+9 -3
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
import { recalcShotStatus } from "@/lib/shot-status";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
export const maxDuration = 300; // 5 min — large .mov high-res files
@@ -194,8 +194,14 @@ export async function POST(
data: { status: "INTERNAL_REVIEW" },
});
// Recalculate the parent shot's status
await recalcShotStatus(shotId).catch(() => {});
// Recalculate the parent shot's status, clearing any stale sign-off first so
// the new version does not land under a shot still reading COMPLETE.
await db
.$transaction(async (tx) => {
await clearStaleShotApproval(tx, shotId);
await recalcShotStatus(shotId, tx);
})
.catch(() => {});
return NextResponse.json({
success: true,
+43 -27
View File
@@ -1,21 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import { validateReviewToken } from "@/lib/review-auth";
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
export async function POST(
req: NextRequest,
@@ -48,9 +35,12 @@ export async function POST(
}
if (action === "APPROVE") {
await db.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED" },
});
await recalcShotStatus(shotId, tx);
});
} else if (action === "NEEDS_CHANGES") {
// Increment shotVersion: v001 → v002, v009 → v010, etc.
@@ -59,17 +49,41 @@ export async function POST(
const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2;
const nextVersion = "v" + String(nextNum).padStart(3, "0");
const user = await getOrCreateReviewUser(session, token);
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion },
data: { shotVersion: nextVersion },
});
// Mark all non-DONE tasks as CHANGES
await tx.task.updateMany({
where: { shotId, status: { not: "DONE" } },
data: { status: "CHANGES" },
// Clears the approval, unshares, marks the tasks needing rework and
// recalculates — including the case where every task was already DONE,
// which used to match nothing and leave the request with no effect.
await markShotForRevisions(tx, shotId);
// The shot-level verdict has to land on the versions too: the dashboard
// counters, the version badges and the client portal's own filters all
// read Version.approvalStatus, so a shot-level change request was
// invisible everywhere those look.
const shown = await tx.version.findMany({
where: { task: { shotId }, isClientVisible: true, isLatest: true },
select: { id: true },
});
await recalcShotStatus(shotId, tx);
for (const v of shown) {
await tx.approval.create({
data: {
versionId: v.id,
userId: user.id,
status: "NEEDS_CHANGES",
notes: notes ?? "Changes requested on the shot",
},
});
await tx.version.update({
where: { id: v.id },
data: { approvalStatus: "NEEDS_CHANGES" },
});
}
});
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
@@ -99,8 +113,7 @@ export async function POST(
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
const user = await getOrCreateReviewUser(session, token);
// Record approval
await db.approval.create({
@@ -126,9 +139,12 @@ export async function POST(
select: { sharedWithClient: true, shotApprovalStatus: true },
});
if (shot?.sharedWithClient && shot.shotApprovalStatus !== "CLIENT_APPROVED") {
// status is left to the recalc below — writing COMPLETE here was
// overwritten by it anyway, and disagreed with it whenever another
// task on the shot was still on CHANGES.
await db.shot.update({
where: { id: version.task.shot.id },
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
data: { shotApprovalStatus: "CLIENT_APPROVED" },
});
}
}
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
/**
* Edit and delete for notes left through a client review link.
*
* A reviewer may only touch their own notes: the studio's comments and those of
* any other review session are read-only here, whatever the link holder sends.
* Ownership is the review session's guest User (lib/review-auth.ts) — the link
* is the credential, so everyone holding it shares that identity.
*/
/** Matches the internal addComment/editComment limits (actions/comments.ts). */
const MAX_LENGTH = 5000;
/** The annotation companion comments written by the drawing tools. */
const ANNOTATION_PREFIX = "✏️ Annotation at frame";
type Ctx = { params: Promise<{ token: string; commentId: string }> };
/**
* Resolves the request to a comment the caller is allowed to change, or the
* response explaining why not.
*
* "Not yours" is deliberately answered with 404 rather than 403: a link holder
* has no business learning which comment ids exist outside their own notes.
*/
async function authorizeCommentEdit(req: NextRequest, ctx: Ctx) {
const { token, commentId } = await ctx.params;
const result = await validateReviewToken(token, req);
if (result.type === "requiresPassword") {
return { error: NextResponse.json({ requiresPassword: true }, { status: 401 }) };
}
if (result.type === "invalid") {
return {
error: NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }),
};
}
const session = result.session;
const notFound = NextResponse.json({ error: "Comment not found" }, { status: 404 });
const comment = await db.comment.findUnique({
where: { id: commentId },
select: {
id: true,
authorId: true,
versionId: true,
frameNumber: true,
text: true,
version: {
select: {
shot: { select: { projectId: true } },
task: { select: { projectId: true } },
},
},
},
});
if (!comment) return { error: notFound };
// Same project scoping the rest of the portal applies.
const projectId = comment.version.shot?.projectId ?? comment.version.task?.projectId;
if (projectId !== session.projectId) return { error: notFound };
const user = await findReviewUser(session, token);
if (!ownsComment(comment, user?.id)) return { error: notFound };
return { comment };
}
/** PATCH — change the text of one's own note. */
export async function PATCH(req: NextRequest, ctx: Ctx) {
const authorized = await authorizeCommentEdit(req, ctx);
if (authorized.error) return authorized.error;
const { comment } = authorized;
const body = await req.json().catch(() => null);
const text = typeof body?.text === "string" ? body.text.trim() : "";
if (!text) {
return NextResponse.json({ error: "Note text is required" }, { status: 400 });
}
if (text.length > MAX_LENGTH) {
return NextResponse.json(
{ error: `Note must be ${MAX_LENGTH} characters or fewer` },
{ status: 400 }
);
}
// Only the text is editable. The frame a note is pinned to is what the studio
// works from, so moving it would silently repoint feedback someone may have
// already actioned; a note about a different frame is a new note.
const updated = await db.comment.update({
where: { id: comment.id },
data: { text },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: { author: { select: { id: true, name: true, image: true, email: true } } },
},
},
});
return NextResponse.json({ comment: { ...updated, canEdit: true } });
}
/** DELETE — remove one's own note. */
export async function DELETE(req: NextRequest, ctx: Ctx) {
const authorized = await authorizeCommentEdit(req, ctx);
if (authorized.error) return authorized.error;
const { comment } = authorized;
await db.comment.delete({ where: { id: comment.id } });
// A drawing leaves a companion comment; deleting the note without the strokes
// would leave marks on the frame that nothing explains. Mirrors deleteComment
// in actions/comments.ts.
if (comment.text.startsWith(ANNOTATION_PREFIX)) {
await db.annotation.deleteMany({
where: { versionId: comment.versionId, frameNumber: comment.frameNumber },
});
}
return NextResponse.json({ success: true });
}
+4 -18
View File
@@ -1,21 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack";
import { validateReviewToken } from "@/lib/review-auth";
/** Find or create a guest user for the client reviewer based on the session email */
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
export async function POST(
req: NextRequest,
@@ -52,8 +38,7 @@ export async function POST(
}
// Resolve commenter identity
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
const user = await getOrCreateReviewUser(session, token);
const comment = await db.comment.create({
data: {
@@ -85,5 +70,6 @@ export async function POST(
});
}
return NextResponse.json({ comment }, { status: 201 });
// canEdit mirrors the GET shape — a note you just wrote is always your own.
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
}
+20 -3
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken } from "@/lib/review-auth";
import { shotThumbnail } from "@/lib/shot-thumbnail";
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
export async function GET(
@@ -43,12 +45,14 @@ export async function GET(
select: {
id: true,
shotCode: true,
clientCode: true,
episode: true,
sequence: true,
description: true,
status: true,
shotApprovalStatus: true,
thumbnailUrl: true,
generatedThumbnailUrl: true,
tasks: {
where: {
versions: { some: { isClientVisible: true } },
@@ -68,7 +72,6 @@ export async function GET(
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
@@ -100,7 +103,6 @@ export async function GET(
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
@@ -115,7 +117,22 @@ export async function GET(
return NextResponse.json({
project,
shots,
// Shots the client sees resolve the same way as internally: the reference
// still first, a render-derived one only where the show supplied none.
// Everything the client reads is in their own naming: the alias replaces
// our shot code, and the same swap runs over task titles, which carry the
// code by convention ("UNG_106_010_020_cmp_TT_v004"). The internal code is
// not sent at all — there is nothing here for it to be useful for, and
// leaking it invites the client to quote it back at us.
shots: shots.map(({ generatedThumbnailUrl, clientCode, ...shot }) => ({
...shot,
shotCode: clientShotCode({ ...shot, clientCode }),
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
tasks: shot.tasks.map((task) => ({
...task,
title: clientTaskTitle(task.title, { ...shot, clientCode }),
})),
})),
assetTasks,
sessionLabel: session.label,
});
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken } from "@/lib/review-auth";
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
export async function GET(
@@ -40,7 +41,7 @@ export async function GET(
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { id: true, shotCode: true, highResKey: true, highResFilename: true } },
shot: { select: { id: true, shotCode: true, clientCode: true, highResKey: true, highResFilename: true } },
asset: { select: { assetCode: true, name: true } },
},
},
@@ -64,7 +65,7 @@ export async function GET(
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const comments = await db.comment.findMany({
const commentRows = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
@@ -78,9 +79,22 @@ export async function GET(
},
});
// Which notes this reviewer may edit or delete. Read-only: opening a review
// link should not create the guest User, so a session that has never posted
// simply owns nothing. The edit/delete route re-checks this — the flag drives
// the UI, it does not grant anything.
const reviewUser = await findReviewUser(session, token);
const comments = commentRows.map((c) => ({
...c,
canEdit: ownsComment(c, reviewUser?.id),
}));
// Resolve the shot that has the high-res file (comes from version.shot OR version.task.shot)
const highResShot = version.shot ?? (version.task as any)?.shot ?? null;
// The shot the title and breadcrumb are drawn from — either relation.
const namingShot = version.shot ?? ((version.task as any)?.shot ?? null);
const serializedVersion = {
...version,
fileSize: version.fileSize?.toString() ?? null,
@@ -92,17 +106,24 @@ export async function GET(
shot: version.shot
? {
...version.shot,
shotCode: clientShotCode(version.shot),
clientCode: undefined,
hasHighRes: !!version.shot.highResKey,
highResFilename: version.shot.highResFilename ?? null,
highResKey: undefined,
}
: null,
// Task titles carry the shot code by convention, so the player header gets
// the same swap as the shot list (lib/shot-code.ts).
task: version.task
? {
...version.task,
title: namingShot ? clientTaskTitle(version.task.title, namingShot) : version.task.title,
shot: (version.task as any)?.shot
? {
...((version.task as any).shot),
shotCode: clientShotCode((version.task as any).shot),
clientCode: undefined,
highResKey: undefined, // strip storage key
}
: null,
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { cancelDeliveryPackage } from "@/lib/render-pipeline/deliveries";
// ── POST /api/deliveries/{id}/cancel ─────────────────────────────────────────
//
// Packages are never deleted (§11.5); a mistaken one is cancelled.
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ packageId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { packageId } = await params;
const result = await cancelDeliveryPackage(packageId, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { markDelivered } from "@/lib/render-pipeline/deliveries";
// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
//
// The folder has been uploaded to the client. Moves every packaged export to
// DELIVERED — the end of the pipeline.
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ packageId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { packageId } = await params;
const result = await markDelivered(packageId, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { getReadyForDelivery } from "@/lib/render-pipeline/deliveries";
// ── GET /api/deliveries/ready?projectId=&episode= (E18) ──────────────────────
//
// READY_FOR_DELIVERY exports grouped by episode — "Episode 101 — Ready: 18 shots".
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const { searchParams } = new URL(req.url);
const projectId = searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
const result = await getReadyForDelivery(projectId, searchParams.get("episode"));
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { DeliveryKind, DeliveryStatus } from "@prisma/client";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser, requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import {
createDeliveryPackage,
createDeliverySchema,
listDeliveryPackages,
} from "@/lib/render-pipeline/deliveries";
// ── GET /api/deliveries (E17) · POST /api/deliveries (E16) ───────────────────
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const { searchParams } = new URL(req.url);
const status = searchParams.get("status");
const kind = searchParams.get("kind");
const result = await listDeliveryPackages({
projectId: searchParams.get("projectId") ?? undefined,
episode: searchParams.get("episode") ?? undefined,
status: status ? (status as DeliveryStatus) : undefined,
kind: kind ? (kind as DeliveryKind) : undefined,
});
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
export async function POST(req: NextRequest) {
try {
// Building a package writes to the delivery volume and locks versions —
// producer/supervisor territory, not any signed-in user.
const user = await requirePipelineAdmin();
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = createDeliverySchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid delivery request", details: parsed.error.issues }, { status: 422 });
}
const result = await createDeliveryPackage(parsed.data, user.id);
return NextResponse.json(result, { status: 201 });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc";
// ── POST /api/ext/exports/{exportId}/qc (E4) ─────────────────────────────────
//
// QC Pass / QC Fail from the AE panel. PASS → READY_FOR_DELIVERY and nothing
// else; FAIL → QC_FAILED and the comp task back to CHANGES (§10.2).
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { exportId } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = qcSubmitSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid QC submission", details: parsed.error.issues }, { status: 422 });
}
try {
const result = await submitQCReview(exportId, parsed.data, { source: "AE_PANEL" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { regeneratePreview } from "@/lib/render-pipeline/preview";
// ── POST /api/ext/exports/{exportId}/regenerate-preview (E15b) ───────────────
//
// Rebuild the preview MOV/MP4 for an export that already delivered one, after a
// slate or burn-in mistake was caught. Reuses the validated EXRs and keeps the
// version number; slate text edited on the web app's Slate tab is picked up.
//
// Body (all optional):
// vfxScope per-export override; omit to re-read the shot's slate field
// submissionNote same
// note audit note for the ExportEvent
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { exportId } = await params;
let body: {
vfxScope?: string | null;
submissionNote?: string | null;
note?: string;
} = {};
try {
const parsed = await req.json();
if (parsed && typeof parsed === "object") body = parsed;
} catch {
// empty body is fine — everything falls back to the shot's slate fields
}
try {
const result = await regeneratePreview(exportId, {
...(body.vfxScope !== undefined ? { vfxScope: body.vfxScope } : {}),
...(body.submissionNote !== undefined ? { submissionNote: body.submissionNote } : {}),
note: typeof body.note === "string" ? body.note : "Regenerate preview from AE panel",
});
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { retryExport } from "@/lib/render-pipeline/exports";
// ── POST /api/ext/exports/{exportId}/retry (E14) ─────────────────────────────
//
// Retry a *_FAILED export from the AE panel: new RenderJob attempt, back to QUEUED.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { exportId } = await params;
let note: string | undefined;
try {
const body = await req.json();
if (typeof body?.note === "string") note = body.note;
} catch {
// empty body is fine
}
try {
const result = await retryExport(exportId, { type: "USER", note: note ?? "Retry from AE panel" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { getExportDetail } from "@/lib/render-pipeline/exports";
// ── GET /api/ext/exports/{exportId} (E3) ─────────────────────────────────────
//
// Export detail incl. render attempts and audit events (validations/QC join in
// later phases).
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { exportId } = await params;
try {
const result = await getExportDetail(exportId);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { getLatestExport } from "@/lib/render-pipeline/exports";
// ── GET /api/ext/exports/latest?shotCode=&projectCode= (E2) ──────────────────
//
// Latest export + status for the AE panel status header. Returns
// { "export": null } when the shot has never been exported.
export async function GET(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const shotCode = searchParams.get("shotCode")?.trim();
const projectCode = searchParams.get("projectCode")?.trim();
if (!shotCode) {
return NextResponse.json({ error: "shotCode query param is required" }, { status: 400 });
}
try {
const result = await getLatestExport(shotCode, projectCode);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { createExport } from "@/lib/render-pipeline/exports";
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
//
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force?, skipPreview? }
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
export async function POST(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
manifest?: unknown;
submittedByEmail?: string;
priority?: number;
force?: boolean;
vfxScope?: string | null;
submissionNote?: string | null;
skipPreview?: boolean;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.manifest) {
return NextResponse.json({ error: "manifest is required" }, { status: 400 });
}
try {
const result = await createExport({
manifest: body.manifest,
submittedByEmail: body.submittedByEmail ?? null,
priority: typeof body.priority === "number" ? body.priority : undefined,
force: body.force === true,
// undefined inherits the shot's previous export values
vfxScope: body.vfxScope,
submissionNote: body.submissionNote,
skipPreview: body.skipPreview === true,
});
return NextResponse.json(result, { status: 201 });
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { shotThumbnail } from "@/lib/shot-thumbnail";
// ── Auth ─────────────────────────────────────────────────────────────────────
@@ -18,6 +19,7 @@ function isAuthorized(req: NextRequest): boolean {
//
// Query params (all optional):
// episode e.g. 108
// scene e.g. 022
// sequence e.g. 004
// status e.g. IN_PROGRESS
// page (default 1)
@@ -39,6 +41,7 @@ export async function GET(
const { searchParams } = new URL(req.url);
const episode = searchParams.get("episode")?.trim() || undefined;
const scene = searchParams.get("scene")?.trim() || undefined;
const sequence = searchParams.get("sequence")?.trim() || undefined;
const status = searchParams.get("status")?.trim() || undefined;
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
@@ -59,6 +62,7 @@ export async function GET(
where: {
projectId: project.id,
...(episode ? { episode } : {}),
...(scene ? { scene } : {}),
...(sequence ? { sequence } : {}),
...(status ? { status: status as never } : {}),
},
@@ -88,6 +92,7 @@ export async function GET(
seqTimecodeStart: true,
seqTimecodeEnd: true,
thumbnailUrl: true,
generatedThumbnailUrl: true,
artist: {
select: { id: true, name: true, email: true },
},
@@ -98,6 +103,7 @@ export async function GET(
where: {
projectId: project.id,
...(episode ? { episode } : {}),
...(scene ? { scene } : {}),
...(sequence ? { sequence } : {}),
...(status ? { status: status as never } : {}),
},
@@ -107,6 +113,9 @@ export async function GET(
return NextResponse.json({
project,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
shots,
shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({
...shot,
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
})),
});
}
+13 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { resolveProjectTemplates } from "@/lib/render-pipeline/project-templates";
// ── Auth ─────────────────────────────────────────────────────────────────────
@@ -50,11 +51,22 @@ export async function GET(req: NextRequest) {
status: true,
startDate: true,
dueDate: true,
pipelineConfig: true,
_count: {
select: { shots: true },
},
},
});
return NextResponse.json({ projects, total: projects.length });
// Templates are resolved here rather than in the panel so that the panel and
// the headless preview build cannot disagree about which template, or which
// show LUT, a shot was built with.
const withTemplates = await Promise.all(
projects.map(async ({ pipelineConfig, ...project }) => ({
...project,
templates: await resolveProjectTemplates({ ...project, pipelineConfig }),
}))
);
return NextResponse.json({ projects: withTemplates, total: withTemplates.length });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { getQCQueue } from "@/lib/render-pipeline/qc";
// ── GET /api/ext/qc/queue?projectCode=&episode= (E5) ─────────────────────────
//
// Exports sitting in READY_FOR_QC, oldest first, with everything the panel
// needs to import the real EXR sequence for full-res QC.
export async function GET(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
try {
const result = await getQCQueue({
projectCode: searchParams.get("projectCode"),
episode: searchParams.get("episode"),
limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined,
});
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { createBlenderJob, createBlenderJobSchema } from "@/lib/render-pipeline/blender";
// ── POST /api/ext/render/blender ─────────────────────────────────────────────
//
// Queue a standalone Blender render from the Blender add-on. No Export, no shot
// version, no validation or QC — the .blend is already set up to render, and
// this puts it on the farm instead of on the artist's machine.
export async function POST(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = createBlenderJobSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid Blender job", details: parsed.error.issues }, { status: 422 });
}
try {
const result = await createBlenderJob(parsed.data);
return NextResponse.json(result, { status: 201 });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
import { db } from "@/lib/db";
import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/storage";
// ── POST /api/ext/render/jobs/{jobId}/artifact-presign (E20) ─────────────────
//
// Presigned upload URL for worker artifacts. Kinds map to the existing object
// storage folder layout: preview → videos/, thumbnail → image/,
// metadata/log → renders/.
const KIND_FOLDERS: Record<string, string> = {
preview: "videos",
thumbnail: "image",
metadata: "renders",
log: "renders",
};
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: { machineId?: string; kind?: string; fileName?: string; contentType?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
try {
if (!body.machineId) throw new PipelineError(400, "machineId is required");
if (!body.fileName) throw new PipelineError(400, "fileName is required");
if (!body.contentType) throw new PipelineError(400, "contentType is required");
const folder = KIND_FOLDERS[body.kind ?? ""];
if (!folder) {
throw new PipelineError(422, `kind must be one of: ${Object.keys(KIND_FOLDERS).join(", ")}`);
}
const job = await db.renderJob.findUnique({ where: { id: jobId }, select: { machineId: true } });
if (!job) throw new PipelineError(404, "Render job not found");
if (job.machineId !== body.machineId) {
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
}
const key = `${folder}/${randomUUID()}-${sanitizeFileName(body.fileName)}`;
const presignedUrl = await generateHetznerPresignedUploadUrl(key, body.contentType);
return NextResponse.json({ presignedUrl, key, url: `/api/files/${key}` });
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { completeRender } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
//
// aerender finished with exit 0 → Export goes to VALIDATING and the worker
// keeps the job to run the checks (E12). With validation disabled the preview
// stage is queued directly. `validationRequired` in the response tells the
// worker which of the two happened.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: {
machineId?: string;
renderSeconds?: number;
logFileKey?: string;
logTail?: string;
exrFileCount?: number;
exrTotalBytes?: number;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await completeRender(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { deliveryReportSchema, reportDeliveryBuild } from "@/lib/render-pipeline/deliveries";
// ── POST /api/ext/render/jobs/{jobId}/delivery ───────────────────────────────
//
// The worker finished (or gave up on) a DELIVERY_BUILD job: per-item results,
// the manifest it wrote, and whether the package as a whole is good.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = deliveryReportSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "Delivery report is malformed", details: parsed.error.issues }, { status: 422 });
}
try {
const result = await reportDeliveryBuild(jobId, parsed.data);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { reportFail } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/{jobId}/fail (E10) ─────────────────────────────
//
// Failure report. If retryable and attempts remain the server auto-creates the
// next attempt and returns autoRequeued: true.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: {
machineId?: string;
stage?: string;
exitCode?: number;
errorMessage?: string;
logTail?: string;
logFileKey?: string;
retryable?: boolean;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await reportFail(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { finalizePreview, type FinalizeInput } from "@/lib/render-pipeline/preview";
// ── POST /api/ext/render/jobs/{jobId}/finalize (E13) ─────────────────────────
//
// Preview artifacts uploaded → register the review MP4 as an internal-only
// Version and move the Export to READY_FOR_QC. Never shares to the client
// portal and never changes task/shot status (§10.0).
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: FinalizeInput & { machineId?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await finalizePreview(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}

Some files were not shown because too many files have changed in this diff Show More