46 Commits

Author SHA1 Message Date
twotalesanimation 3a7ba689ed feat(shot-status): show new-comment count on section headers
Deploy / deploy (push) Successful in 3m56s
Episode, scene and group headers carry an orange "!" with the number of
their shots that have new comments, so a collapsed section still shows
there is something to read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:08:35 +02:00
twotalesanimation 41cf79abbe fix(notifications): tell staff about every new comment
Only the shot's assigned artist was notified of player comments, and
client-link comments and pasted WhatsApp feedback notified no one.
notifyCommentsAdded now notifies all active staff plus the shot/task
artist, never the author, from all three paths. A pasted round becomes
one notification per version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:06:26 +02:00
twotalesanimation 7a7088f2ad feat(shot-status): flag shots with new comments until marked read
A per-user ShotCommentRead marker records how far each person has read a
shot's comments. Comments and replies from others after the marker put an
orange "!" beside the shot code; its popover lists them and only "Mark
read" clears it. A chip filters the board to flagged shots and marks all
read. The migration marks existing comments as read so the board starts
clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:06:25 +02:00
twotalesanimation 0aedc4b1fa feat(shots): previous/next shot navigation on the shot page
Deploy / deploy (push) Successful in 3m26s
Prev/next buttons beside the breadcrumb walk the project's shot list in
the same order as the Shots tab. The ordering now lives in
lib/shot-order.ts, shared by the project page and the shot API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:45:24 +02:00
twotalesanimation d7e37de18f feat(deliveries): choose which ready shots go into a package
Deploy / deploy (push) Successful in 3m35s
Each QC-passed shot in "Ready for delivery" gets a checkbox; all start
selected, with a per-episode Select/Deselect all. Build sends the explicit
exportIds ticked on screen, so a shot that turns ready after the page
loaded never slips into a package unseen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:10:51 +02:00
twotalesanimation 06a4122ebb feat(shot-status): status badges filter the shot list
Deploy / deploy (push) Successful in 3m28s
Clicking a summary badge shows only shots with that status; several can be
combined. Selection drops shots the filter hides so bulk actions never reach
them. Filter resets on project change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:46:43 +02:00
twotalesanimation 842a8fe036 feat(board): paste client feedback from WhatsApp
Deploy / deploy (push) Successful in 3m34s
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.

A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.

Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:31:34 +02:00
twotalesanimation 34af26dfbf feat(board): daily board linked to shots
A /board page that replaces the shared doc used as the day-to-day tracker.
One card per active/on-hold project with a status line, dated milestones
and to-do lines typed or pasted in the doc's own format:

  DH2_VFX_027_090 finalise (CHRIS)
  DH2_VFX_005_010 gfx update @michelle wait: client GFX ADH_GFX_005_020

Shot codes link to the shot (live status + latest version), (NAME)/@name
assigns, wait: marks a blocker, headings group lines. Codes that match no
shot are flagged, with a relink when there is an obvious near miss.

Board lines are their own model, not Tasks: tasks drive shot status and
notifications, and a note like "finalise" must not touch either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:30:40 +02:00
twotalesanimation 27d9618995 feat(client-portal): group shots by scene
Deploy / deploy (push) Successful in 3m31s
Collapsible scene sub-headers inside each episode, in scene order with
per-scene status counts; the review queue follows the same order. Aliased
shots go out without a scene so our numbering never reaches the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:41:19 +02:00
twotalesanimation 1d60a55b3c fix(shot-status): compact last sent cell
Deploy / deploy (push) Successful in 3m28s
Recipient moves to the tooltip, the version and date no longer wrap, and the
column is narrower so Notes keeps its room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:29 +02:00
twotalesanimation 88112112d4 feat(shot-status): last sent column per shot
Deploy / deploy (push) Successful in 3m27s
Shows what last reached the client with evidence (shared EXR package or
manual delivery), anything newer still on its way, and the last review MOV,
so it's clear on Shot Status what has actually been sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:24:18 +02:00
twotalesanimation 91e69f0bcb feat(worker): parallel multipart uploads for large artifacts
Deploy / deploy (push) Successful in 3m18s
From the studio a single connection to Hetzner nbg1 (188 ms away) is
capped near 0.1 MB/s, although a browser speed test shows 50 Mbps.
Measured: 1 connection 0.13 MB/s, 8 -> 0.64, 24 -> 1.69 MB/s. At the old
speed a ProRes MOV could not finish inside the 30 min request timeout.

- Worker 1.7.2 uploads files >= 16 MB as S3 multipart: 8 MB parts on
  uploadConcurrency connections (config.json, default 16), each part
  retried up to 4 times; aborts the upload if a part keeps failing.
- artifact-presign returns uploadId + per-part presigned URLs when asked
  for multipart; new E20b artifact-complete stitches or aborts.
- Old workers and old servers fall back to the single PUT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:48:28 +02:00
twotalesanimation 477aef7b27 feat(worker): upload status in the job log, ProRes uploaded in the background
Deploy / deploy (push) Successful in 3m25s
Preview jobs looked stuck after aerender finished: the worker was silently
uploading the review MP4s and the ProRes MOV over a slow uplink, and a
restart mid-upload threw the preview away.

- Worker 1.7.1 replaces the job's log tail with live upload status and logs
  each upload's size, duration and speed.
- The ProRes MOV now uploads after finalize via a persistent queue
  (HighResUploader, resumes after restart, 5 attempts) and attaches through
  the new E13b endpoint POST /api/ext/render/jobs/{id}/attach-highres.
  A MOV from a superseded preview is discarded.

Deploy the server before the 1.7.1 worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:13:23 +02:00
twotalesanimation f30acef101 feat(deliveries): delivery requests and upload/share evidence
Deploy / deploy (push) Successful in 5m17s
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00
twotalesanimation 326d8cbefa fix(playlist): black screen in production builds
Deploy / deploy (push) Failing after 1m25s
On mount, the loop-toggle effect preloaded clip 2 into the slot the first
goTo was loading clip 1 into, cancelling it, so nothing ever went on screen.
Dev's StrictMode double-mount re-ran the first load and hid it.

- preloadNext never loads over a slot a goTo is loading into
- the loop effect only reloads the standby on an actual toggle
- pausing or jumping mid pre-roll no longer re-parks a clip that a jump
  has since put on screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:54:02 +02:00
twotalesanimation b596c97faf feat(playlist): seamless playback of selected shots, cut only
Deploy / deploy (push) Successful in 3m12s
Check shots on the Playlist page and "Play selection" plays them back to
back without slate or handles. The cut comes from the version's handle and
slate metadata (new cutSpan in lib/handles); the media is never changed.

SequencePlayer double-buffers two videos: the next clip is parked a few
frames before its cut-in and pre-rolled hidden, the outgoing clip pauses on
its last cut frame, and the cut fires on the incoming clip's cut-in frame
(requestVideoFrameCallback), with the start time calibrated at each cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:55:46 +02:00
twotalesanimation 824695ef30 feat(clients): reactivate an expired client review link
Deploy / deploy (push) Successful in 3m16s
Expired links on the client page get a Reactivate button (7-90 days).
PATCH /api/review-sessions pushes the expiry out and turns the link back
on with the same token, so the URL the client already has works again.
Staff roles only, as for creating a link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:38:42 +02:00
twotalesanimation 390d7a21ac feat(connector): move Increment Shot Version into the Render Pipeline panel
The button sits beside Regenerate Previews instead of at the bottom of
Actions. Panel bumped to 2.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:38:41 +02:00
twotalesanimation 5e29c3b88d feat(pipeline): search the render queue by shot or client code
Deploy / deploy (push) Successful in 3m16s
A search box above the exports table matches any part of the shot code or
client code, case-insensitively, so "UNG_114" lists a whole episode. It is
applied server-side (the table is paged there), debounced, and scopes the
summary counts too; combines with the project and status filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:51:04 +02:00
twotalesanimation c2509512ff feat(delivery): record shots delivered outside the render pipeline
Deploy / deploy (push) Successful in 3m34s
Shots delivered by hand before the pipeline have no Export, so they could
never appear in a DeliveryPackage and the dashboard countdown showed them
as not delivered. Add Shot.manualDeliveredAt/ById/Note, a "Delivered
Outside Pipeline" bulk action on Shot Status (mark with the real date, or
clear), a badge on marked shots, and count them in the countdown alongside
DELIVERED EXR packages. The real date keeps the weekly rate honest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:03:56 +02:00
twotalesanimation 27f9f7ac64 fix(api): shot API 500 once a version has a ProRes size
Deploy / deploy (push) Successful in 3m16s
Version.highResSize (added in 9660cdc) is a BigInt, and GET
/api/shots/[shotId] spreads whole version rows into NextResponse.json,
converting only fileSize. As soon as worker 1.7.0 finalized an export
with a ProRes upload, JSON.stringify threw "Do not know how to serialize
a BigInt" and the shot page failed to load.

- Convert highResSize alongside fileSize in that route.
- lib/db.ts: serialize any BigInt as a string in JSON (BigInt.prototype
  .toJSON), so other routes returning whole rows can't 500 the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:23:03 +02:00
twotalesanimation 9660cdc21c feat(review): High quality playback switch and per-version ProRes download
Deploy / deploy (push) Successful in 3m23s
Worker 1.7.0 (preview.highQuality, default on) now, after the AE render:
- encodes a High quality MP4 from the delivery MOV (full-res H.264 High,
  CRF 14 capped at 100 Mbps) -> Version.hqFileUrl
- uploads the MOV itself -> Version.highResKey/Filename/Size
Both best-effort (the preview never fails over them), with a lease
keep-alive during the long encode/uploads; the export timeline records
what was attached.

Player: HQ switch when a High quality source exists; keeps frame, play
state, speed and zoom, remembered per browser, falls back to Standard if
the file won't load. ProRes itself stays download-only (browser support).

Downloads: the client portal's High Res button serves the version's
ProRes (client-code filename), falling back to the shot's manual file;
staff get a ProRes button on the review page.

Housekeeping (lib/render-pipeline/high-res.ts): HQ MP4 + ProRes are kept
while a version is latest, client-shared, approved or younger than
preview.highResRetentionDays (14); otherwise deleted from storage. Runs
per task after finalize and hourly from the reaper; deleting a version or
a reslate removes its files. The Standard MP4 is never touched.

Also: ProRes/Handles top-bar buttons go icon-only below lg so they don't
overlap the version selector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:32:18 +02:00
twotalesanimation bae46c2968 chore(connector): bump panel to 2.7.0 for the Includes handles option
Deploy / deploy (push) Successful in 3m17s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:33:24 +02:00
twotalesanimation 2e9fa45580 feat(review): flag handles in the review player (metadata only)
Deploy / deploy (push) Successful in 3m31s
Renders run 8 frames past the cut at each end. Directors were flagging
glitches in unfixed handles as shot problems, so the player now marks
them — without trimming or burning anything into the EXRs/MOV/MP4.

- Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/
  handleTail; migration backfills pipeline exports and their preview
  versions from the manifest's handleFrames (else 8)
- AE panel: "Includes handles [8] frames each end" -> manifest.review;
  older manifests fall back to source.handleFrames
- finalize copies handles onto the Version; worker 1.6.4 reports the
  head slate length (pictureStartSeconds), else the player infers it
  as MP4 frames - EXR frameCount
- Player: hatched HANDLE / SLATE regions and cut marks on the timeline,
  "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge
  on notes, "Edit range only" toggle (default on in the client portal)
- Staff Handles editor on the review page for manual uploads/corrections

Also fixes two player bugs found while testing: timeToFrame floored
float error (every seek landed a frame early), and J/reverse never ran
(the keyboard effect's cleanup stopped it on the next render).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:07:29 +02:00
twotalesanimation 2817c7e815 fix(annotations): lock annotations to the picture at any player size
Deploy / deploy (push) Successful in 3m7s
Points were normalised to the whole player box, but the video is drawn
object-contain inside it, so any change in the player's shape (window or
panel resize, fullscreen, phone rotation, the annotation toolbar opening)
moved the picture under the marks.

The canvas now sits exactly over the displayed picture (same letterbox
measurement as AspectOverlay), so points are fractions of the image and
new saves are tagged version "2.0". Legacy "1.0" annotations are mapped
onto the picture at load time from their stored canvasWidth/canvasHeight
and the video's intrinsic size; no data migration. The save action now
accepts both versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:34:57 +02:00
twotalesanimation 428814b8b9 feat(review): zoom and pan in the review player
Deploy / deploy (push) Successful in 3m16s
Wheel / trackpad pinch zooms toward the cursor (up to 8x), drag pans when
zoomed, and two-finger pinch works on phones. A drag never toggles
playback. A badge shows the zoom level with -/+/Fit while zoomed, and
+ / - / 0 work from the keyboard. Zoom resets when the video changes.

Video, blanking and the annotation canvas zoom as one layer, so marks
drawn while zoomed land where the pointer is and stay pinned to the image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:25:46 +02:00
twotalesanimation 45459e4b12 fix(review): Share with Client in the player shares the shot
Deploy / deploy (push) Successful in 4m14s
The review player's button only made the viewed version client-visible. It
never set shot.sharedWithClient, and the client portal lists only shared
shots (and only each task's latest version), so the button reported
"Shared with Client" while the client saw nothing. It also skipped the
internal approval the shot page requires.

For a version that belongs to a shot (directly or through its task), the
button now runs the shot page's shareWithClient: shot marked shared, latest
version of each task visible, status to Client Review. It is disabled with
an explanation until the shot is internally approved, and the player's
Approve Internally button now appears for task versions too, only while the
shot awaits approval. Asset versions keep the per-version share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:24:42 +02:00
twotalesanimation 2b373737d8 feat(review): review players usable on phones
Deploy / deploy (push) Successful in 3m11s
Internal review: the comments panel stayed a 320px sidebar on phones,
leaving the video a 55px strip. Below md it now sits under the video
(collapsible, arrow pointing down/up), and the top bar puts shot + version
on one row with the share and approval actions on a second.

Client review: the header's three-column grid overlapped on phones
(Approve drawn over the shot code). Below md it is two rows: back, shot and
prev/next, then status and the decision buttons. The status badge now shows
on phones, and the notes toggle points down/up to match its position.

Desktop is unchanged on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 08:13:14 +02:00
twotalesanimation 724b0cbefe fix(shot-status): Group by buttons fit on phones
The project picker (fixed 288px) and the Group by buttons shared one
unwrapping row, so on a phone the buttons ran off the right edge. Below md
the picker fills the row and Group by wraps onto its own line; from md it
is one row as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 08:13:13 +02:00
twotalesanimation 0c4f94d56a feat(client-portal): readable shot list on phones
Deploy / deploy (push) Successful in 3m14s
On a phone the approval badge was drawn over the version name on every row,
the TWO TALES logo broke over two lines, and in "recent" sort the date
wrapped onto three. Below sm the version name now takes its own line with
the badge under it, the logo stays on one line and a long link label is
truncated, and the date no longer wraps. Hover-only copy buttons are hidden
on phones, where they were invisible but still took space.

Desktop is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:01:05 +02:00
twotalesanimation 40b99f3add feat(ui): usable on phones without changing the desktop layout
Deploy / deploy (push) Successful in 3m19s
At phone width the sidebar stayed 240px wide, leaving pages a ~135px strip.
Below md it is now hidden behind a menu button in the header and opens as a
drawer (closes on navigation, backdrop, X or Escape); from md up the sidebar
is unchanged. Page wrappers use p-4 on phones and keep p-6/p-8 from md.

Shot detail: on phones the thumbnail takes the full width with the details
below it instead of pushing them off screen, the action buttons wrap, and
the tab bar scrolls sideways. Desktop sizing is kept as it was.

Every change is behind a md: breakpoint, so the desktop layout is the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:56:38 +02:00
twotalesanimation 33bc6a935c feat(ui): shot status, projects and client portal keep their place
Deploy / deploy (push) Successful in 3m31s
Coming back from a shot left the shot status page at the top with its
sections reset. The scroll restore saved window.scrollY, but dashboard pages
scroll the layout's <main>, so it always saved 0; the Projects page had the
same bug. Only episode expansion was remembered — scene and group sections
came back fully collapsed.

A shared useScrollMemory hook saves the real scroll container's position as
the user scrolls (so any way of leaving keeps it) and restores it once the
content that sets the page height is back. It ignores the scroll the shared
<main> fires when the next page replaces this one, which otherwise
overwrote the saved position.

Shot status now remembers episode, scene and group expansion per project.
The client portal already kept its filters and collapsed episodes but
restored scroll before they were applied; it now uses the hook too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:52:13 +02:00
twotalesanimation 532ebd82d7 feat(pipeline): tabbed render queue with paged exports
Deploy / deploy (push) Successful in 3m32s
The queue page loaded up to 200 exports in one table with the Blender
renders appended underneath, so a Blender job sat below the whole export
history. Exports and Blender renders are now separate tabs; the Blender tab
label shows how many are active or failed so a render stays visible from
the exports tab.

Exports page 25 at a time. The summary cards now come from a server-side
count per status (listExports.statusCounts) instead of counting the loaded
rows, which was already wrong past 200, and clicking a card filters to it.
The project filter moves to the header since it applies to both tabs, and
the duplicated Deliveries button is gone.

Adds .claude/launch.json for running the dev server from the preview pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:41 +02:00
twotalesanimation 708e8a0d8f fix(worker): parse Blender 5.x output so rendered frames are counted
Deploy / deploy (push) Successful in 3m28s
Blender 5 logs through CLOG, prefixing every line with elapsed time and a
category ("00:02.954  render | Saved: '...'"), and reports errors as
"ERROR ..." instead of "Error: ...". The parser anchored on "Saved:" at the
start of the line, so it counted no frames from a render that wrote all of
them and the job failed as "saved no frames". Progress (Fra:) and error
detection were dead on 5.x for the same reason.

The prefix is now stripped before matching; 4.x output parses as before.
Warnings no longer count as errors: add-ons print them on every launch and
one would otherwise be reported as the reason a render failed. A run that
saves nothing now reports Blender's own error when there is one.

Verified on the real DH2 test.blend under Blender 5.2: the 1.6.2 parser
counts 0 of 3 saved frames, this one counts 3.

Bump worker to 1.6.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 04:42:33 +02:00
twotalesanimation 97246bcbc3 fix(worker): Blender jobs always failed with "Collection was modified"
LogTail was read by the progress loop while the output pumps appended to
it. Blender rewrites its status line many times a second, so the first
progress report of every job threw; aerender hit the same race only rarely.
LogTail now locks internally.

The exception also escaped BlenderRunner without ending the process, leaving
blender.exe rendering unsupervised on the node on every failed attempt. Any
unexpected exception in the monitor loop now kills the process tree.

Bump worker to 1.6.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 04:21:54 +02:00
twotalesanimation 400cf3f6ea feat(validation): try the comp's other delivered plates before failing timecode
When the timing plate disagrees with the render, the worker now checks the
manifest's other plates[] that sit under a plate search root (or beside the
timing plate's footage folder). A match is a PASS naming that element; no
match still FAILs against the timing plate, listing the alternates tried.
Pre-renders are excluded because they agree with the render by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 04:21:53 +02:00
twotalesanimation 2dee1bf6fc feat(batch-upload): bulk footage plate upload matched by filename
Deploy / deploy (push) Successful in 6m39s
Adds a third section to the Batch Upload page that attaches plate movies to
shots derived from the filename: DH2_VFX_058_120_REF.mov becomes the plate
"REF" on shot DH2_VFX_058_120. Shot matching reuses findMatchingShotCode, so
it behaves like the rest of the page (case-insensitive, longest code wins,
underscore-boundary only) and the filename remainder becomes the label.

Bytes go straight to Hetzner via the existing presign endpoint since plates
are multi-GB, with XHR for per-file progress. Files whose shot already has a
plate of the same name are skipped so re-running a batch does not stack
duplicates.

Also resets the thumbnail and plate previews when the project changes — they
are resolved against a specific project, but only the version-upload flow was
being cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:15:29 +02:00
twotalesanimation 469d782b4f feat(projects): poster art and equal-height project cards
Deploy / deploy (push) Successful in 6m27s
A grid of twenty projects was twenty near-identical blocks of text. The cards
now lead with a still, with the code, client and title set over it.

There is no poster column on Project and adding one would mean somebody
maintaining it, so the image is chosen from the shots the project already has:
a key shot with a still first, then the most recently touched shot with one.
Within each, thumbnailUrl beats generatedThumbnailUrl - the same precedence
the rest of the app displays shots by, since the reference frame from the
brief is a deliberate choice and a promoted render frame is whatever the
pipeline landed on. The pick is deterministic rather than random: a card whose
image changes between two loads reads as a glitch, and the eye stops using the
image to find the project.

One DISTINCT ON query covers every project on the page, instead of a query
each or dragging back every shot that has a still.

Equal heights come from three things: auto-rows-fr on the grid, h-full on the
card, and fixed room for the description, which was the only part that varied.
A project with no stills gets a placeholder of the same footprint rather than
a hole in the row. The whole card is now the link, so the Open button is gone.

Also fixes a latent bug: the card read project.deadline, but the column is
dueDate, so the due date never rendered. Both are accepted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:36:04 +02:00
twotalesanimation 6afbe82a29 feat(dashboard): QC queue, deliveries and whose-court panels
Deploy / deploy (push) Successful in 6m51s
Completes the operations dashboard.

QC queue: what is waiting on a person to watch the frames, oldest first.
Every stage before QC is worked by the farm, so this is the only one that
cannot drain itself - depth here is the best early warning that a delivery
date is going to move, and it had no home outside a filter on the queue page.

Deliveries: the last few packages, flagging FAILED and builds stuck in
BUILDING. A failed build is otherwise silent - the exports stay
READY_FOR_DELIVERY, nothing reaches the delivery volume, and nobody finds out
until the client asks where the shots are.

Whose court: versions sent to the client and unanswered, against shots with
unresolved notes. Two numbers rather than one because they call for opposite
responses - a fortnight-old review is a phone call, unactioned notes are a
scheduling problem.

The artist view now scopes to the artist throughout: the blocked list, and
the counters above it, which otherwise quoted a studio-wide figure next to a
personal list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:58:08 +02:00
twotalesanimation 6bfc013e35 feat(dashboard): episode delivery countdown
Turns "23 shots to go" into "you are not going to make it". One card per
episode from EpisodeDueDate (or the project's own date on a standard show):
days left, the episode's shots by where they actually are, and the only
comparison that matters - shots per day still needed against shots per day
the studio managed over the last week.

A shot counts as delivered when it appears in a DeliveryItem inside a
DELIVERED EXR_DELIVERY package. Shot.status is deliberately not the measure:
it says where the work is, not whether the client has the frames, and a
MOV_REVIEW package ships nothing.

The pace arithmetic is a pure function so the sentence a producer acts on is
testable: a rate that only just lands on the due date reports as "no slack"
rather than fine, and a week with no deliveries reports as stalled instead of
projecting infinity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:45:01 +02:00
twotalesanimation f6a7f0ef5a feat(dashboard): show what is blocked and how the farm is doing
The dashboard was a generic task-manager view of a studio that does not work
that way. Two of its five headline numbers counted Task.dueDate, which no
pipeline code writes - the only writer is a manual field on the task detail
page, so those cards read zero by construction. The rest were unscoped and
cumulative ("approved" across every project ever finished), the activity feed
was the notification table, and the shot queue sampled 15 rows by updatedAt,
a column the farm touches constantly. Nothing about failed exports, QC depth
or the render farm appeared at all, so a RENDER_FAILED was only visible if
someone thought to open the queue and filter for it.

Replaced with pipeline state, which the system maintains on its own:

- Blocked queue: the four failed statuses plus exports that stopped
  progressing, each with the reason read off the actual cause - the failing
  validation check, the QC note, the aerender error, or the machine that
  claimed it and went quiet.
- Farm strip: machines, queue depth by job type, oldest unclaimed job, and
  worker-version drift, since a worker's version decides which fixes it has.
- Five counters that can go down, scoped to live productions, each linking to
  the rows it counted.

The queue page ignored ?status=, so the counters had nowhere to link; it now
takes an initial filter from the URL. Stat cards are hidden from CLIENT users
- the old ones were generic version counts, these are internal pipeline state.

Thresholds live in SystemConfig (dashboard.qcStaleHours, .stalledJobHours) so
they can be tuned without a redeploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:40:40 +02:00
twotalesanimation fe420b9455 chore(worker): bump to 1.6.1 for the PrepareOutputDir fix
Deploy / deploy (push) Successful in 6m49s
The version is what a machine reports on startup, so it has to move for the
Machines page to show which nodes are still clearing the preview MOV out of
the version folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:06:37 +02:00
twotalesanimation 2a8888089f fix(worker): stop PrepareOutputDir deleting the version's preview MOV/MP4
Deploy / deploy (push) Successful in 6m33s
PrepareOutputDir cleared every file in the output dir whose name started
with the output pattern's prefix. The preview stage writes its MOV and MP4
into that same folder from the same basename ({outputDir}/{base}.mov), so
they matched the prefix and were deleted before aerender started.

The dir is not private to one export: outputDir is keyed on the shot's
manually-set shotVersion, not on the export, so re-queuing without a
Version Up renders into the folder again. That is exactly the "No preview"
workflow - export scaled, reverse the scale, re-render only the EXRs and
inherit the earlier MOV - and inheritPreviewMedia then handed the new
export a deliveryMovPath pointing at the file the render had just removed.
Nothing surfaced it: the review MP4 is an uploaded blob and still played,
and DeliveryStage treats a missing MOV as non-fatal, so the package
silently shipped EXRs with no viewing copy.

Match on prefix and extension instead, via a new
SequencePattern.MatchesOutputFile - the predicate ValidationStage and
DeliveryStage already applied inline. Crashed-attempt cleanup is unchanged;
only files with a different extension are now spared.

JobExecutor's post-render stats loop matched on prefix alone too. With the
media no longer deleted it would have counted the MOV/MP4 as frames and
failed validation's file_count check, so it uses the same matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:19:33 +02:00
twotalesanimation a073f92da3 fix(delivery): number same-day packages per episode, not per project
Deploy / deploy (push) Successful in 7m31s
Numbering was scoped to the project, so the day's first delivery of
episode 102 was named _02 purely because episode 101 had already gone out
that morning. The count is meant to distinguish repeat deliveries of the
same content, and packageDir already separates episodes — three episodes
delivering today each land in their own folder and each are that folder's
first delivery. Adding `episode` to the lookup makes the suffix mean what
the folder layout already implies: only a second delivery of the *same*
episode becomes _02. The [projectId, episode] index already exists, so
the extra filter costs nothing.

packageNameTemplate also moves from {yymmdd} to {yyyymmdd}. The AE panel
has always built its local delivery folders with a four-digit year, so
this is the server matching the connector rather than a new convention.
The {yymmdd} token stays in TokenValues: a project carrying its own
template in deliveryConfig is unaffected either way.

Existing packages are untouched — buildDeliveryJobManifest reads rootPath
off the record. On the day of the changeover an episode that already
delivered as 260903_Delivery starts again at the bare 20260903_Delivery,
which is a different name and a different folder, so nothing merges.

Producer reviews keep their per-project numbering: a review can span
several episodes and stores episode as "", so scoping it the same way
would put every such review in one bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:07:52 +02:00
twotalesanimation 04e5eb123a feat(shot-status): bulk-assign selected shots to an artist
Deploy / deploy (push) Successful in 6m30s
Adds an "Assign Artist" entry to the bulk actions menu, backed by a
bulkAssignArtist server action. The action re-checks the target user
server-side — a deactivated account or a CLIENT is rejected rather than
trusted from the picker — and passing null clears the assignment, so
reassignment and hand-back are the same flow.

Like single-shot assignment in updateShot this writes the column and
sends no notification, so reshuffling a batch during planning stays quiet.

Also renders the Artist column the page was already querying but never
showing; without it there was no way to see the result of an assignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:51:13 +02:00
twotalesanimation 75f9258b69 feat(player): aspect-ratio blanking overlay in the review player
Deploy / deploy (push) Successful in 5m39s
A shot is reviewed at the framing it was rendered at but usually delivered
cropped, so add a dropdown beside the playback-speed control that lays
delivery blanking over the video: 2.39 through 9:16, widest to tallest, with
"No blanking" as the default. Purely a viewing aid — nothing is written to
the review, and the video element is untouched.

The geometry is the awkward part. The video is object-contain, so the picture
is letterboxed inside the element and bars drawn against the element's edges
would be wrong on any panel whose shape does not match the source.
letterboxRect works out where the picture is actually painted and the bars are
positioned against that, kept current by a ResizeObserver and a loadedmetadata
listener so resizes and fullscreen stay correct. Ratios within half a pixel of
the source draw nothing rather than a hairline of rounding error.

Two modes, since blanking and crop bars are different jobs: solid black hides
the crop the way the master will, guide dims it so the excluded area is still
readable. The overlay sits under the annotation canvas — deliberately no
z-index, since the canvas sets none either and DOM order alone gives marks
drawn over a blanked area the right precedence.

Verified against real browser layout, not just unit tests: the computed
picture rect lands exactly on the image edges across three panel shapes, and
the visible area measures the requested ratio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 07:21:58 +02:00
166 changed files with 15303 additions and 1083 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+66 -9
View File
@@ -20,9 +20,14 @@ This is the operational guide. The design rationale and normative rules live in
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**.
4. A human uploads that folder to the client and clicks **Record upload**,
pasting the link to where it now lives.
5. They send the client that link and click **Share link**, recording who it
went to. Only then is the package `DELIVERED`.
Nothing is ever sent to the client automatically. Step 4 is deliberately manual.
Nothing is ever sent to the client automatically. Steps 4 and 5 are deliberately
manual — but until both are recorded the package sits in **Awaiting delivery**
at the top of the dashboard.
---
@@ -69,8 +74,8 @@ Watch the package status on the same page:
|---|---|
| `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 |
| `READY` | Built and verified. Shown as *Built — not uploaded*, then *Uploaded — link not shared* |
| `DELIVERED` | The upload location and who the link was sent to are both on record |
| `FAILED` | Something did not verify — see the error and the per-shot list |
| `CANCELLED` | Abandoned before delivery |
@@ -166,11 +171,59 @@ so it is logged and the package continues without it.
## Marking delivered
Once you have uploaded the folder, **Mark delivered** moves every packaged
export to `DELIVERED`. Requires admin, producer or supervisor.
A built package is not a delivered one, so delivery is two recorded steps. Both
require 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.
1. **Record upload** — paste the link to the folder on the client's portal and
confirm it matches the shot, frame and MOV counts shown. The link can be
corrected until it is shared.
2. **Share link** — copy the generated message (link, shots, versions), send it,
and record who it went to. This moves every packaged export to `DELIVERED`.
The second step is refused until the first is on record. Meanwhile the package
is listed under **Awaiting delivery** on the dashboard, amber after a day and
red after two.
This is still a human statement of fact — the client's portal has no API, so
the app cannot see the upload itself. What it guarantees is that nothing reads
as delivered without a link, a recipient, a name and a time behind it. Packages
delivered before this existed show as delivered with no evidence recorded.
---
## Delivery requests
When a client asks for something ("EXRs for 116"), log it so it cannot be
forgotten. Requires admin, producer or supervisor.
- **Dashboard → Awaiting delivery → Log request**, or the same button on
**Pipeline → Deliveries**: pick the project, then a whole episode or a pasted
list of shot codes (ours or the client's; a code that matches nothing is
refused rather than skipped).
- **Shot Status → Bulk Actions → Log Delivery Request** for a ticked selection.
The shot list is a snapshot taken when the request is logged. The request then
sits in **Awaiting delivery** showing *"3 of 9 delivered"*, and on the
deliveries page every shot shows where it stands: not through QC, in QC, ready
to package, building, built, uploaded, delivered. **Build N ready** packages
the shots that have passed QC.
A request has no "done" button. A shot's line closes only when:
- a package containing it is shared (step 2 of marking delivered) after the
request was logged, or
- it is marked **Delivered Outside Pipeline** with the upload link and who the
link was sent to, dated on or after the day the request was logged. For a
shot on an open request those two fields are required.
The request closes when every line has. A delivery made *before* the request
was logged does not count — a client asking again is asking for it again. The
only other way out is **Cancel request**, which needs a reason and stays in the
history.
The *Wanted* field (EXRs, MOVs, both) records what was asked for; it does not
change what closes a line. A MOV-only ask is closed through Delivered Outside
Pipeline, since a MOV review package has no delivered state.
---
@@ -233,8 +286,12 @@ a different spec is a config change rather than a code change.
| 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}/uploaded` | Record the upload: `{ uploadUrl, contentsConfirmed: true }` |
| E19 | `POST` | `/api/deliveries/{id}/delivered` | Record the share and mark delivered: `{ sharedWith, note? }` |
| — | `POST` | `/api/deliveries/{id}/cancel` | Cancel a package |
| — | `GET` | `/api/delivery-requests` | List requests with per-shot stages (`?projectId=`, `?open=1`) |
| — | `POST` | `/api/delivery-requests` | Log a request: `{ projectId, episode \| shotIds \| shotCodes, deliverable, requestedBy, dueDate?, note? }` |
| — | `POST` | `/api/delivery-requests/{id}/cancel` | Cancel a request: `{ reason }` |
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) |
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
+23
View File
@@ -1073,6 +1073,8 @@ Timecode is the check this engine exists for. It is validated **against the sour
| 2 | Plate re-derived on the worker from the footage root + shot code (manifestVersion 1, or a timing plate that no longer resolves). If elements disagree, the one matching the render's frame count wins; still ambiguous → WARN, never PASS | PASS / FAIL, `reference: "derived"` |
| 3 | API `Shot.timecodeStart` **minus `handleFrames`** | **WARN only, never PASS** — `reference: "api"`, message states the plate could not be read |
**Several delivered elements.** When the rank-1 timing plate does not match, the worker tries the manifest's other `plates[]` before failing — the panel's longest-duration pick is not always the element the comp was anchored to. A match against one of them is a PASS naming that element; no match still FAILs against the timing plate, listing the others tried. Only delivered footage qualifies: plates under a configured plate search root, or (with none configured) alongside the timing plate in its footage folder. `plates[]` also holds pre-renders, which agree with the render by construction and must never rescue it.
Rank 3 is a diagnostic, not a verification: it depends on the show's handle count being correct for that specific plate, which is exactly the assumption the defects above violate.
**Comparison, given the plate reference:**
@@ -1177,6 +1179,27 @@ Not every export is framed like its review. A deliverable rendered unscaled or w
With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media.
### 9.5 Handles in review (metadata only)
Renders run past the cut at each end (8 + 8 by house convention). Handles ship with the EXRs and stay in the preview, but reviewers must be able to tell them from the cut, or a glitch in a handle gets flagged as a problem in the shot. **Nothing is trimmed or burned in** — this is deliberately not the reverted picture-lock trim (which desynced the overlay timecode); the render and every delivered file are unchanged.
- **Panel:** "Includes handles [8] frames each end" (default on, prefilled from Handle Frames) sends `manifest.review = { handleHead, handleTail }`; unticked sends 0/0. Manifests without `review` (older panels) fall back to `source.handleFrames` (`manifestHandles`, exports.ts).
- **Export → Version:** `createExport` stores `Export.handleHead/handleTail`; `finalizePreview` copies them onto the Version, plus `pictureStartFrame` = the head slate length from the build's `pictureStartSeconds` (worker ≥ 1.6.4). Left null, the player infers the slate as MP4 frames − `frameCount` (the EXR count), within 0–5 frames.
- **Player** (`lib/handles.ts` → `computeEditRange`): the timeline hatches the handles amber and labels them HANDLE (the slate, SLATE); while the playhead is in a handle the picture gets an amber frame and a "HEAD/TAIL HANDLE · NOT IN EDIT" tag; notes on handle frames carry a HANDLE badge. "Edit range only" (scissors) keeps play, reverse and go-to-start/end inside the cut — on by default in the client portal, off for staff.
- **Staff override:** the review page's Handles button edits a version's head/tail/slate — for manual uploads, or to correct a render.
- **Backfill** (`20260922000000_version_handles`): every existing export takes its manifest's `source.handleFrames` (else 8), and the Versions they produced take the same.
### 9.6 High quality playback and the ProRes download
The Standard review MP4 is AE's H.264 (UHD, ~35 Mbps). ProRes itself can't be the player's source — only Safari on a Mac decodes it in a web page, and UHD ProRes LT is ~280 Mbps — so the worker (≥ 1.7.0, when `preview.highQuality` is on) adds two things after the AE render, both **best-effort** (a failure costs the extras, never the preview):
- **High quality MP4:** ffmpeg re-encodes the delivery MOV as full-res H.264 High, CRF 14 capped at 100 Mbps, yuv420p, faststart → `Version.hqFileUrl`. The player shows an **HQ** switch when present; switching keeps frame, play state, speed and zoom, is remembered per browser (`review:quality`), and falls back to Standard if the file won't load. Same comp as the Standard MP4, so slate and handles line up frame for frame.
- **ProRes download:** the MOV itself is uploaded (presign kind `highres`) → `Version.highResKey/highResFilename/highResSize`. It is the version's **High Res** download in the client portal (named with the client code, `clientHighResFilename`) and a **ProRes** button on the staff review page. The shot's manually uploaded High Res remains the fallback for versions without one. Since worker 1.7.1 the MOV uploads **in the background after finalize** (`HighResUploader`, queue in `%ProgramData%\VFXReviewWorker\highres-queue`, resumes after a restart, 5 attempts) and attaches via `POST /api/ext/render/jobs/{id}/attach-highres` (E13b); a MOV from a preview that a newer preview has superseded is discarded. While a preview job uploads, the worker replaces the job's log tail with live upload status. Since 1.7.2, artifacts of 16 MB or more upload as S3 multipart: `artifact-presign` with `multipart: true, size` returns `uploadId`, `partSize` (8 MB minimum) and one presigned URL per part; the worker PUTs parts on `uploadConcurrency` connections (default 16), retries each part up to 4 times, then calls `artifact-complete` (E20b) to stitch them, or with `abort: true` to discard them. A single connection from the studio to nbg1 is capped near 0.1 MB/s.
A lease keep-alive runs during the encode and uploads (they can outlast the 300 s lease). The export timeline records what was attached.
**Housekeeping** (`lib/render-pipeline/high-res.ts`): a version keeps its HQ MP4 and ProRes while it is the task's latest, shared with the client, approved, or younger than `preview.highResRetentionDays` (14). Otherwise both are deleted from storage and the row gets `highResPrunedAt`; the Standard MP4 is never touched. It runs for the task after every finalize and studio-wide hourly from the reaper; deleting a version deletes its files. A reslate replaces (and deletes) the version's previous HQ/ProRes.
---
## 10. QC Workflow
+3
View File
@@ -208,6 +208,9 @@ read back from the assembly, so a machine can never report a version it is not r
`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.
`uploadConcurrency` (default 16) is how many parallel connections a file of 16 MB or more
uploads over. Each connection to object storage is capped near 0.1 MB/s from the studio, so
throughput scales with it (measured 2026-10-01: 1 → 0.13 MB/s, 8 → 0.64, 24 → 1.69 MB/s).
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.
@@ -73,6 +73,46 @@ public class BlenderProgressParserTests
Assert.Equal(1002, frames[^1]);
}
/// <summary>
/// Blender 5.x prefixes every line with elapsed time and a log category.
/// Anchored on "Saved:", the parser counted no frames from a render that
/// wrote every one, and the job failed as "saved no frames".
/// These lines are verbatim Blender 5.2 output.
/// </summary>
[Fact]
public void ParsesBlender5PrefixedOutput()
{
Assert.Equal(@"X:\renders\frame_0001.png",
BlenderProgressParser.ParseSaved(@"00:02.954 render | Saved: 'X:\renders\frame_0001.png'"));
Assert.Equal(1, BlenderProgressParser.ParseFrame("00:02.469 render | Fra: 1 | Mem: 0M | Synchronizing object | Cube"));
Assert.Null(BlenderProgressParser.ParseSaved("00:02.954 render | Time: 00:00.87 (Saving: 00:00.14)"));
Assert.Null(BlenderProgressParser.ParseFrame("00:02.079 render | Rendering frame 1"));
}
[Fact]
public void RecognisesBlender5Errors()
{
const string unwritable = @"00:03.063 reports | ERROR Render error (No such file or directory) cannot save: 'Q:\out\f_0001.png'";
Assert.True(BlenderProgressParser.IsErrorLine(unwritable));
Assert.True(BlenderProgressParser.IsNonRetryableError(unwritable));
const string missing = @"00:13.250 reports | ERROR Cannot read file ""X:\gone.blend"": No such file or directory";
Assert.True(BlenderProgressParser.IsErrorLine(missing));
Assert.True(BlenderProgressParser.IsNonRetryableError(missing));
}
/// <summary>
/// Add-ons print warnings on every launch; one must never become the
/// reported reason a render failed.
/// </summary>
[Theory]
[InlineData("Warning: add-on missing 'bl_info', this can cause poor performance!: 'G:\\addons\\x\\__init__.py'")]
[InlineData("00:01.000 addon | WARNING something harmless")]
public void WarningsAreNotErrors(string line)
{
Assert.False(BlenderProgressParser.IsErrorLine(line));
}
/// <summary>Mirrors BlenderRunner.PumpAsync: CR and LF both end a line.</summary>
private static List<string> SplitLikeThePump(string text)
{
@@ -0,0 +1,205 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// Large artifacts go up as parallel multipart parts: from the studio a single
/// connection to object storage is capped near 0.1 MB/s, so a ProRes MOV could
/// not finish inside the 30 min request timeout. These drive the real upload
/// code against a local fake of the server + storage.
/// </summary>
public class MultipartUploadTests : IDisposable
{
private const int PartSize = 8 * 1024 * 1024;
private readonly HttpListener _listener = new();
private readonly string _base;
private readonly ConcurrentDictionary<int, byte[]> _parts = new();
private readonly ConcurrentDictionary<int, int> _failuresLeft = new();
private readonly ConcurrentBag<string> _completions = new();
private int _inFlight, _maxInFlight;
private bool _multipartSupported = true;
private byte[]? _singlePut;
public MultipartUploadTests()
{
var port = FreePort();
_base = $"http://localhost:{port}/";
_listener.Prefixes.Add(_base);
_listener.Start();
_ = Task.Run(Serve);
}
private static int FreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private async Task Serve()
{
while (_listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); } catch { return; }
_ = Task.Run(() => Handle(ctx));
}
}
private async Task Handle(HttpListenerContext ctx)
{
var path = ctx.Request.Url!.AbsolutePath;
using var ms = new MemoryStream();
await ctx.Request.InputStream.CopyToAsync(ms);
var body = ms.ToArray();
var res = ctx.Response;
if (path.EndsWith("/artifact-presign"))
{
var req = JsonDocument.Parse(body).RootElement;
var size = req.GetProperty("size").GetInt64();
object payload = _multipartSupported && req.GetProperty("multipart").GetBoolean()
? new
{
key = "highres/x.mov",
uploadId = "up1",
partSize = PartSize,
partUrls = Enumerable.Range(1, (int)((size + PartSize - 1) / PartSize))
.Select(n => $"{_base}part/{n}").ToList(),
}
: new { key = "highres/x.mov", presignedUrl = $"{_base}single" };
await Json(res, payload);
}
else if (path.StartsWith("/part/"))
{
var n = int.Parse(path["/part/".Length..]);
var now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref _maxInFlight, now);
await Task.Delay(50);
Interlocked.Decrement(ref _inFlight);
if (_failuresLeft.TryGetValue(n, out var left) && left > 0)
{
_failuresLeft[n] = left - 1;
res.StatusCode = 500;
res.Close();
return;
}
_parts[n] = body;
res.Headers["ETag"] = $"\"etag-{n}\"";
res.Close();
}
else if (path == "/single")
{
_singlePut = body;
res.Close();
}
else if (path.EndsWith("/artifact-complete"))
{
_completions.Add(System.Text.Encoding.UTF8.GetString(body));
await Json(res, new { ok = true });
}
else
{
res.StatusCode = 404;
res.Close();
}
}
private static void InterlockedMax(ref int target, int value)
{
int seen;
while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen) { }
}
private static async Task Json(HttpListenerResponse res, object payload)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, WorkerOptions.JsonOpts);
res.ContentType = "application/json";
await res.OutputStream.WriteAsync(bytes);
res.Close();
}
private ApiClient Client(int concurrency = 4) =>
new(new WorkerOptions { ServerUrl = _base, ApiKey = "k", UploadConcurrency = concurrency },
NullLogger<ApiClient>.Instance);
private static string TempFile(int bytes)
{
var path = Path.GetTempFileName();
var data = new byte[bytes];
new Random(42).NextBytes(data);
File.WriteAllBytes(path, data);
return path;
}
[Fact]
public async Task Uploads_parts_in_parallel_and_stitches_them_in_order()
{
var file = TempFile(PartSize * 5 + 12345);
long lastProgress = 0;
var key = await Client(4).UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime",
CancellationToken.None, sent => Interlocked.Exchange(ref lastProgress, sent));
Assert.Equal("highres/x.mov", key);
var stitched = Enumerable.Range(1, 6).SelectMany(n => _parts[n]).ToArray();
Assert.Equal(File.ReadAllBytes(file), stitched);
Assert.True(_maxInFlight > 1, "parts should upload concurrently");
Assert.True(_maxInFlight <= 4, "concurrency is capped by UploadConcurrency");
Assert.Equal(new FileInfo(file).Length, Interlocked.Read(ref lastProgress));
var complete = JsonDocument.Parse(Assert.Single(_completions)).RootElement;
Assert.False(complete.TryGetProperty("abort", out _));
Assert.Equal(6, complete.GetProperty("parts").GetArrayLength());
Assert.Equal("\"etag-1\"", complete.GetProperty("parts")[0].GetProperty("etag").GetString());
File.Delete(file);
}
[Fact]
public async Task A_failed_part_is_retried_on_its_own()
{
var file = TempFile(PartSize * 3);
_failuresLeft[2] = 2;
var key = await Client().UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime", CancellationToken.None);
Assert.NotNull(key);
Assert.Equal(File.ReadAllBytes(file), Enumerable.Range(1, 3).SelectMany(n => _parts[n]).ToArray());
File.Delete(file);
}
[Fact]
public async Task A_part_that_keeps_failing_aborts_the_upload()
{
var file = TempFile(PartSize * 3);
_failuresLeft[3] = 99;
var key = await Client().UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime", CancellationToken.None);
Assert.Null(key);
var abort = JsonDocument.Parse(Assert.Single(_completions)).RootElement;
Assert.True(abort.GetProperty("abort").GetBoolean());
File.Delete(file);
}
[Fact]
public async Task Falls_back_to_a_single_put_on_a_server_without_multipart()
{
_multipartSupported = false;
var file = TempFile(PartSize * 3);
var key = await Client().UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime", CancellationToken.None);
Assert.Equal("highres/x.mov", key);
Assert.Equal(File.ReadAllBytes(file), _singlePut);
Assert.Empty(_completions);
File.Delete(file);
}
public void Dispose() => _listener.Close();
}
@@ -70,6 +70,59 @@ public class PrepareOutputDirTests
}
}
/// <summary>
/// The version folder holds the preview MOV/MP4 built from the same
/// basename. A shot re-rendered at the same version — the "No preview"
/// workflow, where the EXRs are re-exported unscaled and the earlier MOV is
/// inherited as the review media — must not have that MOV cleared out from
/// under it before aerender starts.
/// </summary>
[Fact]
public void KeepsPreviewMediaSharingThePatternPrefix()
{
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.mov"), "delivery mov");
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.mp4"), "review mp4");
AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr");
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr")));
Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.mov")));
Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.mp4")));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ClearsOnlyThisVersionsFrames()
{
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_v005.1001.exr"), "other version");
File.WriteAllText(Path.Combine(dir, "OTHERSHOT_cmp_TT_v004.1001.exr"), "other shot");
AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr");
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr")));
Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v005.1001.exr")));
Assert.True(File.Exists(Path.Combine(dir, "OTHERSHOT_cmp_TT_v004.1001.exr")));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void CreatesMissingDirectory()
{
@@ -117,4 +117,21 @@ public class LogTailTests
for (var i = 1; i <= 5; i++) tail.Add($"line {i}");
Assert.Equal("line 3\nline 4\nline 5", tail.ToString());
}
[Fact]
public async Task ReadingWhileAnotherThreadWritesDoesNotThrow()
{
// The Blender runner's output pumps add lines while its progress loop
// reads the tail; unsynchronised this threw "Collection was modified".
var tail = new LogTail(200);
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(1));
var writer = Task.Run(() =>
{
var i = 0;
while (!stop.IsCancellationRequested) tail.Add($"Fra:1001 Sample {i++}/128");
});
while (!stop.IsCancellationRequested) _ = tail.ToString();
await writer;
}
}
@@ -109,6 +109,74 @@ public class ValidationStageTests : IDisposable
Assert.Contains("8-frame handle", tc.Message);
}
/// <summary>Writes a one-frame plate into a footage-style element folder.</summary>
private string WritePlate(string element, string timecode, int fps = 24)
{
var plate = Path.Combine(_dir, "footage", $"UNG_107_051_010_{element}_TT_v001", $"UNG_107_051_010_{element}_TT_v001.01001.exr");
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
File.Move(ExrFixture.Write(timecode, 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, fps, 4), plate);
return plate;
}
private static ManifestSource Source(string timingPlate, params string[] plates) => new()
{
TimingPlate = new PlateRef { Path = timingPlate, FrameStart = 1001 },
Plates = plates.Select(p => new PlateRef { Path = p, FrameStart = 1001 }).ToList(),
HandleFrames = 8,
};
[Fact]
public void AnotherDeliveredPlatePassesWhenTheTimingPlateDoesNot()
{
// The comp was anchored to FG01, but the panel named BG01 (longest) as
// the timing plate — the two carry unrelated camera timecodes.
WriteSequence(105, "09:12:00:00");
var bg = WritePlate("BG01", "14:26:31:08");
var fg = WritePlate("FG01", "09:12:00:00");
var manifest = Manifest(_dir, null, 1001);
manifest.Source = Source(bg, bg, fg);
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
Assert.Equal("PASS", tc.Status);
Assert.Contains("UNG_107_051_010_FG01_TT_v001.01001.exr", tc.Message);
Assert.Contains("BG01", tc.Message);
}
[Fact]
public void NoMatchingPlateStillFailsAgainstTheTimingPlate()
{
WriteSequence(105, "14:26:31:16");
var bg = WritePlate("BG01", "14:26:31:08");
var fg = WritePlate("FG01", "09:12:00:00");
var manifest = Manifest(_dir, null, 1001);
manifest.Source = Source(bg, bg, fg);
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
Assert.Equal("FAIL", tc.Status);
Assert.Equal("14:26:31:08", tc.Expected);
Assert.Contains("8-frame handle", tc.Message);
Assert.Contains("1 other delivered plate", tc.Message);
}
[Fact]
public void APreRenderIsNeverUsedAsTheFallbackPlate()
{
// plates[] is every file in the comp. A pre-render was rendered from
// this comp, so it agrees with the render whether or not either is right.
WriteSequence(105, "14:26:31:16");
var bg = WritePlate("BG01", "14:26:31:08");
var preRender = Path.Combine(_dir, "renders", "prerender", "UNG_107_051_010_PRERENDER_01001.exr");
Directory.CreateDirectory(Path.GetDirectoryName(preRender)!);
File.Move(ExrFixture.Write("14:26:31:16", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), preRender);
var manifest = Manifest(_dir, null, 1001);
manifest.Source = Source(bg, bg, preRender);
Assert.Equal("FAIL", Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match").Status);
}
[Fact]
public void APlateAtADifferentRateFailsPlateFps()
{
+36 -18
View File
@@ -234,26 +234,30 @@ public sealed class AerenderRunner
}
/// <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.
/// §7.3: outputDir is created before launch; a pre-existing dir can hold
/// frames from a previous crashed attempt, which aerender would otherwise
/// leave interleaved with this attempt's. Safety guard: only files matching
/// the job's own output pattern — its prefix *and* its extension — are ever
/// deleted.
///
/// The extension is not optional. The version folder is shared with the
/// preview stage, whose MOV and MP4 are built from the same basename
/// (preview.ts: `{outputDir}/{base}.mov`), so prefix alone also matches
/// them. A version folder can legitimately be rendered into twice — the
/// output path is keyed on the shot's manually-set version, not on the
/// export — and on a no-preview re-render (Export.skipPreview) the media
/// that gets inherited is precisely the MOV sitting in this folder.
/// Deleting it left the export pointing at a file that no longer existed,
/// and delivery silently packaged the EXRs without their viewing copy.
/// </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;
var pattern = SequencePattern.FromOutputPattern(outputPattern);
if (string.IsNullOrEmpty(pattern.Prefix)) return;
foreach (var file in Directory.EnumerateFiles(outputDirLocal))
{
if (Path.GetFileName(file).StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
if (pattern.MatchesOutputFile(Path.GetFileName(file)))
{
try { File.Delete(file); } catch { /* locked leftovers surface as an aerender error */ }
}
@@ -276,11 +280,19 @@ public sealed class AerenderRunner
}
}
/// <summary>Ring buffer of the last N output lines (§5.3 logTail).</summary>
/// <summary>
/// Ring buffer of the last N output lines (§5.3 logTail).
///
/// Thread-safe: output readers add lines while the progress loop reads the
/// tail for its report. Unsynchronised, ToString enumerates the queue while a
/// reader enqueues and throws "Collection was modified" — Blender, which
/// writes a status line many times a second, hit this on every job.
/// </summary>
public sealed class LogTail
{
private readonly Queue<string> _lines;
private readonly int _capacity;
private readonly object _gate = new();
public LogTail(int capacity)
{
@@ -290,9 +302,15 @@ public sealed class LogTail
public void Add(string line)
{
_lines.Enqueue(line);
while (_lines.Count > _capacity) _lines.Dequeue();
lock (_gate)
{
_lines.Enqueue(line);
while (_lines.Count > _capacity) _lines.Dequeue();
}
}
public override string ToString() => string.Join('\n', _lines);
public override string ToString()
{
lock (_gate) return string.Join('\n', _lines);
}
}
+149 -11
View File
@@ -13,10 +13,16 @@ namespace VFXReviewWorker;
public sealed class ApiClient
{
private readonly HttpClient _http;
private readonly WorkerOptions _options;
private readonly ILogger<ApiClient> _log;
// Presigned uploads: a separate client so the URL is not sent with our
// Authorization header, and a generous per-request timeout for media.
private static readonly HttpClient _upload = new() { Timeout = TimeSpan.FromMinutes(30) };
public ApiClient(WorkerOptions options, ILogger<ApiClient> log)
{
_options = options;
_log = log;
_http = new HttpClient
{
@@ -124,6 +130,7 @@ public sealed class ApiClient
"validation" => $"api/ext/render/jobs/{jobId}/validation",
"delivery" => $"api/ext/render/jobs/{jobId}/delivery",
"finalize" => $"api/ext/render/jobs/{jobId}/finalize",
"attach-highres" => $"api/ext/render/jobs/{jobId}/attach-highres",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
};
var res = await _http.PostAsJsonAsync(path, payload, Json, ct);
@@ -143,8 +150,10 @@ public sealed class ApiClient
}
// E20 + PUT upload. Returns the storage key, or null when the upload failed.
// onProgress receives the bytes sent so far, for status lines in the app.
public async Task<string?> UploadArtifactAsync(
string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct)
string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct,
Action<long>? onProgress = null)
{
try
{
@@ -153,6 +162,13 @@ public sealed class ApiClient
_log.LogWarning("Artifact {Kind} not found at {Path}", kind, filePath);
return null;
}
var size = new FileInfo(filePath).Length;
var sw = System.Diagnostics.Stopwatch.StartNew();
// Uploads can run for many minutes on a slow uplink; without these two
// lines the log goes silent and a busy worker looks hung.
if (kind != "log")
_log.LogInformation("Uploading {Kind} for job {JobId}: {File} ({MB:F1} MB)",
kind, jobId, Path.GetFileName(filePath), size / 1048576.0);
var presignRes = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-presign", new
{
@@ -160,6 +176,8 @@ public sealed class ApiClient
kind,
fileName = Path.GetFileName(filePath),
contentType,
multipart = size >= MultipartThreshold,
size,
}, Json, ct);
if (!presignRes.IsSuccessStatusCode)
{
@@ -169,18 +187,27 @@ public sealed class ApiClient
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)
if (presign.UploadId is not null && presign.PartUrls is { Count: > 0 } && presign.PartSize > 0)
{
_log.LogWarning("Upload of {Kind} failed: {Status}", kind, (int)putRes.StatusCode);
return null;
if (!await UploadMultipartAsync(jobId, machineId, kind, filePath, size, presign, onProgress, ct))
return null;
}
else
{
// Single PUT: small files, or a server that predates multipart.
await using var stream = File.OpenRead(filePath);
var content = new ProgressStreamContent(stream, onProgress);
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
var putRes = await _upload.PutAsync(presign.PresignedUrl, content, ct);
if (!putRes.IsSuccessStatusCode)
{
_log.LogWarning("Upload of {Kind} failed after {Sec:F0}s: {Status}", kind, sw.Elapsed.TotalSeconds, (int)putRes.StatusCode);
return null;
}
}
if (kind != "log")
_log.LogInformation("Uploaded {Kind} for job {JobId} in {Sec:F0}s ({Speed:F1} MB/s)",
kind, jobId, sw.Elapsed.TotalSeconds, size / 1048576.0 / Math.Max(0.001, sw.Elapsed.TotalSeconds));
return presign.Key;
}
catch (Exception ex)
@@ -190,6 +217,117 @@ public sealed class ApiClient
}
}
// Files at least this big go up as parallel multipart parts (E20b).
private const long MultipartThreshold = 16L * 1024 * 1024;
private const int PartAttempts = 4;
/// <summary>
/// Uploads the parts concurrently (UploadConcurrency connections), each
/// retried on its own so a dropped connection costs one part, not the
/// file; then asks the server to stitch them. Aborts on failure so the
/// stored parts don't linger in the bucket.
/// </summary>
private async Task<bool> UploadMultipartAsync(string jobId, string machineId, string kind, string filePath,
long size, PresignResponse presign, Action<long>? onProgress, CancellationToken ct)
{
var partUrls = presign.PartUrls!;
var partSize = presign.PartSize;
var etags = new string?[partUrls.Count];
var sentPerPart = new long[partUrls.Count];
long sentTotal = 0;
var next = -1;
using var handle = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, FileOptions.Asynchronous);
using var failed = CancellationTokenSource.CreateLinkedTokenSource(ct);
async Task Worker()
{
int i;
while ((i = Interlocked.Increment(ref next)) < partUrls.Count && !failed.IsCancellationRequested)
{
var offset = i * partSize;
var len = (int)Math.Min(partSize, size - offset);
var buffer = new byte[len];
var read = 0;
while (read < len) read += await RandomAccess.ReadAsync(handle, buffer.AsMemory(read), offset + read, failed.Token);
for (var attempt = 1; ; attempt++)
{
var part = i;
// Progress restarts from zero for a retried part.
Interlocked.Add(ref sentTotal, -Interlocked.Exchange(ref sentPerPart[part], 0));
var content = new ProgressStreamContent(new MemoryStream(buffer, writable: false), sent =>
{
var delta = sent - Interlocked.Exchange(ref sentPerPart[part], sent);
onProgress?.Invoke(Interlocked.Add(ref sentTotal, delta));
});
try
{
var res = await _upload.PutAsync(partUrls[i], content, failed.Token);
var etag = res.Headers.ETag?.Tag
?? (res.Headers.TryGetValues("ETag", out var v) ? v.FirstOrDefault() : null);
if (res.IsSuccessStatusCode && etag is not null)
{
etags[i] = etag;
break;
}
_log.LogWarning("Part {Part}/{Count} of {Kind} for job {JobId} failed: {Status} (attempt {N}/{Max})",
i + 1, partUrls.Count, kind, jobId, (int)res.StatusCode, attempt, PartAttempts);
}
catch (Exception ex) when (!failed.IsCancellationRequested)
{
_log.LogWarning("Part {Part}/{Count} of {Kind} for job {JobId} failed: {Error} (attempt {N}/{Max})",
i + 1, partUrls.Count, kind, jobId, ex.Message, attempt, PartAttempts);
}
if (attempt >= PartAttempts)
{
failed.Cancel(); // no point finishing the other parts
return;
}
await Task.Delay(TimeSpan.FromSeconds(5 * attempt), failed.Token);
}
}
}
var concurrency = Math.Clamp(_options.UploadConcurrency, 1, 64);
try
{
await Task.WhenAll(Enumerable.Range(0, Math.Min(concurrency, partUrls.Count)).Select(_ => Task.Run(Worker)));
}
catch (OperationCanceledException)
{
// a part gave up, or shutdown — either way the upload is aborted below
}
var complete = !failed.IsCancellationRequested && etags.All(e => e is not null);
if (complete)
{
var res = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-complete", new
{
machineId,
key = presign.Key,
uploadId = presign.UploadId,
parts = etags.Select((etag, i) => new { partNumber = i + 1, etag }),
}, Json, ct);
if (res.IsSuccessStatusCode) return true;
_log.LogWarning("Completing multipart {Kind} for job {JobId} failed: {Status} {Body}",
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
}
try
{
await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-complete", new
{
machineId,
key = presign.Key,
uploadId = presign.UploadId,
abort = true,
}, Json, CancellationToken.None);
}
catch { /* parts expire on their own */ }
return false;
}
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct);
@@ -8,18 +8,36 @@ namespace VFXReviewWorker;
/// 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.
///
/// Blender 5.x logs through CLOG, which prefixes every line with an elapsed
/// time and a category ("00:02.954 render | Saved: '...'") and
/// reports errors as "ERROR ..." rather than "Error: ...". The prefix is
/// stripped before matching, so both formats parse the same way.
/// </summary>
public static partial class BlenderProgressParser
{
[GeneratedRegex(@"^Fra:(\d+)", RegexOptions.Compiled)]
[GeneratedRegex(@"^(?:\d+:)?\d{2}:\d{2}\.\d{3}\s+\S+\s*\| ?", RegexOptions.Compiled)]
private static partial Regex LogPrefix();
[GeneratedRegex(@"^Fra:\s*(\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)]
// Errors only. Warnings are not failures, and add-ons print them on every
// launch ("Warning: add-on missing 'bl_info'"), so counting them made an
// add-on's complaint the reported reason for a failed render.
[GeneratedRegex(@"(?i:^Error: )|^(ERROR|FATAL) ", RegexOptions.Compiled)]
private static partial Regex ErrorLine();
/// <summary>The line without Blender 5.x's "elapsed category | " prefix.</summary>
private static string Message(string line)
{
var m = LogPrefix().Match(line);
return m.Success ? line[m.Length..] : line;
}
/// <summary>
/// Failures that will repeat identically on another machine — a missing
/// file, an unknown scene, an unwritable output. Retrying these burns the
@@ -35,7 +53,9 @@ public static partial class BlenderProgressParser
"unknown scene",
"scene not found",
"unable to create directory",
"couldn't create directory",
"cannot write file",
"cannot save:",
"no camera",
"python script fail",
"unknown argument",
@@ -44,18 +64,18 @@ public static partial class BlenderProgressParser
/// <summary>The frame Blender is currently working on, or null.</summary>
public static int? ParseFrame(string line)
{
var m = FrameLine().Match(line);
var m = FrameLine().Match(Message(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);
var m = SavedLine().Match(Message(line));
return m.Success ? m.Groups[1].Value : null;
}
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line);
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(Message(line));
public static bool IsNonRetryableError(string line)
{
+75 -57
View File
@@ -190,75 +190,93 @@ public sealed class BlenderRunner
PumpAsync(process.StandardError, line => HandleLine(line, isStderr: true)),
};
while (!process.HasExited)
try
{
await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
return await MonitorAsync();
}
catch
{
// Disposing a Process does not end it: without this, any exception
// in the monitor loop leaves Blender rendering on the node,
// unsupervised, while the job is retried alongside it.
KillTree(process);
throw;
}
if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested)
async Task<RenderResult> MonitorAsync()
{
while (!process.HasExited)
{
_log.LogWarning("Cancellation requested — killing Blender tree for job {JobId}", jobId);
KillTree(process);
cancelledByServer = cancelJob.IsCancellationRequested;
break;
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;
}
}
}
if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds)
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)
{
_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)",
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);
}
if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10)
// 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)
{
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;
}
return new RenderResult(false, 0, Retryable: false,
firstError is not null
? $"Blender exited cleanly but saved no frames: {firstError}"
: "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);
}
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>
+10
View File
@@ -175,6 +175,11 @@ public sealed class PreviewManifest
public string Mp4Output { get; set; } = "";
public string? SlateScopeProp { get; set; }
public string? SlateSubmissionProp { get; set; }
/// <summary>
/// Also encode the High quality MP4 from the MOV and upload the MOV itself
/// as the version's High Res file (server "preview.highQuality").
/// </summary>
public bool HighQuality { get; set; }
public SlateInfo Slate { get; set; } = new();
public int TotalFrames => FrameEnd - FrameStart + 1;
@@ -299,6 +304,11 @@ public sealed class PresignResponse
public string PresignedUrl { get; set; } = "";
public string Key { get; set; } = "";
public string Url { get; set; } = "";
// Multipart (server ≥ 1.7.2 contract) — absent from older servers, which
// only ever return PresignedUrl.
public string? UploadId { get; set; }
public long PartSize { get; set; }
public List<string>? PartUrls { get; set; }
}
public sealed class ExportDetailResponse
@@ -0,0 +1,143 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// Uploads each preview's ProRes MOV (the version's High Res download) in the
/// background, after the job has already been finalized.
///
/// The MOV is by far the largest upload — hundreds of MB — and on a slow or
/// flaky uplink it held every preview job open for 10+ minutes, during which
/// the job looked stuck in the app and a worker restart threw the whole
/// preview away. Now the job completes as soon as the review MP4s are up, the
/// worker moves on to the next job, and the MOV follows; an "attach-highres"
/// report (through the ordered spooler, so always after its finalize) attaches
/// it to the version.
///
/// Pending uploads are files in highres-queue/, so a restart resumes them. The
/// MOV itself lives on the SAN beside the EXRs, never in scratch.
/// </summary>
public sealed class HighResUploader
{
private const int MaxAttempts = 5;
private readonly ApiClient _api;
private readonly ReportSpooler _spooler;
private readonly ILogger<HighResUploader> _log;
private readonly string _dir;
private readonly SemaphoreSlim _wake = new(0);
public HighResUploader(ApiClient api, ReportSpooler spooler, ILogger<HighResUploader> log)
{
_api = api;
_spooler = spooler;
_log = log;
_dir = Path.Combine(WorkerOptions.DataDir, "highres-queue");
Directory.CreateDirectory(_dir);
}
private sealed class Pending
{
public string JobId { get; set; } = "";
public string MachineId { get; set; } = "";
public string MovPath { get; set; } = "";
public int Attempts { get; set; }
}
public void Enqueue(string jobId, string machineId, string movPath)
{
var item = new Pending { JobId = jobId, MachineId = machineId, MovPath = movPath };
var name = $"{DateTimeOffset.UtcNow.UtcTicks:D20}_{jobId}.json";
var tmp = Path.Combine(_dir, name + ".tmp");
File.WriteAllText(tmp, JsonSerializer.Serialize(item, WorkerOptions.JsonOpts));
File.Move(tmp, Path.Combine(_dir, name));
_log.LogInformation("ProRes for job {JobId} queued for background upload ({MB:F0} MB)",
jobId, File.Exists(movPath) ? new FileInfo(movPath).Length / 1048576.0 : 0);
_wake.Release();
}
/// <summary>Long-running loop; started once by the worker service.</summary>
public async Task RunAsync(CancellationToken ct)
{
var backoff = TimeSpan.FromSeconds(30);
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(30), ct); } catch (OperationCanceledException) { break; }
continue;
}
Pending? item;
try { item = JsonSerializer.Deserialize<Pending>(File.ReadAllText(next), WorkerOptions.JsonOpts); }
catch { item = null; }
if (item is null || string.IsNullOrEmpty(item.JobId))
{
_log.LogWarning("Discarding unreadable ProRes queue file {File}", next);
TryDelete(next);
continue;
}
if (!File.Exists(item.MovPath))
{
_log.LogWarning("ProRes for job {JobId} is gone ({Path}) — not uploading", item.JobId, item.MovPath);
Report(item, null, $"MOV not found at {item.MovPath}");
TryDelete(next);
continue;
}
string? key;
try
{
key = await _api.UploadArtifactAsync(item.JobId, item.MachineId, "highres", item.MovPath, "video/quicktime", ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_log.LogInformation("Worker stopping — ProRes upload for job {JobId} resumes on next start", item.JobId);
break;
}
if (key is not null)
{
Report(item, key, null);
TryDelete(next);
backoff = TimeSpan.FromSeconds(30);
continue;
}
item.Attempts++;
if (item.Attempts >= MaxAttempts)
{
_log.LogWarning("ProRes upload for job {JobId} failed {N} times — giving up", item.JobId, item.Attempts);
Report(item, null, $"upload failed {item.Attempts} times (see the worker log)");
TryDelete(next);
continue;
}
_log.LogWarning("ProRes upload for job {JobId} failed (attempt {N}/{Max}); retrying in {Sec}s",
item.JobId, item.Attempts, MaxAttempts, backoff.TotalSeconds);
try { File.WriteAllText(next, JsonSerializer.Serialize(item, WorkerOptions.JsonOpts)); } catch { /* retried anyway */ }
try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { break; }
backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 600));
}
}
private void Report(Pending item, string? key, string? error)
{
_spooler.Enqueue("attach-highres", item.JobId, new Dictionary<string, object?>
{
["machineId"] = item.MachineId,
["highResKey"] = key,
["highResFileName"] = key is null ? null : Path.GetFileName(item.MovPath),
["highResSize"] = key is null ? null : new FileInfo(item.MovPath).Length,
["error"] = error,
});
}
private static void TryDelete(string path)
{
try { File.Delete(path); } catch { /* next pass */ }
}
}
+59 -6
View File
@@ -18,11 +18,14 @@ public sealed class JobExecutor
private readonly DeliveryStage _delivery;
private readonly BlenderRunner _blender;
private readonly ReportSpooler _spooler;
private readonly HighResUploader _highRes;
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)
ValidationStage validation, DeliveryStage delivery, BlenderRunner blender, ReportSpooler spooler,
HighResUploader highRes, ILogger<JobExecutor> log)
{
_highRes = highRes;
_options = options;
_api = api;
_runner = runner;
@@ -76,12 +79,15 @@ public sealed class JobExecutor
{
long totalBytes = 0;
int fileCount = 0;
var prefix = AerenderRunner.OutputPatternPrefix(manifest.OutputPattern);
// Prefix and extension both: the preview MOV/MP4 for this
// version share the basename and live in this same folder, and
// counting them as frames would inflate the EXR stats.
var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern);
try
{
foreach (var f in Directory.EnumerateFiles(outputDirLocal))
{
if (!Path.GetFileName(f).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
if (!pattern.MatchesOutputFile(Path.GetFileName(f))) continue;
fileCount++;
totalBytes += new FileInfo(f).Length;
}
@@ -94,8 +100,8 @@ public sealed class JobExecutor
// 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);
_log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*{Ext}\" files exist in {Dir} — reporting failure",
job.Id, pattern.Prefix, pattern.Extension, outputDirLocal);
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
@@ -415,8 +421,15 @@ public sealed class JobExecutor
return; // temp AEP is deliberately kept for debugging
}
// From here on aerender is done and the app would keep showing its
// last frame line; this reports what the worker is doing instead,
// and renews the lease through the encode and uploads.
await using var status = new LeaseKeepAlive(_api, job.Id, machineId, TimeSpan.FromSeconds(10), shutdown);
status.Line($"Preview rendered in {result.Seconds:F0}s — uploading review media");
var previewKey = await _api.UploadArtifactAsync(
job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown);
job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown,
status.Track("preview MP4", result.Mp4Path!));
if (previewKey is null)
{
// The renders are on the SAN and fine — only the upload failed,
@@ -430,6 +443,7 @@ public sealed class JobExecutor
});
return;
}
status.Line("Preview MP4 uploaded");
string? thumbKey = null;
if (result.ThumbnailPath is not null)
@@ -438,6 +452,30 @@ public sealed class JobExecutor
job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown);
}
// High quality MP4 + the MOV as the version's High Res download
// (§9.6). Both optional: a failure here costs the extras, never the
// preview, which is already safely uploaded. The MOV is uploaded in
// the background after finalize (HighResUploader) — it is the big
// one and used to hold the job open for many minutes.
string? hqKey = null;
var highResPending = false;
if (manifest.HighQuality)
{
status.Line("Encoding High quality MP4");
var hqPath = await _preview.TryMakeHighQualityAsync(job.Id, result.MovPath!, result.Mp4Path!, shutdown);
if (hqPath is not null)
{
hqKey = await _api.UploadArtifactAsync(job.Id, machineId, "preview", hqPath, "video/mp4", shutdown,
status.Track("High quality MP4", hqPath));
if (hqKey is null) _log.LogWarning("High quality MP4 upload failed for job {JobId}", job.Id);
status.Line(hqKey is null ? "High quality MP4 upload FAILED (see worker log)" : "High quality MP4 uploaded");
}
highResPending = result.MovPath is not null && File.Exists(result.MovPath);
if (highResPending)
status.Line("ProRes MOV queued for background upload — completing the job now");
}
_spooler.Enqueue("finalize", job.Id, new Dictionary<string, object?>
{
["machineId"] = machineId,
@@ -446,6 +484,8 @@ public sealed class JobExecutor
["deliveryMovPath"] = manifest.MovOutput,
["previewMovKey"] = previewKey,
["thumbnailKey"] = thumbKey,
["hqPreviewKey"] = hqKey,
["highResPending"] = highResPending,
},
["renderStats"] = new Dictionary<string, object?>
{
@@ -458,9 +498,16 @@ public sealed class JobExecutor
["frameCount"] = result.Build?.FrameCount,
["fps"] = manifest.Fps,
["fileName"] = Path.GetFileName(result.Mp4Path!),
// Slate length, so the review player can place the handle
// markers exactly instead of inferring it (server ≥ 2026-09-22).
["pictureStartSeconds"] = result.Build?.PictureStartSeconds,
},
});
// Enqueued after finalize: its attach report must reach the spooler
// behind finalize, which creates the version it attaches to.
if (highResPending) _highRes.Enqueue(job.Id, machineId, result.MovPath!);
// Only clean up once the outputs are safely reported.
CleanScratch(job.Id, result.TempAep);
}
@@ -488,6 +535,12 @@ public sealed class JobExecutor
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);
try
{
var hqDir = PreviewStage.HighQualityDir(jobId);
if (Directory.Exists(hqDir)) Directory.Delete(hqDir, recursive: true);
}
catch { /* scratch only */ }
}
/// <summary>
@@ -0,0 +1,103 @@
using System.Diagnostics;
using System.Text;
namespace VFXReviewWorker;
/// <summary>
/// Renews a job's lease with progress reports while the worker does something
/// long that reports nothing on its own — the High quality encode and the
/// uploads can each outlast a 300 s lease, and an expired lease lets the
/// reaper hand the job to another machine mid-upload.
///
/// Each report also replaces the job's log tail with what the worker is doing
/// now ("Uploading High quality MP4: 12.0 / 34.0 MB…"). Without it the app
/// keeps showing aerender's last frame line and a busy job looks hung.
/// </summary>
internal sealed class LeaseKeepAlive : IAsyncDisposable
{
private readonly ApiClient _api;
private readonly string _jobId;
private readonly string _machineId;
private readonly CancellationTokenSource _cts;
private readonly SemaphoreSlim _wake = new(0);
private readonly Task _loop;
private readonly object _lock = new();
private readonly List<string> _lines = new();
private string? _current;
public LeaseKeepAlive(ApiClient api, string jobId, string machineId, TimeSpan interval, CancellationToken shutdown)
{
_api = api;
_jobId = jobId;
_machineId = machineId;
_cts = CancellationTokenSource.CreateLinkedTokenSource(shutdown);
var token = _cts.Token;
_loop = Task.Run(async () =>
{
try
{
while (!token.IsCancellationRequested)
{
// Best-effort by contract (ApiClient.ProgressAsync never throws
// on transport errors); progress stays at 1.0 — the render is done.
await _api.ProgressAsync(_jobId, _machineId, 1.0, null, null, null, Render(), token);
await _wake.WaitAsync(interval, token);
}
}
catch (OperationCanceledException)
{
// stopped
}
});
}
/// <summary>Adds a finished step to the status shown in the app.</summary>
public void Line(string text)
{
lock (_lock)
{
_current = null;
_lines.Add($"{DateTime.Now:HH:mm:ss} {text}");
}
_wake.Release();
}
/// <summary>
/// Progress callback for <see cref="ApiClient.UploadArtifactAsync"/>: keeps
/// one live line ("Uploading X: 12.0 / 34.0 MB (35%, 2.1 MB/s)") up to date.
/// </summary>
public Action<long> Track(string label, string filePath)
{
long total = File.Exists(filePath) ? new FileInfo(filePath).Length : 0;
var sw = Stopwatch.StartNew();
lock (_lock) _current = $"Uploading {label}: 0.0 / {Mb(total)} MB";
_wake.Release();
return sent =>
{
var pct = total > 0 ? 100.0 * sent / total : 0;
var speed = sent / 1048576.0 / Math.Max(0.001, sw.Elapsed.TotalSeconds);
lock (_lock) _current = $"Uploading {label}: {Mb(sent)} / {Mb(total)} MB ({pct:F0}%, {speed:F1} MB/s)";
};
}
private static string Mb(long bytes) => (bytes / 1048576.0).ToString("F1");
private string Render()
{
lock (_lock)
{
var sb = new StringBuilder();
foreach (var l in _lines) sb.AppendLine(l);
if (_current is not null) sb.AppendLine($"{DateTime.Now:HH:mm:ss} {_current}");
return sb.ToString();
}
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try { await _loop; } catch { /* already reported */ }
_cts.Dispose();
_wake.Dispose();
}
}
@@ -302,6 +302,107 @@ public sealed class PreviewStage
return null;
}
/// <summary>Scratch folder for a job's High quality encode.</summary>
internal static string HighQualityDir(string jobId) =>
Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_hq");
/// <summary>
/// High quality review MP4 (§9.6): the delivery MOV re-encoded as
/// full-resolution, high-bitrate H.264 High — the player's "High" quality.
/// ProRes itself only plays in Safari on a Mac, so the MOV is offered as a
/// download and this is what streams. Never fatal: without it the version
/// simply has Standard quality only.
/// </summary>
public async Task<string?> TryMakeHighQualityAsync(
string jobId, string movPath, string mp4Path, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_options.FfmpegPath) || !File.Exists(_options.FfmpegPath))
{
_log.LogWarning("No High quality MP4 for job {JobId}: ffmpegPath is not set or missing.", jobId);
return null;
}
if (!File.Exists(movPath))
{
_log.LogWarning("No High quality MP4 for job {JobId}: MOV not found at {Path}.", jobId, movPath);
return null;
}
var dir = HighQualityDir(jobId);
Directory.CreateDirectory(dir);
// Named after the review MP4 so the stored object reads as its sibling.
var outPath = Path.Combine(dir, Path.GetFileNameWithoutExtension(mp4Path) + "_HQ.mp4");
TryDelete(outPath);
var args = new List<string>
{
"-y", "-i", movPath,
"-map", "0:v:0", "-map", "0:a?",
// Visually lossless for review, capped so a busy shot can't balloon
// past what a good connection streams. yuv420p 8-bit High profile
// is the combination every browser decodes.
"-c:v", "libx264", "-profile:v", "high", "-preset", "medium", "-crf", "14",
"-maxrate", "100M", "-bufsize", "200M", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "256k",
// Index at the front so the player can start before it's all loaded
"-movflags", "+faststart",
outPath,
};
var psi = new ProcessStartInfo
{
FileName = _options.FfmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
var sw = Stopwatch.StartNew();
try
{
using var process = Process.Start(psi);
if (process is null)
{
_log.LogWarning("Could not start ffmpeg for the High quality MP4 (job {JobId})", jobId);
return null;
}
// Drain both pipes (see RunFfmpegThumbnailAsync — an unread pipe deadlocks ffmpeg).
var stderrTask = process.StandardError.ReadToEndAsync();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromMinutes(30));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
_log.LogWarning("High quality encode for job {JobId} timed out or was cancelled — killing ffmpeg", jobId);
try { process.Kill(entireProcessTree: true); } catch { }
TryDelete(outPath);
return null;
}
var stderr = await stderrTask;
await stdoutTask;
if (process.ExitCode == 0 && File.Exists(outPath) && new FileInfo(outPath).Length > 0)
{
_log.LogInformation("High quality MP4 for job {JobId} encoded in {Sec:F0}s ({MB:F0} MB)",
jobId, sw.Elapsed.TotalSeconds, new FileInfo(outPath).Length / 1e6);
return outPath;
}
_log.LogWarning("High quality encode failed for job {JobId} (exit {Exit}): {Stderr}",
jobId, process.ExitCode, Tail(stderr, 1200));
TryDelete(outPath);
return null;
}
catch (Exception ex)
{
_log.LogWarning(ex, "High quality encode failed for job {JobId} (non-fatal)", jobId);
return null;
}
}
/// <summary>How one ffmpeg attempt ended.</summary>
private enum ThumbAttempt
{
+1
View File
@@ -37,6 +37,7 @@ builder.Logging.AddProvider(new FileLoggerProvider());
builder.Services.AddSingleton(options);
builder.Services.AddSingleton<ApiClient>();
builder.Services.AddSingleton<ReportSpooler>();
builder.Services.AddSingleton<HighResUploader>();
builder.Services.AddSingleton<AerenderRunner>();
builder.Services.AddSingleton<PreviewStage>();
builder.Services.AddSingleton<ValidationStage>();
@@ -0,0 +1,42 @@
using System.Net;
namespace VFXReviewWorker;
/// <summary>
/// StreamContent that reports bytes sent, so a long upload can show progress.
/// Length is computed from the stream, so the PUT still carries Content-Length
/// (presigned S3 uploads reject chunked bodies).
/// </summary>
internal sealed class ProgressStreamContent : HttpContent
{
private readonly Stream _source;
private readonly Action<long>? _onProgress;
public ProgressStreamContent(Stream source, Action<long>? onProgress)
{
_source = source;
_onProgress = onProgress;
}
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
SerializeToStreamAsync(stream, context, CancellationToken.None);
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken ct)
{
var buffer = new byte[81920];
long sent = 0;
int read;
while ((read = await _source.ReadAsync(buffer, ct)) > 0)
{
await stream.WriteAsync(buffer.AsMemory(0, read), ct);
sent += read;
_onProgress?.Invoke(sent);
}
}
protected override bool TryComputeLength(out long length)
{
length = _source.Length;
return true;
}
}
@@ -15,7 +15,7 @@
every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b".
-->
<Version>1.6.0</Version>
<Version>1.7.2</Version>
</PropertyGroup>
<ItemGroup>
+110 -18
View File
@@ -350,6 +350,31 @@ public sealed class ValidationStage
return;
}
var deltaOf = (TimecodeReference r) =>
renderTc.ToFrames(fps) - (r.PlateTimecode.ToFrames(fps) + (renderFirstFrame - r.PlateFirstFrame));
// A shot with several delivered elements: the panel names the longest
// as the timing plate, but the comp may have been anchored to another
// (BG01 and FG01 carry unrelated camera timecodes). When the timing
// plate disagrees, the manifest's other delivered plates are tried
// before failing. The comparison is unchanged — it is still a plate, so
// a match is a real PASS and no match still FAILs against the timing plate.
var timingPlate = reference;
var alternatesTried = new List<object>();
if (reference.Kind == "manifest" && deltaOf(reference) != 0)
{
foreach (var alternate in ResolveAlternatePlates(m, renderFirstFrame, reference))
{
var altDelta = deltaOf(alternate);
alternatesTried.Add(new { platePath = alternate.PlatePath, plateTimecode = alternate.PlateTimecode.ToString(), deltaFrames = altDelta });
if (altDelta == 0)
{
reference = alternate;
break;
}
}
}
// plate_fps — the render's rate against the plate's own.
//
// timecode_fps only compares the render to the manifest, and both come
@@ -377,8 +402,8 @@ public sealed class ValidationStage
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 deltaFrames = deltaOf(reference);
var matchedAlternate = !ReferenceEquals(reference, timingPlate);
var details = new
{
reference = reference.Kind,
@@ -387,10 +412,14 @@ public sealed class ValidationStage
plateFirstFrame = reference.PlateFirstFrame,
renderFirstFrame,
deltaFrames,
timingPlatePath = timingPlate.PlatePath,
matchedAlternate,
alternatesTried,
};
if (deltaFrames == 0)
{
var timingDelta = deltaOf(timingPlate);
// 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.
@@ -398,7 +427,11 @@ public sealed class ValidationStage
? 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));
$"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}" +
(matchedAlternate
? $" (the timing plate {Path.GetFileName(timingPlate.PlatePath ?? "")} is {(timingDelta > 0 ? "+" : "")}{timingDelta} frames off — the comp was anchored to this element instead)"
: ""),
details));
return;
}
@@ -409,7 +442,10 @@ public sealed class ValidationStage
? $"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."
: "");
: "") +
(alternatesTried.Count > 0
? $" Also checked {alternatesTried.Count} other delivered plate{(alternatesTried.Count == 1 ? "" : "s")} in the manifest: none match."
: "");
checks.Add(reference.Kind == "api"
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
@@ -430,20 +466,9 @@ public sealed class ValidationStage
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);
var manifestPlate = ReadManifestPlate(timing, mapper, handleFrames, renderFirstFrame);
if (manifestPlate is not null) return manifestPlate;
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", mapper.Map(timing.Path));
}
// Rank 2 — derive the plate from a configured search root.
@@ -469,6 +494,63 @@ public sealed class ValidationStage
return null;
}
private static TimecodeReference? ReadManifestPlate(PlateRef plate, PathMapper mapper, int handleFrames, int renderFirstFrame)
{
var local = mapper.Map(plate.Path);
var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
if (header?.Timecode is null) return 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))
?? plate.FrameStart
?? renderFirstFrame;
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
header.FramesPerSecond);
}
/// <summary>
/// The manifest's other delivered plates, tried when the timing plate does
/// not match. Only real footage qualifies — under a configured plate search
/// root or, with none configured, alongside the timing plate in its footage
/// folder — because plates[] is every file in the comp, and a pre-render
/// agrees with the render whether or not either is right.
/// </summary>
private IEnumerable<TimecodeReference> ResolveAlternatePlates(RenderManifest m, int renderFirstFrame, TimecodeReference timingPlate)
{
var plates = m.Source?.Plates;
if (plates is null || plates.Count == 0 || timingPlate.PlatePath is null) yield break;
var mapper = new PathMapper(_options.PathMappings);
static string AsFolder(string dir) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)) + Path.DirectorySeparatorChar;
var deliveredRoots = _options.PlateSearchRoots.Count > 0
? _options.PlateSearchRoots.Select(r => AsFolder(mapper.Map(r))).ToList()
// {footage}/{shot}_{element}/file.exr or {footage}/{shot}/{element}/file.exr:
// two levels up from the timing plate is the folder its siblings share.
: Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(timingPlate.PlatePath))) is { } footage
? new List<string> { AsFolder(footage) }
: new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(timingPlate.PlatePath) };
foreach (var plate in plates)
{
if (string.IsNullOrWhiteSpace(plate.Path)) continue;
var local = Path.GetFullPath(mapper.Map(plate.Path));
if (!seen.Add(local)) continue;
if (!deliveredRoots.Any(root => local.StartsWith(root, StringComparison.OrdinalIgnoreCase))) continue;
var reference = ReadManifestPlate(plate, mapper, timingPlate.HandleFrames, renderFirstFrame);
if (reference is null)
{
_log.LogWarning("Alternate plate from manifest is unreadable: {Path}", local);
continue;
}
yield return reference;
}
}
/// <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
@@ -645,6 +727,16 @@ public sealed class SequencePattern
return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding };
}
/// <summary>
/// Whether a filename belongs to *this* render's output: the pattern's own
/// prefix and extension. A version folder also holds the preview MOV and
/// MP4, which are built from the same basename and so share the prefix —
/// only the extension separates them from the frames.
/// </summary>
public bool MatchesOutputFile(string fileName) =>
fileName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)
&& fileName.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
public int? FrameNumberOf(string fileName) => FrameFromName(fileName);
/// <summary>`UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.</summary>
@@ -35,6 +35,12 @@ public sealed class WorkerOptions
/// </summary>
public List<string> PlateSearchRoots { get; set; } = new();
/// <summary>
/// Parallel connections per large upload. From the studio each connection
/// to object storage is capped near 0.1 MB/s, so throughput scales with
/// this (measured: 1 → 0.13 MB/s, 8 → 0.64, 24 → 1.69).
/// </summary>
public int UploadConcurrency { get; set; } = 16;
/// <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.
@@ -15,6 +15,7 @@ public sealed class WorkerService : BackgroundService
private readonly ApiClient _api;
private readonly JobExecutor _executor;
private readonly ReportSpooler _spooler;
private readonly HighResUploader _highRes;
private readonly ILogger<WorkerService> _log;
private string? _machineId;
@@ -23,8 +24,10 @@ public sealed class WorkerService : BackgroundService
private CancellationTokenSource? _cancelCurrentJob;
private bool? _lastAeOpen;
public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler, ILogger<WorkerService> log)
public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler,
HighResUploader highRes, ILogger<WorkerService> log)
{
_highRes = highRes;
_options = options;
_api = api;
_executor = executor;
@@ -40,6 +43,7 @@ public sealed class WorkerService : BackgroundService
_ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken);
await RegisterWithRetryAsync(stoppingToken);
_ = Task.Run(() => _highRes.RunAsync(stoppingToken), stoppingToken);
await _api.ReportVersionAsync(_options, stoppingToken);
await _executor.ReconcileAsync(_machineId!, stoppingToken);
+42 -8
View File
@@ -32,7 +32,7 @@ var SETTINGS_KEY = "cfg";
// Bump on every change deployed to a workstation. The panel reports this to
// the server on load, so Pipeline → Machines shows which version each machine
// is actually running and flags the ones left behind.
var PANEL_VERSION = "2.6.2";
var PANEL_VERSION = "2.7.1";
(function VFXReviewConnector(thisObj) {
var uiState = {
@@ -45,6 +45,8 @@ var PANEL_VERSION = "2.6.2";
pipelineStatusText: null,
urgentCheckbox: null,
skipPreviewCheckbox: null,
handlesCheckbox: null,
handleFramesField: null,
submissionNoteField: null,
qcList: null,
qcNotesField: null,
@@ -3476,6 +3478,9 @@ var PANEL_VERSION = "2.6.2";
CONFIG.bannerImagePath = trimText(fBanner.text);
CONFIG.artistEmail = trimText(fEmail.text);
CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8;
if (uiState.handleFramesField) {
uiState.handleFramesField.text = String(CONFIG.handleFrames);
}
CONFIG.pathMappings = trimText(fMappings.text);
CONFIG.sequenceStart = parseInt(fSeqStart.text, 10) || 1001;
saveConfig();
@@ -4196,7 +4201,23 @@ var PANEL_VERSION = "2.6.2";
return manifest;
}
// Handles the render carries past the cut. Review metadata only: the web
// player marks these frames as "not in edit". Nothing in the comp or the
// render changes — unticked just means the frames are all in the edit.
function reviewHandles() {
var frames;
if (!uiState.handlesCheckbox || !uiState.handlesCheckbox.value) {
return { handleHead: 0, handleTail: 0 };
}
frames = parseInt(uiState.handleFramesField ? uiState.handleFramesField.text : "", 10);
if (isNaN(frames) || frames < 0) {
frames = CONFIG.handleFrames;
}
return { handleHead: frames, handleTail: frames };
}
function submitExport(manifest, urgent, force) {
manifest.review = reviewHandles();
var body = {
manifest: manifest,
force: force ? true : false
@@ -4689,7 +4710,6 @@ var PANEL_VERSION = "2.6.2";
var importRendersButton;
var btnRow6;
var pictureLockButton;
var btnRow7;
var incrementVersionButton;
var btnRow6b;
var setStartTCButton;
@@ -4803,6 +4823,25 @@ var PANEL_VERSION = "2.6.2";
"un-punched-in EXRs — where a preview rebuilt from these frames\n" +
"would be the wrong picture.";
var handlesGroup = pipelinePanel.add("group");
handlesGroup.orientation = "row";
handlesGroup.alignChildren = ["left", "center"];
handlesGroup.spacing = 6;
uiState.handlesCheckbox = handlesGroup.add("checkbox", undefined, "Includes handles");
uiState.handlesCheckbox.value = true;
uiState.handleFramesField = handlesGroup.add("edittext", undefined, String(CONFIG.handleFrames));
uiState.handleFramesField.characters = 3;
handlesGroup.add("statictext", undefined, "frames each end");
uiState.handlesCheckbox.helpTip =
"The render runs past the cut at both ends (handles). The review\n" +
"player marks those frames HANDLE / NOT IN EDIT so a glitch in a\n" +
"handle isn't mistaken for a problem in the shot.\n\n" +
"Review-only: nothing is burned in and the EXRs are unchanged.\n" +
"Untick for a render cut exactly to the edit.";
uiState.handlesCheckbox.onClick = function () {
uiState.handleFramesField.enabled = uiState.handlesCheckbox.value;
};
var slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
@@ -4828,6 +4867,7 @@ var PANEL_VERSION = "2.6.2";
"Rebuild the MOV/MP4 slate and burn-ins for the delivered version.\n" +
"Reuses the rendered EXRs and keeps the version number. Slate text is\n" +
"re-read from the shot, so fix it on the web app's Slate tab first.";
incrementVersionButton = pipelineRow3.add("button", undefined, "Increment Shot Version");
qcPanel = scrollContent.add("panel", undefined, "QC");
qcPanel.orientation = "column";
@@ -4934,12 +4974,6 @@ var PANEL_VERSION = "2.6.2";
btnRow6b.spacing = 6;
setStartTCButton = btnRow6b.add("button", undefined, "Set Start Timecode");
btnRow7 = buttonsPanel.add("group");
btnRow7.orientation = "row";
btnRow7.alignChildren = ["fill", "center"];
btnRow7.spacing = 6;
incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version");
workspacePanel = scrollContent.add("panel", undefined, "Workspace");
workspacePanel.orientation = "column";
workspacePanel.alignChildren = ["fill", "top"];
+1 -1
View File
@@ -178,7 +178,7 @@ Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`.
The panel is divided into seven sections:
1. **Shot Builder** — Episode/shot selection and comp creation
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews, increment shot version
3. **QC** — Technical QC queue for rendered exports
4. **Actions** — Per-shot and batch operations
5. **Workspace** — One-time project initialisation
+1 -1
View File
@@ -14,7 +14,7 @@ const saveAnnotationSchema = z.object({
shapes: z.array(z.any()),
canvasWidth: z.number(),
canvasHeight: z.number(),
version: z.literal("1.0"),
version: z.enum(["1.0", "2.0"]),
}),
color: z.string().default("#ef4444"),
});
+196
View File
@@ -0,0 +1,196 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { findShot, parseBoardText } from "@/lib/board-parse";
// The board is studio-internal. Anyone on staff can add and tick off lines;
// it is a shared to-do list, not an approval surface.
async function requireStaff() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
return session.user;
}
async function boardContext(projectId: string) {
const [shots, users] = await Promise.all([
db.shot.findMany({
where: { projectId },
select: { id: true, shotCode: true, clientCode: true },
}),
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true },
}),
]);
return { shots, users };
}
const addSchema = z.object({
projectId: z.string().cuid(),
input: z.string().min(1).max(20000),
group: z.string().max(100).optional(),
});
/** Adds one line, or a pasted block of lines, to a project's board. */
export async function addBoardItems(data: z.infer<typeof addSchema>) {
const user = await requireStaff();
const parsed = addSchema.parse(data);
const items = parseBoardText(parsed.input, {
...(await boardContext(parsed.projectId)),
group: parsed.group,
});
if (items.length === 0) return { success: true, added: 0, unmatched: [] as string[] };
const last = await db.boardItem.findFirst({
where: { projectId: parsed.projectId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const base = (last?.sortOrder ?? -1) + 1;
await db.boardItem.createMany({
data: items.map((item, i) => ({
projectId: parsed.projectId,
shotId: item.shotId,
code: item.code,
text: item.text,
group: item.group,
assigneeId: item.assigneeId,
waitingOn: item.waitingOn,
sortOrder: base + i,
createdById: user.id,
})),
});
revalidatePath("/board");
return {
success: true,
added: items.length,
unmatched: items.filter((i) => i.code && !i.shotId).map((i) => i.code as string),
};
}
const updateSchema = z.object({
text: z.string().max(5000).optional(),
code: z.string().max(100).nullable().optional(),
group: z.string().max(100).optional(),
assigneeId: z.string().cuid().nullable().optional(),
waitingOn: z.string().max(500).nullable().optional(),
});
export async function updateBoardItem(id: string, data: z.infer<typeof updateSchema>) {
await requireStaff();
const parsed = updateSchema.parse(data);
const item = await db.boardItem.findUnique({ where: { id }, select: { projectId: true } });
if (!item) throw new Error("Board item not found");
// Changing the code re-links the line; a code that matches nothing is kept
// as a flag, exactly as when it was first typed.
let link: { code: string | null; shotId: string | null } | undefined;
if (parsed.code !== undefined) {
const code = parsed.code?.trim() || null;
const shot = code ? findShot(code, (await boardContext(item.projectId)).shots) : null;
link = { code: shot?.shotCode ?? code?.toUpperCase() ?? null, shotId: shot?.id ?? null };
}
await db.boardItem.update({
where: { id },
data: {
...(parsed.text !== undefined && { text: parsed.text.trim() }),
...(parsed.group !== undefined && { group: parsed.group.trim() }),
...(parsed.assigneeId !== undefined && { assigneeId: parsed.assigneeId }),
...(parsed.waitingOn !== undefined && { waitingOn: parsed.waitingOn?.trim() || null }),
...link,
},
});
revalidatePath("/board");
return { success: true };
}
export async function setBoardItemDone(id: string, done: boolean) {
const user = await requireStaff();
const item = await db.boardItem.update({
where: { id },
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
select: { commentId: true },
});
// A line from pasted feedback is that client note: ticking it off resolves
// the comment in the player too. (The comment may have been deleted there.)
if (item.commentId) {
const comment = await db.comment.updateMany({
where: { id: item.commentId },
data: { isResolved: done },
});
if (comment.count) {
const c = await db.comment.findUnique({ where: { id: item.commentId }, select: { versionId: true } });
if (c) revalidatePath(`/review/${c.versionId}`);
}
}
revalidatePath("/board");
return { success: true };
}
export async function deleteBoardItem(id: string) {
await requireStaff();
await db.boardItem.delete({ where: { id } });
revalidatePath("/board");
return { success: true };
}
const statusSchema = z.object({
projectId: z.string().cuid(),
status: z.string().max(2000),
});
export async function setProjectBoardStatus(data: z.infer<typeof statusSchema>) {
await requireStaff();
const parsed = statusSchema.parse(data);
const status = parsed.status.trim() || null;
await db.project.update({
where: { id: parsed.projectId },
data: { boardStatus: status, boardStatusAt: status ? new Date() : null },
});
revalidatePath("/board");
return { success: true };
}
const milestoneSchema = z.object({
projectId: z.string().cuid(),
label: z.string().min(1).max(200),
date: z.string().min(1),
});
export async function addMilestone(data: z.infer<typeof milestoneSchema>) {
await requireStaff();
const parsed = milestoneSchema.parse(data);
const date = new Date(parsed.date);
if (isNaN(date.getTime())) throw new Error("Invalid date");
await db.projectMilestone.create({
data: { projectId: parsed.projectId, label: parsed.label.trim(), date },
});
revalidatePath("/board");
return { success: true };
}
export async function setMilestoneDone(id: string, done: boolean) {
await requireStaff();
await db.projectMilestone.update({
where: { id },
data: { doneAt: done ? new Date() : null },
});
revalidatePath("/board");
return { success: true };
}
export async function deleteMilestone(id: string) {
await requireStaff();
await db.projectMilestone.delete({ where: { id } });
revalidatePath("/board");
return { success: true };
}
+42
View File
@@ -0,0 +1,42 @@
"use server";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
getUnreadCommentsForShot,
getUnreadCommentSummaries,
markShotCommentsRead as markRead,
} from "@/lib/comment-reads";
export async function listUnreadShotComments(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return getUnreadCommentsForShot(session.user.id, z.string().min(1).parse(shotId));
}
const markSchema = z.object({
shotId: z.string().min(1),
upTo: z.string().datetime(),
});
export async function markShotCommentsRead(shotId: string, upTo: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = markSchema.parse({ shotId, upTo });
await markRead(session.user.id, parsed.shotId, new Date(parsed.upTo));
revalidatePath("/shot-status");
return { success: true };
}
/** Mark every flagged shot in a project read, up to what each one had when checked. */
export async function markProjectCommentsRead(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const summaries = await getUnreadCommentSummaries(session.user.id, z.string().min(1).parse(projectId));
for (const [shotId, s] of Object.entries(summaries)) {
await markRead(session.user.id, shotId, new Date(s.latestAt));
}
revalidatePath("/shot-status");
return { success: true, count: Object.keys(summaries).length };
}
+13 -11
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 { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
import { notifyCommentsAdded, notifyCommentReply } from "@/lib/notifications";
import { slackNotifyNewFeedback } from "@/lib/slack";
import { createDisplayEvent } from "@/lib/display-events";
@@ -36,7 +36,6 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
},
});
// Notify the shot artist if commenter is not the artist
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
@@ -56,16 +55,19 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
version?.task?.title ??
"Task";
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
const user = await db.user.findUnique({ where: { id: session.user.id } });
await notifyFeedbackAdded({
artistId: version.shot.artistId,
shotCode: version.shot.shotCode,
frameNumber: parsed.frameNumber,
// Staff and the shot's artist — never the commenter. A failed notification
// must not fail a comment that is already saved.
const user = await db.user.findUnique({ where: { id: session.user.id }, select: { name: true } });
await notifyCommentsAdded({
notes: [{
versionId: parsed.versionId,
authorName: user?.name ?? session.user.email ?? "Someone",
});
}
shotCode,
frameNumber: parsed.frameNumber,
artistIds: [version?.shot?.artistId, version?.task?.assignedArtistId],
}],
authorId: session.user.id,
authorName: user?.name ?? session.user.email ?? "Someone",
}).catch((err) => console.error("[comments] notify failed", err));
// Slack notification
if (slackWebhook) {
+277
View File
@@ -0,0 +1,277 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
import { updateTaskStatus } from "@/actions/tasks";
import { notifyCommentsAdded, type NewCommentNote } from "@/lib/notifications";
/** Comment.source for notes pasted from a client message */
const SOURCE = "WHATSAPP";
async function requireStaff() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
return session.user;
}
/**
* The shot a pasted code means: exact code or client alias first, then a code
* the client shortened by dropping the show prefix ("113_018_040" for
* "UNG_113_018_040") when exactly one shot ends with it.
*/
function resolveShot<T extends BoardShot>(code: string, shots: T[]): T | null {
const exact = findShot(code, shots) as T | null;
if (exact) return exact;
const tail = `_${code.toUpperCase()}`;
const hits = shots.filter((s) => s.shotCode.toUpperCase().endsWith(tail));
return hits.length === 1 ? hits[0] : null;
}
export interface FeedbackVersionOption {
id: string;
label: string;
sent: boolean;
sharedAt: string | null;
taskId: string | null;
taskStatus: string | null;
/** The note's quoted frame converted for this version's media; null = no frame quoted */
playerFrame: number | null;
fromComp: boolean;
}
export interface FeedbackPreviewNote {
key: string;
code: string;
text: string;
quotedFrame: number | null;
shot: { id: string; shotCode: string } | null;
suggestion: string | null;
versions: FeedbackVersionOption[];
defaultVersionId: string | null;
}
export interface FeedbackPreview {
rounds: { title: string; dueText: string | null; notes: FeedbackPreviewNote[] }[];
ignored: string[];
}
export async function previewFeedback(projectId: string, input: string): Promise<FeedbackPreview> {
await requireStaff();
z.string().cuid().parse(projectId);
const parsed = parseFeedback(z.string().max(50000).parse(input));
const shots = await db.shot.findMany({
where: { projectId },
select: { id: true, shotCode: true, clientCode: true, frameStart: true },
});
const matched = new Map<string, (typeof shots)[number] | null>();
for (const r of parsed.rounds) for (const n of r.notes) {
const code = n.codes[0];
if (!matched.has(code)) matched.set(code, resolveShot(code, shots));
}
const shotIds = [...new Set([...matched.values()].filter(Boolean).map((s) => s!.id))];
// Recent versions per shot, whether hung off the shot or its task
const versions = shotIds.length
? await db.version.findMany({
where: { OR: [{ shotId: { in: shotIds } }, { task: { shotId: { in: shotIds } } }] },
orderBy: { createdAt: "desc" },
select: {
id: true,
versionNumber: true,
shotId: true,
isClientVisible: true,
sharedAt: true,
frameCount: true,
pictureStartFrame: true,
task: { select: { id: true, status: true, shotId: true } },
},
})
: [];
const byShot = new Map<string, typeof versions>();
for (const v of versions) {
const sid = v.shotId ?? v.task?.shotId;
if (!sid) continue;
const list = byShot.get(sid) ?? [];
if (list.length < 6) list.push(v);
byShot.set(sid, list);
}
let k = 0;
return {
ignored: parsed.ignored,
rounds: parsed.rounds.map((r) => ({
title: r.title,
dueText: r.dueText,
notes: r.notes.map((n) => {
const code = n.codes[0];
const shot = matched.get(code) ?? null;
const options: FeedbackVersionOption[] = (shot ? byShot.get(shot.id) ?? [] : []).map((v) => {
const conv = n.frame != null
? toPlayerFrame(n.frame, {
shotFrameStart: shot!.frameStart,
pictureStart: slateFrames(v),
frameCount: v.frameCount,
})
: null;
return {
id: v.id,
label: `v${String(v.versionNumber).padStart(3, "0")}`,
sent: v.isClientVisible,
sharedAt: v.sharedAt?.toISOString() ?? null,
taskId: v.task?.id ?? null,
taskStatus: v.task?.status ?? null,
playerFrame: conv?.frame ?? null,
fromComp: conv?.fromComp ?? false,
};
});
// The client is commenting on what they were sent
const def = options.find((o) => o.sent) ?? options[0] ?? null;
return {
key: `n${k++}`,
code: shot?.shotCode ?? code,
text: n.text,
quotedFrame: n.frame,
shot: shot ? { id: shot.id, shotCode: shot.shotCode } : null,
suggestion: shot ? null : suggestShot(code, shots)?.shotCode ?? null,
versions: options,
defaultVersionId: def?.id ?? null,
};
}),
})),
};
}
const commitSchema = z.object({
projectId: z.string().cuid(),
rounds: z.array(z.object({
title: z.string().max(200),
dueAt: z.string().datetime().nullable(),
notes: z.array(z.object({
code: z.string().min(1).max(100),
shotId: z.string().cuid().nullable(),
text: z.string().min(1).max(5000),
versionId: z.string().cuid().nullable(),
frame: z.number().int().min(0).nullable(),
setChanges: z.boolean(),
})).min(1),
})).min(1),
});
/**
* Saves pasted client feedback: one FeedbackRound per round, each note as a
* player comment (tagged as client feedback) and a board line, and — where
* ticked — the version's task moved to Changes.
*/
export async function commitFeedback(data: z.infer<typeof commitSchema>) {
const user = await requireStaff();
const parsed = commitSchema.parse(data);
const notes = parsed.rounds.flatMap((r) => r.notes);
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
const versions = await db.version.findMany({
where: { id: { in: versionIds } },
select: {
id: true,
fps: true,
shotId: true,
task: { select: { id: true, shotId: true, status: true, projectId: true, assignedArtistId: true, shot: { select: { shotCode: true } } } },
shot: { select: { projectId: true, shotCode: true, artistId: true } },
},
});
const vById = new Map(versions.map((v) => [v.id, v]));
for (const n of notes) {
if (!n.versionId) continue;
const v = vById.get(n.versionId);
const projectOk = v && (v.shot?.projectId ?? v.task?.projectId) === parsed.projectId;
const shotOk = v && (v.shotId ?? v.task?.shotId) === n.shotId;
if (!projectOk || !shotOk) throw new Error(`Version does not belong to ${n.code}`);
}
const last = await db.boardItem.findFirst({
where: { projectId: parsed.projectId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
let sort = (last?.sortOrder ?? -1) + 1;
let comments = 0;
const saved: NewCommentNote[] = [];
await db.$transaction(async (tx) => {
for (const r of parsed.rounds) {
const round = await tx.feedbackRound.create({
data: {
projectId: parsed.projectId,
title: r.title.trim() || "Client feedback",
dueAt: r.dueAt ? new Date(r.dueAt) : null,
createdById: user.id,
},
});
for (const n of r.notes) {
const v = n.versionId ? vById.get(n.versionId)! : null;
const frame = n.frame ?? 0;
const comment = v
? await tx.comment.create({
data: {
versionId: v.id,
authorId: user.id,
frameNumber: frame,
timestamp: frame / v.fps,
text: n.text.trim(),
source: SOURCE,
},
select: { id: true },
})
: null;
if (comment && v) {
comments++;
saved.push({
versionId: v.id,
shotCode: v.shot?.shotCode ?? v.task?.shot?.shotCode ?? n.code.toUpperCase(),
frameNumber: frame,
artistIds: [v.shot?.artistId, v.task?.assignedArtistId],
});
}
await tx.boardItem.create({
data: {
projectId: parsed.projectId,
shotId: n.shotId,
code: n.code.toUpperCase(),
text: n.text.trim(),
roundId: round.id,
commentId: comment?.id ?? null,
sortOrder: sort++,
createdById: user.id,
},
});
}
}
});
// Outside the transaction: updateTaskStatus notifies the artist and
// recalculates shot status, exactly as moving the task by hand does.
const tasks = new Set<string>();
for (const n of notes) {
const task = n.versionId ? vById.get(n.versionId)?.task : null;
if (n.setChanges && task && task.status !== "CHANGES") tasks.add(task.id);
}
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES");
// Only after the transaction commits — a rolled-back paste notifies no one.
await notifyCommentsAdded({
notes: saved,
authorId: user.id,
authorName: user.name ?? user.email ?? "Someone",
origin: "whatsapp",
}).catch((err) => console.error("[feedback] notify failed", err));
for (const id of versionIds) revalidatePath(`/review/${id}`);
revalidatePath("/board");
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
}
+145
View File
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
import { countShotsOnOpenRequests } from "@/lib/render-pipeline/delivery-requests";
const createShotSchema = z.object({
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
@@ -652,6 +653,150 @@ export async function bulkUpdateDueDates(
return { success: true, updated: shots.length };
}
// ── Bulk Assign Artist ────────────────────────────────────────────────────────
/**
* Point a selection of shots at one artist, or clear the assignment when
* `artistId` is null. Mirrors the single-shot artist field in updateShot —
* the assignment is a plain column write with no notification, so reassigning
* a batch during planning stays quiet.
*/
export async function bulkAssignArtist(shotIds: string[], artistId: 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 ids = z.array(z.string().cuid()).parse(shotIds);
if (!ids.length) return { success: true, updated: 0, artistName: null as string | null };
let artistName: string | null = null;
if (artistId) {
const artist = await db.user.findUnique({
where: { id: z.string().cuid().parse(artistId) },
select: { id: true, name: true, email: true, role: true, isActive: true },
});
// A deactivated user or a client account must never end up owning a shot,
// so this is checked here rather than trusting whatever the picker sent.
if (!artist || !artist.isActive || artist.role === "CLIENT") {
throw new Error("That user cannot be assigned shots");
}
artistName = artist.name ?? artist.email;
}
const shots = await db.shot.findMany({
where: { id: { in: ids } },
select: { id: true, projectId: true },
});
if (shots.length !== ids.length) throw new Error("Some shots not found");
await db.shot.updateMany({
where: { id: { in: ids } },
data: { artistId },
});
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
revalidatePath(`/projects/${pid}`);
}
revalidatePath(`/shot-status`);
return { success: true, updated: shots.length, artistName };
}
// ── Bulk Manual Delivery ──────────────────────────────────────────────────────
/**
* Record that a selection of shots was delivered outside the render pipeline
* (by hand, before packages existed), or clear that record when
* `deliveredOn` is null. The delivery countdown counts these alongside
* DELIVERED EXR packages (lib/dashboard/ops.ts).
*
* `deliveredOn` is the day the frames went out ("yyyy-MM-dd"), stored at
* noon UTC so the calendar day survives any timezone it is shown in.
*/
export async function bulkSetManualDelivery(
shotIds: string[],
deliveredOn: string | null,
note?: string,
/** Where it was uploaded and who was sent the link. */
evidence?: { uploadUrl?: string; sharedWith?: 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 ids = z.array(z.string().cuid()).parse(shotIds);
if (!ids.length) return { success: true as const, updated: 0 };
let deliveredAt: Date | null = null;
if (deliveredOn) {
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).parse(deliveredOn);
deliveredAt = new Date(`${day}T12:00:00.000Z`);
if (Number.isNaN(deliveredAt.getTime())) throw new Error("Invalid delivery date");
if (deliveredAt.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
throw new Error("A delivery date can't be in the future");
}
}
const shots = await db.shot.findMany({
where: { id: { in: ids } },
select: { id: true, projectId: true },
});
if (shots.length !== ids.length) throw new Error("Some shots not found");
const trimmedNote = note?.trim() || null;
const uploadUrl = evidence?.uploadUrl?.trim() || null;
const sharedWith = evidence?.sharedWith?.trim() || null;
if (uploadUrl && !/^https?:\/\/\S+$/i.test(uploadUrl)) {
return { success: false as const, error: "The upload link must be a full http(s) link" };
}
// A backfill of old deliveries may have no link to give. A shot a client is
// waiting on is different: without the link and recipient the request it
// answers would stay open, so say so now rather than leave it unexplained.
if (deliveredAt && (!uploadUrl || !sharedWith)) {
const owed = await countShotsOnOpenRequests(ids);
if (owed > 0) {
// Returned, not thrown: a production build replaces a thrown action
// error with a generic message, and this one has to reach the person.
return {
success: false as const,
error: `${owed} of these shots ${owed === 1 ? "is" : "are"} on an open delivery request — add the upload link and who it was sent to`,
};
}
}
await db.shot.updateMany({
where: { id: { in: ids } },
data: deliveredAt
? {
manualDeliveredAt: deliveredAt,
manualDeliveredById: session.user.id,
manualDeliveryNote: trimmedNote,
manualDeliveryUrl: uploadUrl,
manualDeliverySharedWith: sharedWith,
}
: {
manualDeliveredAt: null,
manualDeliveredById: null,
manualDeliveryNote: null,
manualDeliveryUrl: null,
manualDeliverySharedWith: null,
},
});
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
revalidatePath(`/projects/${pid}`);
}
revalidatePath(`/shot-status`);
revalidatePath(`/dashboard`);
return { success: true as const, updated: shots.length };
}
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
export async function bulkDeleteShots(shotIds: string[]) {
+37
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack";
import { deleteStorageKeys, highResStorageKeys } from "@/lib/render-pipeline/high-res";
const createVersionSchema = z.object({
taskId: z.string().cuid(),
@@ -185,6 +186,8 @@ export async function deleteVersion(versionId: string) {
id: true,
isLatest: true,
taskId: true,
hqFileUrl: true,
highResKey: true,
task: { select: { projectId: true, shotId: true } },
},
});
@@ -203,6 +206,8 @@ export async function deleteVersion(versionId: string) {
}
await db.version.delete({ where: { id: versionId } });
// Its High quality MP4 and ProRes are large and referenced by nothing else.
await deleteStorageKeys(highResStorageKeys(version));
const projectId = version.task?.projectId;
const shotId = version.task?.shotId;
@@ -259,3 +264,35 @@ export async function getVersionComments(versionId: string) {
orderBy: { frameNumber: "asc" },
});
}
const updateVersionHandlesSchema = z.object({
versionId: z.string().cuid(),
/** 0/0 = no handles (every frame is in the edit). */
handleHead: z.number().int().min(0).max(1000),
handleTail: z.number().int().min(0).max(1000),
/** Slate frames before the picture; null = infer from the media. */
pictureStartFrame: z.number().int().min(0).max(1000).nullable(),
});
/**
* Marks which frames of a version are handles (past the cut at each end), so
* the review player can flag them. Review metadata only — the media and any
* delivered files are untouched. Staff only: a client can't redefine the cut.
*/
export async function updateVersionHandles(data: z.infer<typeof updateVersionHandlesSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Forbidden");
const parsed = updateVersionHandlesSchema.parse(data);
const none = parsed.handleHead === 0 && parsed.handleTail === 0;
await db.version.update({
where: { id: parsed.versionId },
data: {
handleHead: none ? null : parsed.handleHead,
handleTail: none ? null : parsed.handleTail,
pictureStartFrame: parsed.pictureStartFrame,
},
});
revalidatePath(`/review/${parsed.versionId}`);
}
+689
View File
@@ -0,0 +1,689 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { differenceInCalendarDays, format, formatDistanceToNowStrict } from "date-fns";
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
Clock,
Flag,
Link2,
MessageSquareText,
Pencil,
Plus,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useToast } from "@/components/ui/use-toast";
import { cn, getInitials } from "@/lib/utils";
import {
addBoardItems,
addMilestone,
deleteBoardItem,
deleteMilestone,
setBoardItemDone,
setMilestoneDone,
setProjectBoardStatus,
updateBoardItem,
} from "@/actions/board";
import { FeedbackPasteDialog } from "./FeedbackPasteDialog";
type User = { id: string; name: string | null; email: string; image: string | null };
type BoardItem = {
id: string;
text: string;
code: string | null;
group: string;
waitingOn: string | null;
round: { id: string; title: string; dueAt: string | null } | null;
/** The player comment a pasted-feedback line was saved as */
comment: { versionId: string; frame: number } | null;
doneAt: string | null;
assignee: { id: string; name: string | null; image: string | null } | null;
shot: {
id: string;
shotCode: string;
status: string;
latest: { id: string; versionNumber: number; approvalStatus: string; isClientVisible: boolean } | null;
} | null;
relinkTo: string | null;
relinkIsExact: boolean;
};
export type BoardProject = {
id: string;
name: string;
code: string;
status: string;
boardStatus: string | null;
boardStatusAt: string | null;
shotTotal: number;
shotComplete: number;
milestones: { id: string; label: string; date: string; doneAt: string | null }[];
items: BoardItem[];
};
const SHOT_STATUS: Record<string, { label: string; color: string }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" },
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20" },
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
COMPLETE: { label: "Complete", color: "bg-green-500/10 text-green-400 border-green-500/20" },
};
const APPROVAL: Record<string, { label: string; color: string }> = {
PENDING_REVIEW: { label: "pending", color: "text-zinc-500" },
APPROVED: { label: "approved", color: "text-green-400" },
REJECTED: { label: "rejected", color: "text-red-400" },
NEEDS_CHANGES: { label: "changes", color: "text-orange-400" },
};
export function BoardClient({
projects,
users,
who,
}: {
projects: BoardProject[];
users: User[];
who: string | null;
}) {
const router = useRouter();
const filters = [
{ key: null, label: "Everyone" },
...users.map((u) => ({ key: u.id, label: u.name?.split(" ")[0] ?? u.email })),
{ key: "none", label: "Unassigned" },
];
return (
<div className="p-4 md:p-8 max-w-5xl">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white">Board</h1>
<p className="text-zinc-400 mt-1">{format(new Date(), "EEEE, d MMMM yyyy")}</p>
</div>
<div className="flex flex-wrap gap-1 rounded-lg border border-zinc-800 bg-zinc-900 p-1">
{filters.map((f) => (
<button
key={f.key ?? "all"}
type="button"
onClick={() => router.push(f.key ? `/board?who=${f.key}` : "/board")}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
who === f.key ? "bg-amber-500/10 text-amber-400" : "text-zinc-400 hover:text-white",
)}
>
{f.label}
</button>
))}
</div>
</div>
{projects.length === 0 && (
<p className="text-sm text-zinc-500">No active or on-hold projects.</p>
)}
<div className="space-y-6">
{projects.map((p) => (
<ProjectSection key={p.id} project={p} users={users} who={who} />
))}
</div>
</div>
);
}
function ProjectSection({ project, users, who }: { project: BoardProject; users: User[]; who: string | null }) {
const [showDone, setShowDone] = useState(false);
const [pasting, setPasting] = useState(false);
const visible = project.items.filter((i) =>
who === null ? true : who === "none" ? !i.assignee : i.assignee?.id === who,
);
const open = visible.filter((i) => !i.doneAt);
const done = visible.filter((i) => i.doneAt);
// Pasted feedback stays together under its round, flags and all
const rounds: { round: NonNullable<BoardItem["round"]>; items: BoardItem[] }[] = [];
for (const item of open) {
if (!item.round) continue;
let r = rounds.find((x) => x.round.id === item.round!.id);
if (!r) rounds.push((r = { round: item.round, items: [] }));
r.items.push(item);
}
rounds.sort((a, b) => (a.round.dueAt ?? "9").localeCompare(b.round.dueAt ?? "9"));
const loose = open.filter((i) => !i.round);
const flagged = loose.filter((i) => i.code && !i.shot);
const waiting = loose.filter((i) => i.waitingOn && !(i.code && !i.shot));
const rest = loose.filter((i) => !flagged.includes(i) && !waiting.includes(i));
// Groups keep the order they were first typed in
const groups: { name: string; items: BoardItem[] }[] = [];
for (const item of rest) {
let g = groups.find((x) => x.name === item.group);
if (!g) groups.push((g = { name: item.group, items: [] }));
g.items.push(item);
}
groups.sort((a, b) => (a.name === "" ? -1 : b.name === "" ? 1 : 0));
const pct = project.shotTotal ? Math.round((project.shotComplete / project.shotTotal) * 100) : 0;
return (
<section className="rounded-xl border border-zinc-800 bg-zinc-900">
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 px-4 py-3">
<Link href={`/projects/${project.id}`} className="text-lg font-semibold text-white hover:text-amber-400">
{project.name}
</Link>
<span className="text-xs text-zinc-500">{project.code}</span>
{project.status === "ON_HOLD" && (
<span className="rounded border border-yellow-500/20 bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
On hold
</span>
)}
<Button
size="sm"
variant="outline"
className="ml-auto h-7"
onClick={() => setPasting(true)}
title="Paste a client's feedback message"
>
<MessageSquareText /> Paste feedback
</Button>
{project.shotTotal > 0 && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-800">
<div className="h-full bg-green-500/70" style={{ width: `${pct}%` }} />
</div>
{project.shotComplete}/{project.shotTotal} shots complete
</div>
)}
</header>
<div className="space-y-4 px-4 py-3">
<StatusLine project={project} />
<Milestones project={project} />
{rounds.map(({ round, items }) => (
<RoundBlock key={round.id} round={round} count={items.length}>
{items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</RoundBlock>
))}
{flagged.length > 0 && (
<ItemBlock title="Flags" icon={<Flag className="h-3.5 w-3.5 text-red-400" />}>
{flagged.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</ItemBlock>
)}
{waiting.length > 0 && (
<ItemBlock title="Waiting on" icon={<Clock className="h-3.5 w-3.5 text-amber-400" />}>
{waiting.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</ItemBlock>
)}
{groups.map((g) => (
<ItemBlock key={g.name || "_"} title={g.name}>
{g.items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</ItemBlock>
))}
<QuickAdd projectId={project.id} />
{done.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowDone(!showDone)}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
>
{showDone ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Done this week ({done.length})
</button>
{showDone && (
<div className="mt-1">
{done.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</div>
)}
</div>
)}
</div>
<FeedbackPasteDialog
projectId={project.id}
projectName={project.name}
open={pasting}
onOpenChange={setPasting}
/>
</section>
);
}
function RoundBlock({
round,
count,
children,
}: {
round: NonNullable<BoardItem["round"]>;
count: number;
children: React.ReactNode;
}) {
const due = round.dueAt ? new Date(round.dueAt) : null;
const late = due && due.getTime() < Date.now();
const days = due ? differenceInCalendarDays(due, new Date()) : null;
const when = due
? days === 0 ? `today ${format(due, "HH:mm")}` : days === 1 ? `tomorrow ${format(due, "HH:mm")}` : format(due, "d MMM HH:mm")
: null;
return (
<div>
<div className="mb-1 flex flex-wrap items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
<MessageSquareText className="h-3.5 w-3.5 text-indigo-400" />
{round.title}
{when && (
<span
className={cn(
"rounded px-1.5 py-px normal-case tracking-normal",
late ? "bg-red-500/10 text-red-300" : days !== null && days <= 1 ? "bg-amber-500/10 text-amber-300" : "text-zinc-400",
)}
>
{late ? "overdue · " : "due "}{when}
</span>
)}
<span className="normal-case tracking-normal text-zinc-600">{count} open</span>
</div>
<div className="divide-y divide-zinc-800/60">{children}</div>
</div>
);
}
function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
return (
<div>
{title && (
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
{icon}
{title}
</div>
)}
<div className="divide-y divide-zinc-800/60">{children}</div>
</div>
);
}
function useAction() {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const run = (fn: () => Promise<unknown>, after?: () => void) =>
startTransition(async () => {
try {
await fn();
after?.();
router.refresh();
} catch (e) {
toast({ title: "Couldn't save", description: (e as Error).message, variant: "destructive" });
}
});
return { run, isPending };
}
// ── Status line ──────────────────────────────────────────────────────────────
function StatusLine({ project }: { project: BoardProject }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(project.boardStatus ?? "");
const { run, isPending } = useAction();
const save = () =>
run(() => setProjectBoardStatus({ projectId: project.id, status: value }), () => setEditing(false));
if (editing) {
return (
<div className="space-y-1.5">
<textarea
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); }
if (e.key === "Escape") { setValue(project.boardStatus ?? ""); setEditing(false); }
}}
rows={2}
placeholder="Where things stand — e.g. Awaiting feedback on pick-ups"
className="w-full resize-y rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none"
/>
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={isPending}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => { setValue(project.boardStatus ?? ""); setEditing(false); }}>
Cancel
</Button>
</div>
</div>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
className="group block w-full text-left"
>
{project.boardStatus ? (
<p className="whitespace-pre-wrap text-sm text-zinc-200">
{project.boardStatus}
{project.boardStatusAt && (
<span className="ml-2 text-xs text-zinc-600">
{formatDistanceToNowStrict(new Date(project.boardStatusAt), { addSuffix: true })}
</span>
)}
<Pencil className="ml-2 inline h-3 w-3 text-zinc-600 opacity-0 group-hover:opacity-100" />
</p>
) : (
<p className="text-sm text-zinc-600 group-hover:text-zinc-400">+ Add a status line</p>
)}
</button>
);
}
// ── Milestones ───────────────────────────────────────────────────────────────
function Milestones({ project }: { project: BoardProject }) {
const [adding, setAdding] = useState(false);
const [label, setLabel] = useState("");
const [date, setDate] = useState("");
const { run, isPending } = useAction();
const add = () => {
if (!label.trim() || !date) return;
// Noon keeps the calendar day stable across the studio/server time zones
run(() => addMilestone({ projectId: project.id, label, date: `${date}T12:00:00` }), () => {
setLabel("");
setDate("");
setAdding(false);
});
};
return (
<div className="flex flex-wrap items-center gap-1.5">
{project.milestones.map((m) => <MilestoneChip key={m.id} m={m} />)}
{adding ? (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="e.g. Final delivery"
className="h-7 w-40 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:outline-none"
/>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
className="h-7 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white [color-scheme:dark] focus:outline-none"
/>
<Button size="sm" className="h-7" onClick={add} disabled={isPending || !label.trim() || !date}>Add</Button>
<Button size="sm" variant="ghost" className="h-7" onClick={() => setAdding(false)}>Cancel</Button>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex h-7 items-center gap-1 rounded-md px-2 text-xs text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300"
>
<Plus className="h-3 w-3" /> Date
</button>
)}
</div>
);
}
function MilestoneChip({ m }: { m: BoardProject["milestones"][number] }) {
const { run } = useAction();
const days = differenceInCalendarDays(new Date(m.date), new Date());
const done = !!m.doneAt;
const late = !done && days < 0;
const soon = !done && days >= 0 && days <= 7;
const when = done ? "done" : days === 0 ? "today" : days === 1 ? "tomorrow" : days > 0 ? `in ${days}d` : `${-days}d late`;
return (
<div
className={cn(
"group flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs",
done ? "border-zinc-800 text-zinc-600" :
late ? "border-red-500/30 bg-red-500/10 text-red-300" :
soon ? "border-amber-500/30 bg-amber-500/10 text-amber-300" :
"border-zinc-700 text-zinc-300",
)}
>
<button
type="button"
title={done ? "Mark not done" : "Mark done"}
onClick={() => run(() => setMilestoneDone(m.id, !done))}
className={cn(
"flex h-3.5 w-3.5 items-center justify-center rounded-sm border",
done ? "border-green-500/50 bg-green-500/20 text-green-400" : "border-current opacity-60 hover:opacity-100",
)}
>
{done && <Check className="h-2.5 w-2.5" />}
</button>
<span className={cn(done && "line-through")}>{m.label}</span>
<span className="opacity-70">{format(new Date(m.date), "d MMM")}</span>
<span className="font-medium">· {when}</span>
<button
type="button"
title="Remove"
onClick={() => { if (confirm(`Remove "${m.label}"?`)) run(() => deleteMilestone(m.id)); }}
className="hidden text-zinc-500 hover:text-red-400 group-hover:block"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// ── Items ────────────────────────────────────────────────────────────────────
function ItemRow({ item, users, projectId }: { item: BoardItem; users: User[]; projectId: string }) {
const [editing, setEditing] = useState(false);
const { run, isPending } = useAction();
const done = !!item.doneAt;
const flagged = !!item.code && !item.shot;
if (editing) return <ItemEditor item={item} users={users} onClose={() => setEditing(false)} />;
const st = item.shot ? SHOT_STATUS[item.shot.status] : null;
const v = item.shot?.latest;
return (
<div className={cn("group flex items-start gap-2.5 py-1.5", isPending && "opacity-50")}>
<button
type="button"
title={done ? "Mark not done" : "Mark done"}
onClick={() => run(() => setBoardItemDone(item.id, !done))}
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
done ? "border-green-500/50 bg-green-500/20 text-green-400" : "border-zinc-600 hover:border-zinc-400",
)}
>
{done && <Check className="h-3 w-3" />}
</button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
{item.shot && (
<Link
href={`/projects/${projectId}/shots/${item.shot.id}`}
className="font-mono text-xs text-amber-400 hover:underline"
>
{item.shot.shotCode}
</Link>
)}
{flagged && (
<span className="flex items-center gap-1 font-mono text-xs text-red-400" title="Not a shot in this project">
<AlertTriangle className="h-3 w-3" />
{item.code}
</span>
)}
{st && !done && (
<span className={cn("rounded border px-1.5 py-px text-[10px] font-medium", st.color)}>{st.label}</span>
)}
{v && !done && (
<Link href={`/review/${v.id}`} className="text-[11px] text-zinc-500 hover:text-zinc-300">
v{String(v.versionNumber).padStart(3, "0")}{" "}
<span className={APPROVAL[v.approvalStatus]?.color}>{APPROVAL[v.approvalStatus]?.label}</span>
{v.isClientVisible && <span className="text-indigo-400"> · sent</span>}
</Link>
)}
<span className={cn("text-zinc-200", done && "text-zinc-600 line-through")}>{item.text}</span>
{item.comment && (
<Link
href={`/review/${item.comment.versionId}`}
className="text-[11px] text-indigo-400/80 hover:text-indigo-300"
title="Open the note in the player"
>
note @ F{item.comment.frame}
</Link>
)}
</div>
{(item.waitingOn || (flagged && !done)) && (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
{item.waitingOn && (
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300">
<Clock className="h-3 w-3" /> Waiting on {item.waitingOn}
</span>
)}
{flagged && !done && (
item.relinkTo ? (
<button
type="button"
onClick={() => run(() => updateBoardItem(item.id, { code: item.relinkTo }))}
className="flex items-center gap-1 text-zinc-400 hover:text-amber-400"
>
<Link2 className="h-3 w-3" />
{item.relinkIsExact ? `${item.relinkTo} exists now. Link it` : `Did you mean ${item.relinkTo}?`}
</button>
) : (
<span className="text-zinc-500">Not a shot in this project</span>
)
)}
</div>
)}
</div>
{item.assignee && (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-zinc-400" title={item.assignee.name ?? ""}>
<Avatar className="h-5 w-5">
{item.assignee.image && <AvatarImage src={item.assignee.image} />}
<AvatarFallback className="text-[9px]">{getInitials(item.assignee.name)}</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{item.assignee.name?.split(" ")[0]}</span>
</span>
)}
<div className="flex shrink-0 gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100">
<button type="button" title="Edit" onClick={() => setEditing(true)} className="rounded p-1 text-zinc-500 hover:bg-zinc-800 hover:text-white">
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="Delete"
onClick={() => { if (confirm("Delete this line?")) run(() => deleteBoardItem(item.id)); }}
className="rounded p-1 text-zinc-500 hover:bg-zinc-800 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
function ItemEditor({ item, users, onClose }: { item: BoardItem; users: User[]; onClose: () => void }) {
const [code, setCode] = useState(item.shot?.shotCode ?? item.code ?? "");
const [text, setText] = useState(item.text);
const [assigneeId, setAssigneeId] = useState(item.assignee?.id ?? "");
const [waitingOn, setWaitingOn] = useState(item.waitingOn ?? "");
const [group, setGroup] = useState(item.group);
const { run, isPending } = useAction();
const save = () =>
run(
() => updateBoardItem(item.id, { code, text, assigneeId: assigneeId || null, waitingOn, group }),
onClose,
);
const field = "h-8 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
return (
<div
className="my-1 grid grid-cols-1 gap-2 rounded-lg border border-zinc-700 bg-zinc-950/50 p-2 sm:grid-cols-[10rem_1fr]"
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); save(); }
if (e.key === "Escape") onClose();
}}
>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Shot code" className={cn(field, "font-mono")} />
<input autoFocus value={text} onChange={(e) => setText(e.target.value)} placeholder="Note" className={field} />
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className={field}>
<option value="">Unassigned</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name ?? u.email}</option>)}
</select>
<input value={waitingOn} onChange={(e) => setWaitingOn(e.target.value)} placeholder="Waiting on (blank = not blocked)" className={field} />
<input value={group} onChange={(e) => setGroup(e.target.value)} placeholder="Heading (optional)" className={field} />
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onClose}>Cancel</Button>
<Button size="sm" onClick={save} disabled={isPending}>Save</Button>
</div>
</div>
);
}
function QuickAdd({ projectId }: { projectId: string }) {
const [value, setValue] = useState("");
const ref = useRef<HTMLTextAreaElement>(null);
const { toast } = useToast();
const { run, isPending } = useAction();
// Grow with pasted blocks
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 320)}px`;
}, [value]);
const submit = () => {
if (!value.trim()) return;
run(async () => {
const res = await addBoardItems({ projectId, input: value });
if (res.unmatched.length) {
toast({
title: `${res.unmatched.length} code${res.unmatched.length === 1 ? "" : "s"} not in this project`,
description: `${res.unmatched.join(", ")}: flagged on the board.`,
});
}
}, () => setValue(""));
};
return (
<div>
<textarea
ref={ref}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
}}
rows={1}
disabled={isPending}
placeholder="Add a line, e.g. SHOT_CODE finalise (Chris) wait: client GFX, or paste a list"
className="w-full resize-none rounded-lg border border-dashed border-zinc-700 bg-transparent px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:border-solid focus:border-amber-500/50 focus:outline-none"
/>
</div>
);
}
@@ -0,0 +1,342 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { AlertTriangle, ArrowLeft, Link2, MessageSquareText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { parseDue } from "@/lib/feedback-parse";
import { commitFeedback, previewFeedback, type FeedbackPreview, type FeedbackPreviewNote } from "@/actions/feedback";
type NoteState = {
include: boolean;
text: string;
versionId: string | null;
/** Typed frame; null = use the selected version's converted frame */
frame: string | null;
setChanges: boolean;
};
type RoundState = { title: string; due: string; notes: Record<string, NoteState> };
const LOCAL_DT = "yyyy-MM-dd'T'HH:mm";
function initialState(preview: FeedbackPreview): RoundState[] {
return preview.rounds.map((r) => {
const due = parseDue(r.dueText);
return {
title: r.title || `Feedback ${format(new Date(), "d MMM")}`,
due: due ? format(due, LOCAL_DT) : "",
notes: Object.fromEntries(
r.notes.map((n) => [
n.key,
{ include: true, text: n.text, versionId: n.defaultVersionId, frame: null, setChanges: true },
]),
),
};
});
}
export function FeedbackPasteDialog({
projectId,
projectName,
open,
onOpenChange,
}: {
projectId: string;
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const { toast } = useToast();
const [input, setInput] = useState("");
const [preview, setPreview] = useState<FeedbackPreview | null>(null);
const [rounds, setRounds] = useState<RoundState[]>([]);
const [isPending, startTransition] = useTransition();
const reset = () => {
setInput("");
setPreview(null);
setRounds([]);
};
const read = (text: string) =>
startTransition(async () => {
try {
const p = await previewFeedback(projectId, text);
if (p.rounds.length === 0) {
toast({ title: "No shot notes found", description: "Each note needs to start with a shot code.", variant: "destructive" });
return;
}
setPreview(p);
setRounds(initialState(p));
} catch (e) {
toast({ title: "Couldn't read feedback", description: (e as Error).message, variant: "destructive" });
}
});
// Accepting a suggested code rewrites the paste and reads it again, so
// everything downstream (versions, frames) comes from the real shot.
const applySuggestion = (code: string, suggestion: string) => {
const next = input.split(code).join(suggestion);
setInput(next);
read(next);
};
const updateNote = (ri: number, key: string, patch: Partial<NoteState>) =>
setRounds((rs) => rs.map((r, i) => (i === ri ? { ...r, notes: { ...r.notes, [key]: { ...r.notes[key], ...patch } } } : r)));
const frameFor = (n: FeedbackPreviewNote, s: NoteState): number | null => {
if (s.frame !== null) return s.frame.trim() === "" ? null : Number(s.frame);
return n.versions.find((v) => v.id === s.versionId)?.playerFrame ?? null;
};
const included = preview
? preview.rounds.flatMap((r, ri) => r.notes.filter((n) => rounds[ri]?.notes[n.key]?.include && rounds[ri].notes[n.key].text.trim()))
: [];
const save = () => {
if (!preview) return;
const payload = preview.rounds
.map((r, ri) => {
const rs = rounds[ri];
return {
title: rs.title,
dueAt: rs.due ? new Date(rs.due).toISOString() : null,
notes: r.notes
.filter((n) => rs.notes[n.key].include && rs.notes[n.key].text.trim())
.map((n) => {
const s = rs.notes[n.key];
const frame = frameFor(n, s);
return {
code: n.code,
shotId: n.shot?.id ?? null,
text: s.text.trim(),
versionId: n.shot ? s.versionId : null,
frame: frame != null && Number.isFinite(frame) && frame >= 0 ? Math.floor(frame) : null,
setChanges: !!s.versionId && s.setChanges,
};
}),
};
})
.filter((r) => r.notes.length > 0);
if (payload.length === 0) return;
startTransition(async () => {
try {
const res = await commitFeedback({ projectId, rounds: payload });
toast({
title: `${res.notes} note${res.notes === 1 ? "" : "s"} logged`,
description: `${res.comments} in the player · ${res.tasksChanged} task${res.tasksChanged === 1 ? "" : "s"} moved to Changes`,
});
reset();
onOpenChange(false);
router.refresh();
} catch (e) {
toast({ title: "Couldn't save feedback", description: (e as Error).message, variant: "destructive" });
}
});
};
const field = "rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-amber-400" />
Paste client feedback · {projectName}
</DialogTitle>
<DialogDescription>
{preview
? "Check where each note will go. Notes are saved in the player as client feedback and added to the board."
: "Paste the message as it arrived. Each note should start with a shot code; a line like “FEEDBACK 113 DUE Oct 7 12:00 PM” starts a round."}
</DialogDescription>
</DialogHeader>
{!preview ? (
<div className="space-y-3">
<textarea
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
rows={12}
placeholder={"FEEDBACK 113 DUE Oct 7, 2026 12:00 PM\nUNG_113_008_040 The screen is partially peeking under the mask\nUNG_113_018_050 At frame 1018, the mask is still slipping"}
className={cn(field, "w-full resize-y py-2 font-mono text-[13px] leading-relaxed")}
/>
<div className="flex justify-end">
<Button onClick={() => read(input)} disabled={isPending || !input.trim()}>
{isPending ? "Reading…" : "Read feedback"}
</Button>
</div>
</div>
) : (
<div className="space-y-5">
{preview.ignored.length > 0 && (
<p className="text-xs text-zinc-500">
Skipped (no shot code): {preview.ignored.map((l) => `“${l}”`).join(", ")}
</p>
)}
{preview.rounds.map((r, ri) => {
const rs = rounds[ri];
if (!rs) return null;
return (
<div key={ri} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<input
value={rs.title}
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, title: e.target.value } : x)))}
className={cn(field, "h-8 w-56 text-sm font-medium")}
/>
<span className="text-xs text-zinc-500">due</span>
<input
type="datetime-local"
value={rs.due}
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, due: e.target.value } : x)))}
className={cn(field, "h-8 [color-scheme:dark]")}
/>
{r.dueText && !rs.due && (
<span className="text-xs text-amber-400">Couldn’t read “{r.dueText}”, set it by hand</span>
)}
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-800">
<table className="w-full text-xs">
<thead className="bg-zinc-950 text-left uppercase tracking-wider text-zinc-500">
<tr>
<th className="w-8 px-2 py-2" />
<th className="px-2 py-2">Shot</th>
<th className="px-2 py-2">Note</th>
<th className="px-2 py-2">Version</th>
<th className="px-2 py-2">Frame</th>
<th className="px-2 py-2" title="Move the task to Changes">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{r.notes.map((n) => {
const s = rs.notes[n.key];
const v = n.versions.find((x) => x.id === s.versionId) ?? null;
const frame = frameFor(n, s);
return (
<tr key={n.key} className={cn("align-top", !s.include && "opacity-40")}>
<td className="px-2 py-2">
<input
type="checkbox"
checked={s.include}
onChange={(e) => updateNote(ri, n.key, { include: e.target.checked })}
className="mt-1 accent-amber-500"
/>
</td>
<td className="whitespace-nowrap px-2 py-2 font-mono">
{n.shot ? (
<span className="text-amber-400">{n.shot.shotCode}</span>
) : (
<div className="space-y-1">
<span className="flex items-center gap-1 text-red-400">
<AlertTriangle className="h-3 w-3" /> {n.code}
</span>
{n.suggestion ? (
<button
type="button"
onClick={() => applySuggestion(n.code, n.suggestion!)}
className="flex items-center gap-1 font-sans text-zinc-400 hover:text-amber-400"
>
<Link2 className="h-3 w-3" /> Use {n.suggestion}
</button>
) : (
<span className="block font-sans text-zinc-500">Not in project; flag only</span>
)}
</div>
)}
</td>
<td className="min-w-[16rem] px-2 py-2">
<textarea
value={s.text}
onChange={(e) => updateNote(ri, n.key, { text: e.target.value })}
rows={Math.min(5, Math.max(1, Math.ceil(s.text.length / 60)))}
className={cn(field, "w-full resize-y py-1 leading-snug")}
/>
</td>
<td className="px-2 py-2">
{n.versions.length > 0 ? (
<select
value={s.versionId ?? ""}
onChange={(e) => updateNote(ri, n.key, { versionId: e.target.value || null, frame: null })}
className={cn(field, "h-7")}
>
{n.versions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.sent ? ` · sent${o.sharedAt ? " " + format(new Date(o.sharedAt), "d MMM") : ""}` : " · not sent"}
</option>
))}
<option value="">Board only</option>
</select>
) : (
<span className="text-zinc-500">{n.shot ? "No versions; board only" : "—"}</span>
)}
{v && !v.sent && <span className="mt-1 block text-amber-400/80">Not sent to client</span>}
</td>
<td className="px-2 py-2">
{s.versionId ? (
<>
<input
inputMode="numeric"
value={s.frame ?? (frame != null ? String(frame) : "")}
onChange={(e) => updateNote(ri, n.key, { frame: e.target.value.replace(/\D/g, "") })}
placeholder="0"
className={cn(field, "h-7 w-16 font-mono")}
/>
{n.quotedFrame != null && s.frame === null && v && (
<span className="mt-1 block whitespace-nowrap text-zinc-500">
{v.fromComp ? `comp ${n.quotedFrame}` : `as quoted`}
</span>
)}
</>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
<td className="px-2 py-2 text-center">
{v?.taskId ? (
<input
type="checkbox"
checked={s.setChanges}
disabled={v.taskStatus === "CHANGES"}
title={v.taskStatus === "CHANGES" ? "Already in Changes" : "Move the task to Changes"}
onChange={(e) => updateNote(ri, n.key, { setChanges: e.target.checked })}
className="mt-1 accent-amber-500"
/>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button variant="ghost" onClick={() => setPreview(null)} disabled={isPending}>
<ArrowLeft /> Edit paste
</Button>
<Button onClick={save} disabled={isPending || included.length === 0}>
{isPending ? "Saving…" : `Save ${included.length} note${included.length === 1 ? "" : "s"}`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { suggestShot } from "@/lib/board-parse";
import { BoardClient, type BoardProject } from "./BoardClient";
export const metadata = { title: "Board" };
// Done lines stay visible this long so "what did we get through" is answerable
const DONE_WINDOW_MS = 7 * 86400000;
export default async function BoardPage({
searchParams,
}: {
searchParams: Promise<{ who?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { who } = await searchParams;
const doneSince = new Date(Date.now() - DONE_WINDOW_MS);
const [projects, users] = await Promise.all([
db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
orderBy: [{ status: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
code: true,
status: true,
boardStatus: true,
boardStatusAt: true,
milestones: { orderBy: { date: "asc" } },
boardItems: {
where: { OR: [{ doneAt: null }, { doneAt: { gte: doneSince } }] },
orderBy: { sortOrder: "asc" },
include: {
assignee: { select: { id: true, name: true, image: true } },
round: { select: { id: true, title: true, dueAt: true } },
shot: {
select: {
id: true,
shotCode: true,
status: true,
},
},
},
},
},
}),
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true },
orderBy: { name: "asc" },
}),
]);
const projectIds = projects.map((p) => p.id);
// Latest version per linked shot — versions hang off the shot or its task
const linkedShotIds = [...new Set(projects.flatMap((p) => p.boardItems.map((i) => i.shotId).filter(Boolean) as string[]))];
const latestByShot = new Map<string, { id: string; versionNumber: number; approvalStatus: string; isClientVisible: boolean }>();
if (linkedShotIds.length) {
const versions = await db.version.findMany({
where: { OR: [{ shotId: { in: linkedShotIds } }, { task: { shotId: { in: linkedShotIds } } }] },
orderBy: { createdAt: "desc" },
select: {
id: true, versionNumber: true, approvalStatus: true, isClientVisible: true,
shotId: true, task: { select: { shotId: true } },
},
});
for (const v of versions) {
const sid = v.shotId ?? v.task?.shotId;
if (sid && !latestByShot.has(sid)) latestByShot.set(sid, v);
}
}
// Pasted-feedback lines link to the comment they were saved as
const commentIds = projects.flatMap((p) => p.boardItems.map((i) => i.commentId).filter(Boolean) as string[]);
const commentVersion = new Map(
(commentIds.length
? await db.comment.findMany({ where: { id: { in: commentIds } }, select: { id: true, versionId: true, frameNumber: true } })
: []
).map((c) => [c.id, c]),
);
const shotCounts = await db.shot.groupBy({
by: ["projectId", "status"],
where: { projectId: { in: projectIds } },
_count: { _all: true },
});
// Lines whose code did not match a shot when typed: the shot may exist now,
// or there may be an obvious near-miss (DH2_004_100 → DH2_VFX_004_100).
const flaggedProjects = projects.filter((p) => p.boardItems.some((i) => i.code && !i.shotId));
const shotsByProject = new Map(
await Promise.all(
flaggedProjects.map(async (p) => [
p.id,
await db.shot.findMany({
where: { projectId: p.id },
select: { id: true, shotCode: true, clientCode: true },
}),
] as const),
),
);
const data: BoardProject[] = projects.map((p) => {
const counts = shotCounts.filter((c) => c.projectId === p.id);
const shots = shotsByProject.get(p.id) ?? [];
return {
id: p.id,
name: p.name,
code: p.code,
status: p.status,
boardStatus: p.boardStatus,
boardStatusAt: p.boardStatusAt?.toISOString() ?? null,
shotTotal: counts.reduce((n, c) => n + c._count._all, 0),
shotComplete: counts.find((c) => c.status === "COMPLETE")?._count._all ?? 0,
milestones: p.milestones.map((m) => ({
id: m.id,
label: m.label,
date: m.date.toISOString(),
doneAt: m.doneAt?.toISOString() ?? null,
})),
items: p.boardItems.map((i) => {
const v = i.shot ? latestByShot.get(i.shot.id) : undefined;
const nowMatches = !i.shotId && i.code
? shots.find((s) => s.shotCode.toUpperCase() === i.code!.toUpperCase())
: undefined;
const suggestion = !i.shotId && i.code && !nowMatches ? suggestShot(i.code, shots) : null;
return {
id: i.id,
text: i.text,
code: i.code,
group: i.group,
waitingOn: i.waitingOn,
round: i.round ? { id: i.round.id, title: i.round.title, dueAt: i.round.dueAt?.toISOString() ?? null } : null,
comment: i.commentId && commentVersion.has(i.commentId)
? { versionId: commentVersion.get(i.commentId)!.versionId, frame: commentVersion.get(i.commentId)!.frameNumber }
: null,
doneAt: i.doneAt?.toISOString() ?? null,
assignee: i.assignee,
shot: i.shot
? {
id: i.shot.id,
shotCode: i.shot.shotCode,
status: i.shot.status,
latest: v
? {
id: v.id,
versionNumber: v.versionNumber,
approvalStatus: v.approvalStatus,
isClientVisible: v.isClientVisible,
}
: null,
}
: null,
// Shot created since the line was typed: offer to link it
relinkTo: nowMatches?.shotCode ?? suggestion?.shotCode ?? null,
relinkIsExact: !!nowMatches,
};
}),
};
});
return <BoardClient projects={data} users={users} who={who ?? null} />;
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default async function ClientDetailPage({
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Back */}
<Link
href="/clients"
+1 -1
View File
@@ -26,7 +26,7 @@ export default async function ClientsPage() {
const clients = await getClients();
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
+172 -98
View File
@@ -1,58 +1,69 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatsCards } from "@/components/dashboard/StatsCards";
import { BlockedQueue } from "@/components/dashboard/BlockedQueue";
import { FarmStrip } from "@/components/dashboard/FarmStrip";
import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown";
import { QcQueue } from "@/components/dashboard/QcQueue";
import { AwaitingDelivery } from "@/components/dashboard/AwaitingDelivery";
import { LogRequestButton } from "@/components/dashboard/LogRequestButton";
import { RecentDeliveries } from "@/components/dashboard/RecentDeliveries";
import { WhoseCourt } from "@/components/dashboard/WhoseCourt";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
import { RecentActivity } from "@/components/dashboard/RecentActivity";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
import {
getAwaitingDelivery,
getBlockedExports,
getDeliveryCountdown,
getFarmStatus,
getOpsCounts,
getQcQueue,
getQcStaleHours,
getRecentDeliveries,
getWhoseCourt,
} from "@/lib/dashboard/ops";
import { listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
// Queue depth and machine state are stale the moment they are cached.
export const dynamic = "force-dynamic";
/**
* The dashboard answers one question: can we ship what is due, and what is
* stopping us? Everything on it is pipeline state — exports, QC, deliveries,
* worker heartbeats — rather than anything a person has to maintain by hand.
*/
async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart.getTime() + 86400000);
const [awaitingReview, needsRevisions, approved, activeProjects,
tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
// Producers see the floor; an artist sees the failures that are theirs.
const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, awaitingDelivery, openRequests, court] =
await Promise.all([
db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
db.version.count({ where: { approvalStatus: "APPROVED" } }),
db.project.count({ where: { status: "ACTIVE" } }),
db.task.count({
where: {
dueDate: { gte: todayStart, lt: todayEnd },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
dueDate: { lt: todayStart },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: { assignedArtistId: userId, status: { not: "DONE" } },
}),
getOpsCounts(isArtist ? userId : undefined),
getBlockedExports(isArtist ? { artistId: userId } : {}),
getFarmStatus(),
getDeliveryCountdown(),
isArtist || isClient ? [] : getQcQueue(),
getQcStaleHours(),
isArtist || isClient ? [] : getRecentDeliveries(),
isArtist || isClient ? [] : getAwaitingDelivery(),
isArtist || isClient ? [] : listDeliveryRequests({ openOnly: true }),
isArtist || isClient ? null : getWhoseCourt(),
]);
const shotsWhere = isArtist ? { artistId: userId } : {};
// An artist's own shots come first; everyone else sees the floor.
const shots = await db.shot.findMany({
where: { ...shotsWhere, status: { not: "COMPLETE" } },
where: {
...(isArtist ? { artistId: userId } : {}),
status: { not: "COMPLETE" },
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
},
take: 15,
orderBy: { updatedAt: "desc" },
orderBy: [{ dueDate: { sort: "asc", nulls: "last" } }, { updatedAt: "desc" }],
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
@@ -63,12 +74,8 @@ async function getDashboardData(userId: string, role: string) {
},
});
// My tasks (for widget)
const myTasks = await db.task.findMany({
where: {
assignedArtistId: userId,
status: { not: "DONE" },
},
where: { assignedArtistId: userId, status: { not: "DONE" } },
take: 8,
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
include: {
@@ -78,7 +85,6 @@ async function getDashboardData(userId: string, role: string) {
},
});
// Tasks in review (for supervisors/producers)
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
take: 8,
@@ -91,14 +97,8 @@ async function getDashboardData(userId: string, role: string) {
},
});
const activity = await db.notification.findMany({
where: isClient ? { userId } : {},
take: 20,
orderBy: { createdAt: "desc" },
include: { user: { select: { name: true, image: true } } },
});
// Schedule data (for producers/supervisors/admins)
// Schedule overview (producers/supervisors/admins) — unchanged; it reads the
// schedule page's own data rather than anything the pipeline owns.
const scheduledTasks =
!isArtist && !isClient
? await db.task.findMany({
@@ -119,46 +119,59 @@ async function getDashboardData(userId: string, role: string) {
!isArtist && !isClient
? await db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: { name: "asc" },
})
: [];
const stats: DashboardStats = {
awaitingReview,
needsRevisions,
approved,
overdue: tasksOverdue,
activeProjects,
tasksDueToday,
tasksInReview,
tasksOverdue,
myTasksCount,
};
const stats: DashboardStats = counts;
return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
return {
stats,
blocked,
farm,
episodes,
qcQueue,
qcStaleHours,
deliveries,
awaitingDelivery,
openRequests,
court,
shots,
myTasks,
reviewTasks,
scheduledTasks,
scheduleArtists,
};
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
await getDashboardData(session.user.id, session.user.role);
const isClient = session.user.role === "CLIENT";
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
const isArtistRole = session.user.role === "ARTIST";
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const {
stats,
blocked,
farm,
episodes,
qcQueue,
qcStaleHours,
deliveries,
awaitingDelivery,
openRequests,
court,
shots,
myTasks,
reviewTasks,
scheduledTasks,
scheduleArtists,
} = await getDashboardData(session.user.id, session.user.role);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="mb-4">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">
@@ -166,7 +179,82 @@ export default async function DashboardPage() {
</p>
</div>
<StatsCards stats={stats} />
{!isClient && <StatsCards stats={stats} />}
{/* Rows only clear by being dealt with; the heading stays so there is
always somewhere to log what a client just asked for. */}
{!isClient && !isArtistRole && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2
className={
openRequests.length + awaitingDelivery.length > 0
? "text-sm font-semibold text-amber-400 uppercase tracking-wide"
: "text-sm font-semibold text-muted-foreground uppercase tracking-wide"
}
>
Awaiting delivery
</h2>
{canSeeSchedule && <LogRequestButton />}
</div>
<AwaitingDelivery requests={openRequests} packages={awaitingDelivery} />
</div>
)}
{!isClient && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Delivery countdown
</h2>
<DeliveryCountdown episodes={episodes} />
</div>
)}
{!isClient && (
<div id="blocked" className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start scroll-mt-6">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{isArtistRole ? "Your blocked shots" : "Blocked — needs a human"}
</h2>
<BlockedQueue blocked={blocked} />
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Render farm
</h2>
<FarmStrip farm={farm} />
</div>
</div>
)}
{!isClient && !isArtistRole && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
QC queue
</h2>
<QcQueue entries={qcQueue} staleHours={qcStaleHours} />
</div>
<div className="space-y-6">
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Deliveries
</h2>
<RecentDeliveries deliveries={deliveries} />
</div>
{court && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Whose court
</h2>
<WhoseCourt court={court} />
</div>
)}
</div>
</div>
)}
{!isClient && (
<TaskWidgets
@@ -181,30 +269,16 @@ export default async function DashboardPage() {
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Schedule Overview
</h2>
<ScheduleWidgets
scheduledTasks={scheduledTasks as any}
artists={scheduleArtists}
/>
<ScheduleWidgets scheduledTasks={scheduledTasks as any} artists={scheduleArtists} />
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Shot Queue
</h2>
<div className="rounded-lg border border-border bg-card p-1">
<ShotQueue shots={shots as any} />
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity
</h2>
<div className="rounded-lg border border-border bg-card h-[400px] overflow-hidden">
<RecentActivity activities={activity as any} />
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{session.user.role === "ARTIST" ? "My shots" : "Shots in flight"}
</h2>
<div className="rounded-lg border border-border bg-card p-1">
<ShotQueue shots={shots as any} />
</div>
</div>
</div>
+13 -10
View File
@@ -2,6 +2,7 @@ import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { MobileNavProvider } from "@/components/layout/MobileNav";
export default async function DashboardLayout({
children,
@@ -12,18 +13,20 @@ export default async function DashboardLayout({
if (!session?.user) redirect("/login");
return (
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
<div className="print:hidden">
<Sidebar />
</div>
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
<MobileNavProvider>
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
<div className="print:hidden">
<Header />
<Sidebar />
</div>
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
<div className="print:hidden">
<Header />
</div>
<main className="flex-1 overflow-auto print:overflow-visible">
{children}
</main>
</div>
<main className="flex-1 overflow-auto print:overflow-visible">
{children}
</main>
</div>
</div>
</MobileNavProvider>
);
}
+331 -191
View File
@@ -1,12 +1,14 @@
"use client";
import { useState } from "react";
import { useEffect, 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 { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
@@ -51,37 +53,79 @@ interface QueueExport {
interface Props {
projects: { id: string; name: string; code: string }[];
/** Status to open filtered to, from `?status=` (see page.tsx). */
initialStatus?: string;
}
export function PipelineQueueClient({ projects }: Props) {
const PAGE_SIZE = 25;
const BLENDER_ACTIVE = ["QUEUED", "CLAIMED", "RUNNING"];
export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) {
const { data: session } = useSession();
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>(initialStatus);
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
// Searched on the server (the table is paged there), so typing is debounced
// rather than firing a request per keystroke.
useEffect(() => {
const next = searchInput.trim();
if (next === search) return;
const t = setTimeout(() => {
setSearch(next);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput, search]);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["pipeline-queue", projectId, statusFilter],
queryKey: ["pipeline-queue", projectId, statusFilter, search, page],
queryFn: async () => {
const params = new URLSearchParams({ limit: "200" });
const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(page) });
if (projectId !== "all") params.set("projectId", projectId);
if (statusFilter !== "all") params.set("status", statusFilter);
if (search) params.set("q", search);
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 } }>;
return res.json() as Promise<{
exports: QueueExport[];
pagination: { page: number; total: number; pages: number };
statusCounts: Record<string, number>;
}>;
},
refetchInterval: 5000,
// Keep the current page on screen while the next one loads, instead of
// flashing "Loading…" on every page turn.
placeholderData: (previous) => previous,
});
// Fetched here rather than in the tab so the tab label can say how many are
// running: a Blender render must not become invisible by sitting in a tab.
const { data: blenderData } = useQuery({
queryKey: ["blender-jobs", projectId],
queryFn: async () => {
const qs = 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,
});
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,
};
const pagination = data?.pagination;
const statusCounts = data?.statusCounts ?? {};
const countOf = (statuses: string[]) => statuses.reduce((n, s) => n + (statusCounts[s] ?? 0), 0);
const totalExports = Object.values(statusCounts).reduce((n, c) => n + c, 0);
const blenderJobs = blenderData?.jobs ?? [];
const blenderActive = blenderJobs.filter((j) => BLENDER_ACTIVE.includes(j.status)).length;
const blenderFailed = blenderJobs.filter((j) => ["FAILED", "EXPIRED"].includes(j.status)).length;
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
@@ -94,30 +138,56 @@ export function PipelineQueueClient({ projects }: Props) {
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" },
const cards: { label: string; value: number; status?: string; className?: string }[] = [
{ label: "Queued", value: countOf(["QUEUED"]), status: "QUEUED" },
{ label: "Rendering", value: countOf(["RENDERING"]), status: "RENDERING", className: "text-blue-400" },
// Several statuses count as failed and the filter takes one, so this card
// is a figure only.
{ label: "Failed", value: countOf(FAILED_STATUSES), className: "text-red-400" },
{ label: "Ready for QC", value: countOf(["READY_FOR_QC"]), status: "READY_FOR_QC", className: "text-amber-400" },
{
label: "Ready for Delivery",
value: countOf(["READY_FOR_DELIVERY"]),
status: "READY_FOR_DELIVERY",
className: "text-green-400",
},
];
const changeProject = (id: string) => {
setProjectId(id);
setPage(1);
};
const changeStatus = (status: string) => {
setStatusFilter(status);
setPage(1);
};
const firstRow = pagination && pagination.total > 0 ? (pagination.page - 1) * PAGE_SIZE + 1 : 0;
const lastRow = pagination ? Math.min(pagination.page * PAGE_SIZE, pagination.total) : 0;
return (
<div className="p-6 space-y-6">
<div className="p-4 md: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
After Effects exports and Blender renders — 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>
<div className="flex items-center gap-2 flex-wrap">
<Select value={projectId} onValueChange={changeProject}>
<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>
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
@@ -136,152 +206,231 @@ export function PipelineQueueClient({ projects }: Props) {
</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>
<Tabs defaultValue="exports" className="space-y-4">
<TabsList className="bg-zinc-900 border border-zinc-800">
<TabsTrigger value="exports">
After Effects exports
<span className="ml-2 text-xs text-zinc-500">{totalExports}</span>
</TabsTrigger>
<TabsTrigger value="blender">
Blender renders
{blenderActive > 0 ? (
<span className="ml-2 rounded-full bg-blue-500/20 px-1.5 text-xs text-blue-400">
{blenderActive} active
</span>
) : blenderFailed > 0 ? (
<span className="ml-2 rounded-full bg-red-500/20 px-1.5 text-xs text-red-400">
{blenderFailed} failed
</span>
) : (
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>
))
<span className="ml-2 text-xs text-zinc-500">{blenderJobs.length}</span>
)}
</tbody>
</table>
</div>
</TabsTrigger>
</TabsList>
<BlenderQueue projectId={projectId} />
<TabsContent value="exports" className="space-y-4 mt-0">
{/* Summary cards — each one with a status filters the table to what it counts */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{cards.map((c) => {
const selected = c.status !== undefined && statusFilter === c.status;
const body = (
<>
<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>
</>
);
return c.status ? (
<button
key={c.label}
type="button"
onClick={() => changeStatus(selected ? "all" : c.status!)}
className={cn(
"rounded-lg border bg-zinc-900 px-4 py-3 text-left transition-colors hover:border-zinc-600",
selected ? "border-zinc-500" : "border-zinc-800"
)}
>
{body}
</button>
) : (
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
{body}
</div>
);
})}
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setSearchInput("")}
placeholder="Search shots, e.g. UNG_114"
aria-label="Search exports by shot code"
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={changeStatus}>
<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>
<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">
{search
? `No exports match “${search}”`
: statusFilter === "all"
? "No exports yet — queue one from the After Effects panel"
: `No exports are ${statusLabel(statusFilter).toLowerCase()}`}
</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>
{pagination && pagination.pages > 1 && (
<div className="flex items-center justify-between text-sm text-zinc-400">
<span>
{firstRow}–{lastRow} of {pagination.total}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs">
Page {pagination.page} of {pagination.pages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= pagination.pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="blender" className="mt-0">
<BlenderQueue jobs={blenderJobs} loaded={blenderData !== undefined} />
</TabsContent>
</Tabs>
</div>
);
}
@@ -318,24 +467,13 @@ const BLENDER_STATUS_STYLES: Record<string, string> = {
/**
* 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.
* appear in the exports table — but they share the queue, and a render nobody
* can see is a render nobody can retry.
*/
function BlenderQueue({ projectId }: { projectId: string }) {
function BlenderQueue({ jobs, loaded }: { jobs: BlenderJobRow[]; loaded: boolean }) {
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) {
@@ -345,14 +483,16 @@ function BlenderQueue({ projectId }: { projectId: string }) {
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
}
const jobs = data?.jobs ?? [];
if (jobs.length === 0) return null;
if (jobs.length === 0) {
return (
<div className="rounded-lg border border-zinc-800 px-4 py-10 text-center text-sm text-zinc-500">
{loaded ? "No Blender renders yet — submit one from the Blender panel" : "Loading…"}
</div>
);
}
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">
@@ -3,11 +3,14 @@
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 { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { handoffStage } from "@/lib/render-pipeline/delivery-handoff";
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
import { RecordShareDialog, RecordUploadDialog } from "./HandoffDialogs";
import { RequestsPanel } from "./RequestsPanel";
interface ReadyExport {
exportId: string;
@@ -35,14 +38,25 @@ interface PackageRow {
rootPath: string | null;
errorMessage: string | null;
projectName: string;
projectCode: string;
createdAt: string;
builtAt: string | null;
uploadUrl: string | null;
uploadedAt: string | null;
uploadedByName: string | null;
sharedWith: string | null;
sharedNote: string | null;
deliveredAt: string | null;
deliveredByName: string | null;
itemCount: number;
verifiedCount: number;
exrFileCount: number;
movCount: number;
totalBytes: number;
items: {
id: string;
shotCode: string;
clientCode: string | null;
versionString: string;
status: string;
fileCount: number | null;
@@ -69,6 +83,10 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const [projectId, setProjectId] = useState<string>("");
const [episode, setEpisode] = useState<string>("");
const [building, setBuilding] = useState(false);
// Tracked as exclusions so every ready shot — including one that passes QC
// while the page is open — starts selected.
const [deselected, setDeselected] = useState<Set<string>>(() => new Set());
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
const { data: projects } = useQuery({
queryKey: ["pipeline-projects"],
@@ -101,14 +119,36 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
refetchInterval: 5000,
});
async function build(ep: string) {
if (!projectId) return;
function toggleExport(exportId: string, selected: boolean) {
setDeselected((prev) => {
const next = new Set(prev);
if (selected) next.delete(exportId);
else next.add(exportId);
return next;
});
}
function setEpisodeSelected(exportIds: string[], selected: boolean) {
setDeselected((prev) => {
const next = new Set(prev);
for (const id of exportIds) {
if (selected) next.delete(id);
else next.add(id);
}
return next;
});
}
async function build(ep: string, exportIds: string[]) {
if (!projectId || exportIds.length === 0) return;
setBuilding(true);
try {
// Always send the explicit list: the package holds exactly the shots
// ticked on screen, never one that turned ready since the page loaded.
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, episode: ep || null }),
body: JSON.stringify({ projectId, episode: ep || null, exportIds }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -126,21 +166,28 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
}
}
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" });
}
function refreshPackages() {
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
}
async function cancelPackage(id: string) {
const res = await fetch(`/api/deliveries/${id}/cancel`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Cancel failed", description: body.error ?? res.statusText, variant: "destructive" });
}
refreshPackages();
}
// Looked up from the live list so the dialog never acts on a stale row.
const handoffPackage = handoff ? packages?.packages.find((p) => p.id === handoff.packageId) : undefined;
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="p-4 md:p-6 space-y-6 max-w-6xl">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
@@ -157,6 +204,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
onChange={(e) => {
setProjectId(e.target.value);
setEpisode("");
setDeselected(new Set());
}}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
@@ -183,6 +231,8 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
)}
</div>
<RequestsPanel projectId={projectId} canBuild={canBuild} />
{/* Ready to build */}
{projectId && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
@@ -195,30 +245,69 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
Nothing has passed QC yet. Exports appear here once QC passes them.
</div>
)}
{shown.map((ep) => (
{shown.map((ep) => {
const allIds = ep.exports.map((x) => x.exportId);
const selectedIds = allIds.filter((id) => !deselected.has(id));
const allSelected = selectedIds.length === allIds.length;
const selectedExports = ep.exports.filter((x) => !deselected.has(x.exportId));
const missingChecksum = selectedExports.filter((x) => !x.hasChecksum).length;
return (
<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-sm text-zinc-400">
{canBuild ? `Selected: ${selectedIds.length} of ${ep.count} shots` : `Ready: ${ep.count} shots`}
</span>
{canBuild && selectedIds.length > 0 && (
<span className="ml-3 text-xs text-zinc-500">
{formatBytes(selectedExports.reduce((sum, x) => sum + (x.totalBytes ?? 0), 0))}
</span>
)}
{missingChecksum > 0 && (
<span className="ml-3 text-xs text-amber-400">
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
{missingChecksum} 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>
<span className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setEpisodeSelected(allIds, !allSelected)}
>
{allSelected ? "Deselect all" : "Select all"}
</Button>
<Button
size="sm"
onClick={() => build(ep.episode, selectedIds)}
disabled={building || selectedIds.length === 0}
>
<Package className="h-4 w-4 mr-2" />
Build delivery ({selectedIds.length})
</Button>
</span>
)}
</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">
{ep.exports.map((x) => {
const selected = !deselected.has(x.exportId);
return (
<div
key={x.exportId}
className={cn("flex items-center gap-2 text-xs", canBuild && !selected && "opacity-50")}
>
{canBuild && (
<input
type="checkbox"
checked={selected}
onChange={(e) => toggleExport(x.exportId, e.target.checked)}
aria-label={`Include ${x.shotCode} in the package`}
/>
)}
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
{x.shotCode}
</Link>
@@ -227,10 +316,12 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
</div>
))}
);
})}
</div>
</div>
))}
);
})}
</div>
)}
@@ -238,7 +329,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<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) => (
{(packages?.packages ?? []).map((p) => {
const stage = handoffStage(p);
return (
<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>
@@ -251,10 +344,16 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<span
className={cn(
"rounded-full border px-2 py-0.5 text-xs",
DELIVERY_STATUS_STYLES[p.status] ?? ""
// Built is not delivered: a package still owed to the
// client must not read as a green "READY".
stage ? "bg-amber-500/15 text-amber-400 border-amber-500/30" : (DELIVERY_STATUS_STYLES[p.status] ?? "")
)}
>
{statusLabel(p.status)}
{stage === "AWAITING_UPLOAD"
? "BUILT — NOT UPLOADED"
: stage === "AWAITING_SHARE"
? "UPLOADED — LINK NOT SHARED"
: statusLabel(p.status)}
</span>
<span className="text-xs text-zinc-500">
{p.episode ? `episode ${p.episode} · ` : ""}
@@ -263,17 +362,30 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<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" && (
{canBuild && stage === "AWAITING_UPLOAD" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "delivered");
setHandoff({ step: "upload", packageId: p.id });
}}
>
<Upload className="h-4 w-4 mr-2 text-amber-400" />
Record upload
</Button>
)}
{canBuild && stage === "AWAITING_SHARE" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setHandoff({ step: "share", packageId: p.id });
}}
>
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
Mark delivered
Share link
</Button>
)}
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
@@ -282,7 +394,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "cancel");
cancelPackage(p.id);
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
@@ -294,9 +406,31 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<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.uploadedAt && p.uploadUrl && (
<div className="text-xs text-zinc-400 break-all">
Uploaded {new Date(p.uploadedAt).toLocaleString()}
{p.uploadedByName ? ` by ${p.uploadedByName}` : ""} —{" "}
<a href={p.uploadUrl} target="_blank" rel="noreferrer" className="text-sky-400 hover:underline">
{p.uploadUrl}
</a>
{canBuild && stage === "AWAITING_SHARE" && (
<button
type="button"
className="ml-2 text-zinc-500 hover:text-white underline"
onClick={() => setHandoff({ step: "upload", packageId: p.id })}
>
change
</button>
)}
</div>
)}
{p.deliveredAt && (
<div className="text-xs text-emerald-400">
Delivered {new Date(p.deliveredAt).toLocaleString()}
{p.sharedWith
? `Link shared with ${p.sharedWith} ${new Date(p.deliveredAt).toLocaleString()}`
: `Delivered ${new Date(p.deliveredAt).toLocaleString()} — before uploads and shares were recorded`}
{p.deliveredByName ? ` by ${p.deliveredByName}` : ""}
{p.sharedNote ? ` — ${p.sharedNote}` : ""}
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
@@ -325,12 +459,24 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
</div>
</div>
</details>
))}
);
})}
{(packages?.packages ?? []).length === 0 && (
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
)}
</div>
</div>
{handoff?.step === "upload" && handoffPackage && (
<RecordUploadDialog pkg={handoffPackage} onClose={() => setHandoff(null)} onSaved={refreshPackages} />
)}
{handoff?.step === "share" && handoffPackage?.uploadUrl && (
<RecordShareDialog
pkg={{ ...handoffPackage, uploadUrl: handoffPackage.uploadUrl }}
onClose={() => setHandoff(null)}
onSaved={refreshPackages}
/>
)}
</div>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { buildShareMessage } from "@/lib/render-pipeline/delivery-handoff";
export interface HandoffPackage {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
rootPath: string | null;
uploadUrl: string | null;
itemCount: number;
exrFileCount: number;
movCount: number;
totalBytes: number;
items: { shotCode: string; clientCode: string | null; versionString: string }[];
}
function formatSize(bytes: number): string {
if (!bytes) return "size unknown";
const gb = bytes / 1_073_741_824;
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
}
async function post(path: string, body: unknown): Promise<string | null> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return null;
const data = await res.json().catch(() => ({}));
return data.error ?? res.statusText;
}
/** Step one: where the folder now lives on the client's side. */
export function RecordUploadDialog({
pkg,
onClose,
onSaved,
}: {
pkg: HandoffPackage;
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [uploadUrl, setUploadUrl] = useState(pkg.uploadUrl ?? "");
const [confirmed, setConfirmed] = useState(false);
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
const error = await post(`/api/deliveries/${pkg.id}/uploaded`, { uploadUrl, contentsConfirmed: confirmed });
setSaving(false);
if (error) {
toast({ title: "Upload not recorded", description: error, variant: "destructive" });
return;
}
onSaved();
onClose();
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Record upload — #{pkg.packageNumber} {pkg.name}
</DialogTitle>
<DialogDescription>
Paste the link to the folder on the client&apos;s portal. The package stays on the dashboard as
awaiting delivery until the link has also been shared.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 p-3 text-xs text-zinc-400 space-y-1">
<div className="text-zinc-200">
The upload should contain {pkg.itemCount} shot{pkg.itemCount === 1 ? "" : "s"} · {pkg.exrFileCount} EXR
frames · {pkg.movCount} MOV{pkg.movCount === 1 ? "" : "s"} · {formatSize(pkg.totalBytes)}
</div>
{pkg.rootPath && <div className="break-all">Source: {pkg.rootPath}</div>}
</div>
<div className="space-y-1.5">
<Label htmlFor="upload-url">Upload link</Label>
<Input
id="upload-url"
type="url"
placeholder="https://…"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
autoFocus
/>
</div>
<label className="flex items-start gap-2 text-sm text-zinc-300">
<input
type="checkbox"
className="mt-1"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
<span>I opened this link and the upload has finished and matches the counts above.</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={saving || !uploadUrl.trim() || !confirmed}>
Record upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Step two: the link went to a person. This is what makes it delivered. */
export function RecordShareDialog({
pkg,
onClose,
onSaved,
}: {
pkg: HandoffPackage & { uploadUrl: string };
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [sharedWith, setSharedWith] = useState("");
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
const message = buildShareMessage(pkg);
async function copy() {
try {
await navigator.clipboard.writeText(message);
toast({ title: "Message copied" });
} catch {
toast({ title: "Could not copy", description: "Select the text and copy it by hand.", variant: "destructive" });
}
}
async function save() {
setSaving(true);
const error = await post(`/api/deliveries/${pkg.id}/delivered`, { sharedWith, note: note || null });
setSaving(false);
if (error) {
toast({ title: "Not marked delivered", description: error, variant: "destructive" });
return;
}
onSaved();
onClose();
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Share link — #{pkg.packageNumber} {pkg.name}
</DialogTitle>
<DialogDescription>
Send this message to the client, then record who it went to. That marks the package delivered.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="share-message">Message</Label>
<Button size="sm" variant="outline" onClick={copy}>
<Copy className="h-3.5 w-3.5 mr-2" />
Copy message
</Button>
</div>
<Textarea id="share-message" readOnly value={message} rows={8} className="font-mono text-xs" />
</div>
<div className="space-y-1.5">
<Label htmlFor="shared-with">Sent to</Label>
<Input
id="shared-with"
placeholder="Name, and where — e.g. Sam, production WhatsApp group"
value={sharedWith}
onChange={(e) => setSharedWith(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-note">Note (optional)</Label>
<Input id="share-note" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={saving || !sharedWith.trim()}>
Link sent — mark delivered
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Package, Plus, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
import {
DELIVERABLE_LABEL,
LINE_STAGE_LABEL,
type Deliverable,
type RequestLineStage,
} from "@/lib/render-pipeline/delivery-request-state";
interface RequestRow {
id: string;
requestNumber: number;
projectId: string;
projectCode: string;
episode: string;
deliverable: string;
requestedBy: string;
note: string | null;
dueDate: string | null;
createdAt: string;
cancelReason: string | null;
state: "OPEN" | "CLOSED" | "CANCELLED";
lineCount: number;
deliveredCount: number;
severity: "normal" | "warning" | "critical";
lines: {
id: string;
shotCode: string;
clientCode: string | null;
stage: RequestLineStage;
readyExportId: string | null;
}[];
}
const SEVERITY_STYLES: Record<RequestRow["severity"], string> = {
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
critical: "bg-red-500/15 text-red-400 border-red-500/30",
};
const STAGE_STYLES: Record<RequestLineStage, string> = {
DELIVERED: "text-emerald-400",
UPLOADED: "text-amber-400",
BUILT: "text-amber-400",
BUILDING: "text-blue-400",
READY: "text-green-400",
IN_QC: "text-zinc-400",
NOT_READY: "text-zinc-500",
};
function requestTitle(r: RequestRow): string {
const what = DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable;
return r.episode ? `${what} for ${r.episode}` : `${what} · ${r.lineCount} shot${r.lineCount === 1 ? "" : "s"}`;
}
/**
* What clients have asked for. An open request lists every shot with where it
* stands, so "did 116 go out?" is answered by the page rather than by memory.
*/
export function RequestsPanel({ projectId, canBuild }: { projectId: string; canBuild: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [logging, setLogging] = useState(false);
const [cancelling, setCancelling] = useState<RequestRow | null>(null);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const { data } = useQuery({
queryKey: ["delivery-requests", projectId],
queryFn: async () => {
const res = await fetch(`/api/delivery-requests${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
if (!res.ok) throw new Error("Failed to load delivery requests");
return res.json() as Promise<{ requests: RequestRow[] }>;
},
// Lines move as packages build and get shared on this same page.
refetchInterval: 5000,
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["delivery-requests"] });
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
};
async function build(request: RequestRow) {
const exportIds = request.lines.flatMap((l) => (l.readyExportId ? [l.readyExportId] : []));
if (!exportIds.length) return;
setBusy(true);
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: request.projectId, episode: request.episode || null, exportIds }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
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 for request #${request.requestNumber}`,
});
}
refresh();
}
async function cancel() {
if (!cancelling) return;
setBusy(true);
const res = await fetch(`/api/delivery-requests/${cancelling.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) {
toast({ title: "Not cancelled", description: body.error ?? res.statusText, variant: "destructive" });
return;
}
setCancelling(null);
refresh();
}
const requests = data?.requests ?? [];
const open = requests.filter((r) => r.state === "OPEN");
const past = requests.filter((r) => r.state !== "OPEN");
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-zinc-800">
<span className="text-sm font-medium text-white">Delivery requests</span>
{canBuild && (
<Button size="sm" variant="outline" onClick={() => setLogging(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Log request
</Button>
)}
</div>
{open.length === 0 && <div className="p-4 text-sm text-zinc-500">No open requests.</div>}
<div className="divide-y divide-zinc-800/60">
{open.map((r) => {
const readyCount = r.lines.filter((l) => l.readyExportId).length;
return (
<details key={r.id} open 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">Request #{r.requestNumber}</span>
<span className="text-sm text-zinc-300">{requestTitle(r)}</span>
<span className={cn("rounded-full border px-2 py-0.5 text-xs", SEVERITY_STYLES[r.severity])}>
{r.deliveredCount} of {r.lineCount} delivered
</span>
<span className="text-xs text-zinc-500">
{r.projectCode} · asked by {r.requestedBy} · {new Date(r.createdAt).toLocaleDateString()}
{r.dueDate ? ` · due ${new Date(r.dueDate).toLocaleDateString()}` : ""}
</span>
<span className="ml-auto flex items-center gap-2">
{canBuild && readyCount > 0 && (
<Button
size="sm"
disabled={busy}
onClick={(e) => {
e.preventDefault();
build(r);
}}
>
<Package className="h-4 w-4 mr-2" />
Build {readyCount} ready
</Button>
)}
{canBuild && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setReason("");
setCancelling(r);
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
Cancel request
</Button>
)}
</span>
</summary>
<div className="px-4 pb-4 space-y-2">
{r.note && <div className="text-xs text-zinc-400">{r.note}</div>}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{r.lines.map((line) => (
<div key={line.id} className="flex items-center gap-2 text-xs">
<span className="text-zinc-300">{line.shotCode}</span>
{line.clientCode && <span className="text-zinc-600">{line.clientCode}</span>}
<span className={STAGE_STYLES[line.stage]}>{LINE_STAGE_LABEL[line.stage]}</span>
</div>
))}
</div>
</div>
</details>
);
})}
</div>
{past.length > 0 && (
<details className="border-t border-zinc-800">
<summary className="px-4 py-2.5 cursor-pointer text-xs text-zinc-500 hover:text-zinc-300">
Closed and cancelled ({past.length})
</summary>
<div className="px-4 pb-3 space-y-1">
{past.map((r) => (
<div key={r.id} className="text-xs text-zinc-500">
<span className="text-zinc-300">Request #{r.requestNumber}</span> {requestTitle(r)} · {r.projectCode} ·
asked by {r.requestedBy} ·{" "}
{r.state === "CANCELLED" ? (
<span className="text-zinc-400">cancelled — {r.cancelReason}</span>
) : (
<span className="text-emerald-400">all {r.lineCount} delivered</span>
)}
</div>
))}
</div>
</details>
)}
<LogRequestDialog
open={logging}
onClose={() => setLogging(false)}
onLogged={refresh}
defaultProjectId={projectId || undefined}
/>
{cancelling && (
<Dialog open onOpenChange={(o) => !o && !busy && setCancelling(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel request #{cancelling.requestNumber}</DialogTitle>
<DialogDescription>
{cancelling.lineCount - cancelling.deliveredCount} of {cancelling.lineCount} shots have not been
delivered. Cancelling removes the request from Awaiting delivery; it stays in the history with the
reason.
</DialogDescription>
</DialogHeader>
<Input
aria-label="Reason"
placeholder="Why — e.g. client no longer needs 116"
value={reason}
onChange={(e) => setReason(e.target.value)}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelling(null)} disabled={busy}>
Keep request
</Button>
<Button variant="destructive" onClick={cancel} disabled={busy || !reason.trim()}>
Cancel request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -155,10 +155,10 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
}
}
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
if (isLoading) return <div className="p-4 md:p-6 text-zinc-500">Loading…</div>;
if (error || !data) {
return (
<div className="p-6 text-red-400">
<div className="p-4 md:p-6 text-red-400">
{(error as Error)?.message ?? "Export not found"}
</div>
);
@@ -180,7 +180,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
];
return (
<div className="p-6 space-y-6 max-w-5xl">
<div className="p-4 md: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">
@@ -76,7 +76,7 @@ export function MachinesClient() {
const machines = data?.machines ?? [];
return (
<div className="p-6 space-y-6">
<div className="p-4 md:p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
+14 -2
View File
@@ -1,20 +1,32 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ExportStatus } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineQueueClient } from "./PipelineQueueClient";
export const dynamic = "force-dynamic";
export default async function PipelinePage() {
export default async function PipelinePage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
// Lets other pages link straight to a filtered queue — the dashboard's "In
// QC" figure is only useful if it leads to the rows it counted. Anything
// that is not a real status falls back to the unfiltered queue.
const requested = (await searchParams).status;
const initialStatus =
requested && (Object.values(ExportStatus) as string[]).includes(requested) ? requested : "all";
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} />;
return <PipelineQueueClient projects={projects} initialStatus={initialStatus} />;
}
+196 -8
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo, startTransition } from "react";
import Image from "next/image";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { SequencePlayer, type SequenceClip } from "@/components/player/SequencePlayer";
import { useReviewStore } from "@/hooks/use-review-player";
import {
Select,
@@ -26,6 +27,8 @@ import {
ExternalLink,
ShieldCheck,
Send,
ListVideo,
X,
} from "lucide-react";
interface Project {
@@ -42,6 +45,12 @@ interface PlaylistVersion {
thumbnailUrl: string | null;
posterUrl: string | null;
fps: number;
duration?: number | null;
frameCount?: number | null;
hqFileUrl?: string | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
approvalStatus: string;
taskTitle: string;
}
@@ -86,6 +95,10 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
const [activeShot, setActiveShot] = useState<PlaylistShot | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isActioning, setIsActioning] = useState(false);
/** Shots checked for the seamless playlist, by shot id. */
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sequenceMode, setSequenceMode] = useState(false);
const lastChecked = useRef<string | null>(null);
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(userRole);
@@ -112,6 +125,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
if (cancelled) return;
const list: PlaylistShot[] = data.shots ?? [];
setShots(list);
setSelected(loadSelection(projectId, list));
lastChecked.current = null;
setSequenceMode(false);
// Auto-select the first shot, or keep the current one if it still exists
setActiveShot((prev) => {
const stillExists = prev && list.some((s) => s.id === prev.id);
@@ -134,9 +150,78 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
const handleSelectShot = useCallback((shot: PlaylistShot) => {
reset();
setSequenceMode(false);
setActiveShot(shot);
}, [reset]);
const updateSelection = useCallback(
(next: Set<string>) => {
setSelected(next);
if (projectId) saveSelection(projectId, next);
if (next.size === 0) setSequenceMode(false);
},
[projectId]
);
/** Toggle one shot; shift-click sets every shot between it and the last one checked. */
const toggleChecked = (shot: PlaylistShot, shift: boolean) => {
const next = new Set(selected);
const on = !selected.has(shot.id);
const from = lastChecked.current ? shots.findIndex((s) => s.id === lastChecked.current) : -1;
const to = shots.findIndex((s) => s.id === shot.id);
const range = shift && from >= 0 ? shots.slice(Math.min(from, to), Math.max(from, to) + 1) : [shot];
for (const s of range) {
if (on) next.add(s.id);
else next.delete(s.id);
}
lastChecked.current = shot.id;
updateSelection(next);
};
const setGroupChecked = (group: PlaylistShot[], on: boolean) => {
const next = new Set(selected);
for (const s of group) {
if (on) next.add(s.id);
else next.delete(s.id);
}
updateSelection(next);
};
// The playlist, in edit order. Keyed on the versions it plays so a refresh
// (e.g. after approving a shot) doesn't restart playback.
const clipKey = shots
.filter((s) => selected.has(s.id))
.map((s) => s.latestVersion.id)
.join(",");
const clips = useMemo<SequenceClip[]>(
() =>
shots
.filter((s) => selected.has(s.id))
.map((s) => ({
id: s.latestVersion.id,
label: s.shotCode,
sublabel: `v${String(s.latestVersion.versionNumber).padStart(3, "0")}`,
url: s.latestVersion.fileUrl,
hqUrl: s.latestVersion.hqFileUrl,
fps: s.latestVersion.fps,
duration: s.latestVersion.duration,
handles: s.latestVersion,
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[clipKey]
);
const handleClipChange = useCallback(
(clip: SequenceClip) => {
// A transition, so re-rendering the page (shot list and all) yields to
// the video: rendered in one go, it dropped the frame after every cut.
startTransition(() => {
setActiveShot(shots.find((s) => s.latestVersion.id === clip.id) ?? null);
});
},
[shots]
);
const refreshShots = useCallback(() => {
if (!projectId) return;
fetch(`/api/playlist?projectId=${projectId}`)
@@ -215,6 +300,33 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
</Select>
</div>
{selected.size > 0 && (
sequenceMode ? (
<Button
size="sm"
variant="outline"
onClick={() => setSequenceMode(false)}
className="h-7 text-xs gap-1"
>
<X className="h-3 w-3" />
Exit playlist
</Button>
) : (
<Button
size="sm"
onClick={() => {
reset();
setSequenceMode(true);
}}
className="h-7 text-xs gap-1"
title="Play the checked shots back to back: cut only, no slate or handles"
>
<ListVideo className="h-3.5 w-3.5" />
Play selection ({selected.size})
</Button>
)
)}
{activeShot && (
<div className="flex items-center gap-2 ml-auto">
<span className="font-mono text-sm text-white">{activeShot.shotCode}</span>
@@ -267,7 +379,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 bg-black flex flex-col">
{activeShot ? (
{sequenceMode && clips.length > 0 ? (
<SequencePlayer clips={clips} onClipChange={handleClipChange} />
) : activeShot ? (
<ReviewPlayer
key={activeShot.latestVersion.id}
ref={playerRef}
@@ -276,6 +390,8 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
fps={activeShot.latestVersion.fps}
comments={[]}
annotations={[]}
handles={activeShot.latestVersion}
hqVideoUrl={activeShot.latestVersion.hqFileUrl}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600">
@@ -297,7 +413,26 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
<div className="px-3 py-2.5 border-b border-border shrink-0 flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Shots</span>
{shots.length > 0 && (
<span className="text-xs text-zinc-600">{shots.length}</span>
<div className="flex items-center gap-2 text-xs">
{selected.size > 0 ? (
<button
onClick={() => updateSelection(new Set())}
className="text-zinc-500 hover:text-zinc-200"
>
Clear
</button>
) : (
<button
onClick={() => setGroupChecked(shots, true)}
className="text-zinc-500 hover:text-zinc-200"
>
Select all
</button>
)}
<span className="text-zinc-600">
{selected.size > 0 ? `${selected.size}/${shots.length}` : shots.length}
</span>
</div>
)}
</div>
@@ -320,16 +455,25 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
<div>
{episodeGroups.map(([episode, episodeShots]) => (
<div key={episode}>
<div className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800">
<label className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800 flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-3 w-3 accent-blue-500 cursor-pointer"
checked={episodeShots.every((s) => selected.has(s.id))}
onChange={(e) => setGroupChecked(episodeShots, e.target.checked)}
title="Select this episode"
/>
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
Ep {episode}
</span>
</div>
</label>
{episodeShots.map((shot) => (
<ShotThumbnail
key={shot.id}
shot={shot}
isActive={activeShot?.id === shot.id}
isChecked={selected.has(shot.id)}
onToggle={(shift) => toggleChecked(shot, shift)}
onClick={() => handleSelectShot(shot)}
/>
))}
@@ -343,6 +487,8 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
key={shot.id}
shot={shot}
isActive={activeShot?.id === shot.id}
isChecked={selected.has(shot.id)}
onToggle={(shift) => toggleChecked(shot, shift)}
onClick={() => handleSelectShot(shot)}
/>
))}
@@ -358,25 +504,45 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
function ShotThumbnail({
shot,
isActive,
isChecked,
onToggle,
onClick,
}: {
shot: PlaylistShot;
isActive: boolean;
isChecked: boolean;
onToggle: (shift: boolean) => void;
onClick: () => void;
}) {
const thumb = shot.latestVersion.thumbnailUrl ?? shot.latestVersion.posterUrl ?? shot.thumbnailUrl;
const ApprovalIcon = APPROVAL_ICONS[shot.latestVersion.approvalStatus] ?? Clock;
return (
<button
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter") onClick();
}}
className={cn(
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group",
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group cursor-pointer",
isActive
? "bg-blue-500/10 border-l-2 border-l-blue-500"
: "hover:bg-zinc-800/40 border-l-2 border-l-transparent"
)}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggle(e.shiftKey);
}}
className="mt-3 h-3.5 w-3.5 shrink-0 accent-blue-500 cursor-pointer"
title="Add to playlist (shift-click for a range)"
/>
{/* Thumbnail */}
<div className="w-20 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 shrink-0 flex items-center justify-center">
{thumb ? (
@@ -408,6 +574,28 @@ function ShotThumbnail({
</span>
</div>
</div>
</button>
</div>
);
}
const selectionKey = (projectId: string) => `playlist:selection:${projectId}`;
/** The checked shots remembered for this project, minus any no longer listed. */
function loadSelection(projectId: string, shots: PlaylistShot[]): Set<string> {
try {
const ids: unknown = JSON.parse(localStorage.getItem(selectionKey(projectId)) ?? "[]");
if (!Array.isArray(ids)) return new Set();
const listed = new Set(shots.map((s) => s.id));
return new Set(ids.filter((id): id is string => typeof id === "string" && listed.has(id)));
} catch {
return new Set();
}
}
function saveSelection(projectId: string, selected: Set<string>) {
try {
localStorage.setItem(selectionKey(projectId), JSON.stringify([...selected]));
} catch {
// Storage unavailable: the selection just isn't remembered.
}
}
@@ -13,6 +13,7 @@ import { TaskCard } from "@/components/tasks/TaskCard";
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { cn } from "@/lib/utils";
import { sortShotsForList } from "@/lib/shot-order";
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
@@ -147,13 +148,7 @@ export function ProjectTabsClient({
// For episodic projects: group and sort shots by episode → scene → shotNumber
const episodeGroups: [string, ShotWithDetails[]][] = projectType === "EPISODIC"
? (() => {
const sorted = [...shots].sort((a, b) => {
const ea = a.episode ?? "";
const eb = b.episode ?? "";
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
const sorted = sortShotsForList(shots, projectType);
const map = new Map<string, ShotWithDetails[]>();
for (const shot of sorted) {
const key = shot.episode ?? "(No Episode)";
@@ -167,13 +162,7 @@ export function ProjectTabsClient({
// For standard projects: group by custom shot group when any exist
const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup)
? (() => {
const sorted = [...shots].sort((a, b) => {
const ga = a.shotGroup?.name ?? "\uFFFF";
const gb = b.shotGroup?.name ?? "\uFFFF";
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
const sorted = sortShotsForList(shots, projectType);
const map = new Map<string, ShotWithDetails[]>();
for (const shot of sorted) {
const key = shot.shotGroup?.name ?? "(Ungrouped)";
@@ -69,7 +69,7 @@ export default async function KanbanPage({
]);
return (
<div className="p-6 space-y-6 max-w-[1800px] mx-auto">
<div className="p-4 md:p-6 space-y-6 max-w-[1800px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
+1 -1
View File
@@ -123,7 +123,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">
@@ -31,6 +31,8 @@ import {
Pencil,
Check,
LayoutList,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types";
@@ -78,12 +80,47 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
import { useToast } from "@/components/ui/use-toast";
import { shotThumbnail } from "@/lib/shot-thumbnail";
type ShotNavTarget = { id: string; shotCode: string } | null;
type ShotNav = { prev: ShotNavTarget; next: ShotNavTarget; position: number; total: number };
function ShotNavLink({
projectId,
target,
direction,
}: {
projectId: string;
target: ShotNavTarget;
direction: "prev" | "next";
}) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
const label = direction === "prev" ? "Previous shot" : "Next shot";
if (!target) {
return (
<Button variant="outline" size="sm" disabled className="h-7 gap-1 px-2 font-mono text-xs">
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{direction === "prev" ? "Prev" : "Next"}</span>
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
</Button>
);
}
return (
<Button variant="outline" size="sm" asChild className="h-7 gap-1 px-2 font-mono text-xs">
<Link href={`/projects/${projectId}/shots/${target.id}`} title={`${label}: ${target.shotCode}`}>
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
<span className="hidden sm:inline max-w-[10rem] truncate">{target.shotCode}</span>
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
</Link>
</Button>
);
}
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [shotNav, setShotNav] = useState<ShotNav | null>(null);
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
@@ -108,6 +145,7 @@ export default function ShotDetailPage() {
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setShotNav(data.shotNav ?? null);
setCanApprove(data.canApprove ?? false);
setCanInternallyApprove(data.canInternallyApprove ?? false);
setTasks(data.tasks ?? []);
@@ -230,7 +268,7 @@ export default function ShotDetailPage() {
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
<div className="p-4 md:p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
@@ -242,10 +280,23 @@ export default function ShotDetailPage() {
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
{/* Prev / next shot, in the project page's list order */}
{shotNav && shotNav.total > 1 && (
<div className="ml-auto flex items-center gap-1">
<ShotNavLink projectId={params.id} target={shotNav.prev} direction="prev" />
<span className="px-1 text-xs tabular-nums">
{shotNav.position} / {shotNav.total}
</span>
<ShotNavLink projectId={params.id} target={shotNav.next} direction="next" />
</div>
)}
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Phones: back button, then a full-width thumbnail, then the details
below it. From md: one row, as on desktop. */}
<div className="flex flex-wrap md:flex-nowrap items-start gap-4 md:gap-6">
{/* Thumbnail – cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
@@ -253,7 +304,7 @@ export default function ShotDetailPage() {
</Link>
</Button>
{shotThumbnail(shot) && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<div className="relative flex-shrink-0 w-full md:w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shotThumbnail(shot)!}
@@ -263,10 +314,10 @@ export default function ShotDetailPage() {
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
<div className="space-y-2 min-w-0 w-full md:w-auto md:min-w-min">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold font-mono break-all md:break-normal">{shot.shotCode}</h1>
{/* Shot version badge — click to edit for managers */}
{canManage ? (
@@ -424,7 +475,7 @@ export default function ShotDetailPage() {
)}
{canManage && (
<div className="ml-auto shrink-0 flex items-center gap-2">
<div className="ml-auto shrink-0 flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto">
{/* Internal approval action */}
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
<Button
@@ -510,7 +561,8 @@ export default function ShotDetailPage() {
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
{/* Phones: five tabs are wider than the screen, so the bar scrolls sideways. */}
<div className="flex border-b border-border mb-5 overflow-x-auto overflow-y-hidden md:overflow-visible [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
+9 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,6 +8,7 @@ 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";
import { useScrollMemory } from "@/hooks/use-scroll-memory";
export default function ProjectsPage() {
const [search, setSearch] = useState("");
@@ -43,22 +44,11 @@ export default function ProjectsPage() {
const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects;
const SCROLL_KEY = 'projects-scroll';
useEffect(() => {
if (!isLoading) {
const saved = sessionStorage.getItem(SCROLL_KEY);
if (saved) {
sessionStorage.removeItem(SCROLL_KEY);
requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10)));
}
}
}, [isLoading]);
const saveScroll = () => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
const pageRef = useRef<HTMLDivElement>(null);
useScrollMemory("projects", pageRef, !isLoading);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Projects</h1>
@@ -104,9 +94,11 @@ export default function ProjectsPage() {
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
// auto-rows-fr keeps every row the same height, so a project with a
// long description does not make its whole row taller than the rest.
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleProjects.map((project: any) => (
<div key={project.id} onClick={saveScroll}>
<div key={project.id} className="h-full">
<ProjectCard project={project} />
</div>
))}
+3 -3
View File
@@ -186,7 +186,7 @@ export default function MigrationPage() {
if (fetchError) {
return (
<div className="p-6 max-w-2xl">
<div className="p-4 md:p-6 max-w-2xl">
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{fetchError}
@@ -200,7 +200,7 @@ export default function MigrationPage() {
if (!hetznerConfigured) {
return (
<div className="p-6 max-w-2xl">
<div className="p-4 md:p-6 max-w-2xl">
<div className="flex items-center gap-2 text-sm text-amber-400 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
Hetzner object storage is not configured. Set{" "}
@@ -217,7 +217,7 @@ export default function MigrationPage() {
const allFilteredSelected = filtered.length > 0 && filtered.every((f) => selected.has(f.key));
return (
<div className="p-6 space-y-4 max-w-5xl">
<div className="p-4 md:p-6 space-y-4 max-w-5xl">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
+1 -1
View File
@@ -19,7 +19,7 @@ export default async function SettingsPage() {
const hetznerConfig = isAdmin ? await getHetznerConfig() : null;
return (
<div className="p-6 space-y-6 max-w-2xl mx-auto">
<div className="p-4 md:p-6 space-y-6 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold">Account Settings</h1>
<Card>
@@ -72,7 +72,7 @@ export default function StorageTestPage() {
};
return (
<div className="p-6 max-w-3xl space-y-6">
<div className="p-4 md:p-6 max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-semibold">Object Storage Playback Test</h1>
<p className="text-sm text-muted-foreground mt-1">
@@ -0,0 +1,146 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import * as Popover from "@radix-ui/react-popover";
import { formatDistanceToNow } from "date-fns";
import { AlertCircle, Check, Loader2, MessageSquareReply } from "lucide-react";
import { listUnreadShotComments, markShotCommentsRead } from "@/actions/comment-reads";
import type { UnreadCommentItem } from "@/lib/comment-reads";
import { useToast } from "@/components/ui/use-toast";
/**
* The "!" beside a shot code while it has comments this user has not marked
* read. Opening it lists them; they stay flagged until "Mark read" is clicked —
* opening the list or the shot is not enough.
*/
export function NewCommentsMarker({
shotId,
shotCode,
count,
latestAt,
onRead,
}: {
shotId: string;
shotCode: string;
count: number;
latestAt: string;
onRead: (shotId: string) => void;
}) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UnreadCommentItem[] | null>(null);
const [loadError, setLoadError] = useState(false);
const [marking, startMarking] = useTransition();
const load = async () => {
setLoadError(false);
try {
setItems(await listUnreadShotComments(shotId));
} catch {
setLoadError(true);
}
};
const markRead = () =>
startMarking(async () => {
// Read up to what was listed; with the list not loaded, up to what the
// board showed. Anything newer stays flagged.
const upTo = items?.[0]?.createdAt ?? latestAt;
try {
await markShotCommentsRead(shotId, upTo);
setOpen(false);
onRead(shotId);
} catch {
toast({ title: "Could not mark comments read", variant: "destructive" });
}
});
return (
<Popover.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) load();
}}
>
<Popover.Trigger asChild>
<button
type="button"
title={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
aria-label={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
className="flex items-center justify-center h-4 min-w-4 rounded-full bg-orange-500 px-1 text-[10px] font-bold leading-none text-white hover:bg-orange-400 shrink-0"
>
!{count > 1 && <span className="ml-0.5 font-medium">{count}</span>}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
className="z-50 w-96 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl"
>
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
<span className="text-xs font-medium text-zinc-300">
New on <span className="font-mono text-white">{shotCode}</span>
</span>
<button
type="button"
onClick={markRead}
disabled={marking}
className="flex items-center gap-1 rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
>
{marking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Mark read
</button>
</div>
<div className="max-h-80 overflow-y-auto">
{loadError ? (
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5" />
Could not load the comments.
</p>
) : items === null ? (
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-zinc-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading…
</p>
) : items.length === 0 ? (
<p className="px-3 py-4 text-xs text-zinc-500">
Nothing new — the comments may have been deleted.
</p>
) : (
<ul className="divide-y divide-zinc-800">
{items.map((item) => (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/review/${item.versionId}`}
className="block px-3 py-2 hover:bg-zinc-800/60"
>
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
{item.kind === "reply" && <MessageSquareReply className="h-3 w-3" />}
<span className="text-zinc-300">{item.authorName}</span>
{item.source === "WHATSAPP" && (
<span className="rounded bg-green-500/15 px-1 text-green-400">client</span>
)}
<span className="font-mono">
v{String(item.versionNumber).padStart(3, "0")} · f{item.frameNumber}
</span>
<span className="ml-auto">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</span>
</div>
<p className="mt-0.5 line-clamp-3 whitespace-pre-line text-xs text-zinc-200">
{item.text}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
+677 -46
View File
@@ -4,10 +4,12 @@ import { useState, useTransition, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useScrollMemory } from "@/hooks/use-scroll-memory";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -28,10 +30,14 @@ import {
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, bulkDeleteShots, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, getInitials } from "@/lib/utils";
import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
import { useToast } from "@/components/ui/use-toast";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
import { markProjectCommentsRead } from "@/actions/comment-reads";
import { NewCommentsMarker } from "./NewCommentsMarker";
import {
Film,
Clock,
@@ -50,9 +56,13 @@ import {
ChevronsUpDown,
Clapperboard,
Scaling,
UserPlus,
PackageCheck,
ClipboardList,
} from "lucide-react";
import { format } from "date-fns";
import { shotThumbnail } from "@/lib/shot-thumbnail";
import type { ShotDeliverySummary } from "@/lib/render-pipeline/shot-delivery-summary";
type ShotRow = {
id: string;
@@ -69,11 +79,23 @@ type ShotRow = {
description: string | null;
isKeyShot: boolean;
isScaled: boolean;
manualDeliveredAt: Date | string | null;
manualDeliveryNote: string | null;
shotVersion: string | null;
delivery: ShotDeliverySummary;
/** Comments from others this user has not marked read; null = none. */
unreadComments: { count: number; latestAt: string } | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
shotGroup: { id: string; name: string } | null;
};
interface Artist {
id: string;
name: string | null;
email: string;
image: string | null;
}
interface Project {
id: string;
name: string;
@@ -87,10 +109,29 @@ interface ShotStatusClientProps {
selectedProject: Project | null;
shots: ShotRow[];
episodeDueDates: { episode: string; dueDate: Date | string }[];
artists: Artist[];
canManage: boolean;
}
const SCROLL_KEY = 'shot-status-scroll';
function readStored(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
/** A saved list of open sections, or null when none was saved (or it is unreadable). */
function readStoredList(key: string): string[] | null {
const raw = readStored(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : null;
} catch {
return null;
}
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
@@ -225,6 +266,257 @@ function BulkDueDateDialog({
);
}
// ── BulkAssignArtistDialog ────────────────────────────────────────────────────
const UNASSIGN = "__unassign__";
/**
* Hand a selection of shots to one artist, or clear the assignment.
*
* Unassigning is offered as an explicit choice rather than a separate menu
* entry — reassignment and hand-back are the same production move, and the
* confirm button changes wording so nobody clears a batch by accident.
*/
function BulkAssignArtistDialog({
open,
onClose,
onApply,
artists,
count,
}: {
open: boolean;
onClose: () => void;
onApply: (artistId: string | null) => Promise<void>;
artists: Artist[];
count: number;
}) {
const [value, setValue] = useState("");
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (open) setValue("");
}, [open]);
const handleApply = () => {
if (!value) return;
startTransition(async () => {
await onApply(value === UNASSIGN ? null : value);
});
};
const unassigning = value === UNASSIGN;
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Assign Artist</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400 -mt-1">
Applying to <span className="text-white font-medium">{count}</span>{" "}
shot{count !== 1 ? "s" : ""}
</p>
<div className="space-y-1.5 pt-1">
<label className="text-xs font-medium text-zinc-400">Artist</label>
<Select value={value} onValueChange={setValue}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an artist..." />
</SelectTrigger>
<SelectContent>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
<span className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarImage src={a.image ?? undefined} />
<AvatarFallback className="text-[9px] bg-zinc-700 text-zinc-300">
{getInitials(a.name ?? a.email)}
</AvatarFallback>
</Avatar>
{a.name ?? a.email}
</span>
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={UNASSIGN}>
<span className="text-zinc-400">Unassign</span>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-zinc-500 pt-1">
This replaces whoever is currently on {count === 1 ? "the shot" : "these shots"}.
</p>
</div>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleApply} disabled={!value || isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{unassigning ? "Unassign" : "Assign"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── ManualDeliveryDialog ──────────────────────────────────────────────────────
/**
* Record shots as delivered outside the render pipeline — the ones sent by
* hand before packages existed, which have no Export to put in a package.
*
* The date asked for is when the frames went out, not today: the delivery
* countdown's weekly rate reads it, so a backfill stamped with today's date
* would claim a week nobody had.
*/
function ManualDeliveryDialog({
open,
onClose,
onApply,
count,
}: {
open: boolean;
onClose: () => void;
onApply: (
deliveredOn: string | null,
note: string,
evidence: { uploadUrl: string; sharedWith: string },
) => Promise<void>;
count: number;
}) {
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const [sharedWith, setSharedWith] = useState("");
const [clearing, setClearing] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (open) {
setDate("");
setNote("");
setUploadUrl("");
setSharedWith("");
setClearing(false);
}
}, [open]);
const handleApply = () => {
if (!clearing && !date) return;
startTransition(async () => {
await onApply(clearing ? null : date, note, { uploadUrl, sharedWith });
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delivered Outside Pipeline</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400 -mt-1">
Applying to <span className="text-white font-medium">{count}</span>{" "}
shot{count !== 1 ? "s" : ""}
</p>
<div className="space-y-4 pt-1">
<div className="space-y-2.5">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name="manual-delivery-mode"
checked={!clearing}
onChange={() => setClearing(false)}
className="accent-blue-500 cursor-pointer"
/>
<span className="text-sm text-zinc-200">Mark as delivered</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name="manual-delivery-mode"
checked={clearing}
onChange={() => setClearing(true)}
className="accent-blue-500 cursor-pointer"
/>
<span className="text-sm text-zinc-200">Clear manual delivery</span>
</label>
</div>
{!clearing && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Date the shots went out</label>
<input
type="date"
value={date}
max={format(new Date(), "yyyy-MM-dd")}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 [color-scheme:dark] focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Upload link</label>
<input
type="url"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://…"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Link sent to</label>
<input
type="text"
value={sharedWith}
onChange={(e) => setSharedWith(e.target.value)}
placeholder="Name, and where it was sent"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
<p className="text-xs text-zinc-500">
Both are needed for shots on an open delivery request; a backfill of old deliveries can leave
them blank.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Aspera upload, turnover 3"
className="min-h-[56px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
rows={2}
/>
</div>
</>
)}
<p className="text-xs text-zinc-500">
For shots sent by hand before the render pipeline. They count as delivered on the
dashboard; no package is created and nothing is sent.
</p>
</div>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleApply} disabled={(!clearing && !date) || isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{clearing ? "Clear" : "Mark delivered"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BulkDeleteDialog({
open,
onClose,
@@ -567,6 +859,73 @@ function ScaledShotToggle({ shot }: { shot: ShotRow }) {
);
}
const PENDING_TONE = {
progress: "text-blue-300",
waiting: "text-amber-300",
failed: "text-red-400",
} as const;
/**
* What last reached the client for this shot, with evidence — not what was
* rendered or built — plus whatever newer is still on its way.
*/
function DeliveryCell({ delivery, shotVersion }: { delivery: ShotDeliverySummary; shotVersion: string | null }) {
const { sent, review, pending } = delivery;
if (!sent && !review && !pending) {
return <span className="text-xs text-zinc-600 italic">Never sent</span>;
}
// The panel's version moving past what was delivered means a newer version
// exists that the client has not had, even before it has been exported.
const behind = sent?.version && shotVersion && sent.version !== shotVersion && !pending;
return (
<div className="space-y-0.5 text-xs leading-tight">
{sent ? (
<div
className="flex items-center gap-1 whitespace-nowrap text-emerald-300"
// Who it went to stays in the tooltip: an email address swamps the column.
title={[sent.label, sent.to && `sent to ${sent.to}`, sent.note].filter(Boolean).join(" · ")}
>
<PackageCheck className="h-3 w-3 shrink-0" />
{sent.version && <span className="font-mono">{sent.version}</span>}
<span className="text-zinc-300">{format(new Date(sent.at), "d MMM")}</span>
{sent.via === "MANUAL" && <span className="text-zinc-500">· manual</span>}
</div>
) : (
<div className="text-zinc-600 italic">Not delivered</div>
)}
{pending && (
<div className={cn("truncate", PENDING_TONE[pending.tone])} title={pending.label}>
{pending.version && <span className="font-mono">{pending.version}</span>} {pending.label}
</div>
)}
{behind && <div className="text-zinc-500">current is <span className="font-mono">{shotVersion}</span></div>}
{review && (
<div className="truncate text-zinc-500" title={review.label}>
Review MOV <span className="font-mono">{review.version}</span> {format(new Date(review.at), "d MMM")}
</div>
)}
</div>
);
}
/** On a section header: how many of its shots have new comments, so a
* collapsed episode, scene or group still shows there is something to read. */
function SectionUnreadBadge({ shots }: { shots: ShotRow[] }) {
const flagged = shots.filter((s) => s.unreadComments).length;
if (!flagged) return null;
const label = `${flagged} shot${flagged !== 1 ? "s" : ""} with new comments`;
return (
<span
title={label}
aria-label={label}
className="flex items-center gap-0.5 rounded-full bg-orange-500 px-1.5 h-4 text-[10px] font-bold leading-none text-white shrink-0"
>
!<span className="font-medium">{flagged}</span>
</span>
);
}
function ShotTable({
shots,
canManage,
@@ -574,6 +933,7 @@ function ShotTable({
selectedIds,
onToggle,
onToggleAll,
onCommentsRead,
}: {
shots: ShotRow[];
canManage: boolean;
@@ -581,6 +941,7 @@ function ShotTable({
selectedIds: Set<string>;
onToggle: (id: string) => void;
onToggleAll: (ids: string[], checked: boolean) => void;
onCommentsRead: (shotId: string) => void;
}) {
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
const someChecked = shots.some((s) => selectedIds.has(s.id));
@@ -601,7 +962,9 @@ function ShotTable({
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Thumb</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Shot</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-40">Artist</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-36">Last sent</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</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">
@@ -671,10 +1034,18 @@ function ShotTable({
<Link
href={`/projects/${projectId}/shots/${shot.id}`}
className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
onClick={() => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))}
>
{shot.shotCode}
</Link>
{shot.unreadComments && (
<NewCommentsMarker
shotId={shot.id}
shotCode={shot.shotCode}
count={shot.unreadComments.count}
latestAt={shot.unreadComments.latestAt}
onRead={onCommentsRead}
/>
)}
{shot.shotVersion && (
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
)}
@@ -700,6 +1071,25 @@ function ShotTable({
</Badge>
</td>
{/* Artist */}
<td className="py-2 px-3">
{shot.artist ? (
<span className="flex items-center gap-2 min-w-0">
<Avatar className="h-5 w-5 shrink-0">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[9px] bg-zinc-700 text-zinc-300">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 truncate">
{shot.artist.name ?? shot.artist.email}
</span>
</span>
) : (
<span className="text-xs text-zinc-600 italic">Unassigned</span>
)}
</td>
{/* Due date */}
<td className="py-2 px-3">
{dueDate ? (
@@ -717,6 +1107,11 @@ function ShotTable({
)}
</td>
{/* Last delivery, and anything newer on its way */}
<td className="py-2 px-3">
<DeliveryCell delivery={shot.delivery} shotVersion={shot.shotVersion} />
</td>
{/* Notes */}
<td className="py-2 px-3 min-w-[220px]">
<NotesCell shot={shot} canManage={canManage} />
@@ -743,21 +1138,25 @@ export function ShotStatusClient({
projects,
selectedProjectId,
selectedProject,
shots,
shots: initialShots,
episodeDueDates,
artists,
canManage,
}: ShotStatusClientProps) {
const router = useRouter();
const { toast } = useToast();
// Restore scroll position when shots load
useEffect(() => {
const saved = sessionStorage.getItem(SCROLL_KEY);
if (saved) {
sessionStorage.removeItem(SCROLL_KEY);
requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10)));
}
}, [shots]);
// Shots marked read here drop their "!" at once, ahead of the refresh; the
// refreshed props are authoritative, so the local set is cleared when they land.
const [readShotIds, setReadShotIds] = useState<Set<string>>(new Set());
useEffect(() => setReadShotIds(new Set()), [initialShots]);
const shots = readShotIds.size
? initialShots.map((s) => (readShotIds.has(s.id) ? { ...s, unreadComments: null } : s))
: initialShots;
const markShotReadLocally = (shotId: string) =>
setReadShotIds((prev) => new Set(prev).add(shotId));
const [unreadOnly, setUnreadOnly] = useState(false);
const [markingAllRead, startMarkingAllRead] = useTransition();
// Restore last selected project if no project is currently selected
useEffect(() => {
@@ -775,36 +1174,56 @@ export function ShotStatusClient({
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Statuses picked from the summary badges; empty shows every shot.
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set());
// Load saved episode expand state when project changes
// Which sections were open is restored before the scroll position: until
// they are, the page is shorter and the saved offset would not fit.
const [expandRestored, setExpandRestored] = useState(false);
const pageRef = useRef<HTMLDivElement>(null);
useScrollMemory(selectedProjectId ? `shotStatus:${selectedProjectId}` : null, pageRef, expandRestored);
// Load saved expand state and grouping when the project changes
useEffect(() => {
if (!selectedProjectId) return;
try {
const saved = localStorage.getItem(`shotStatus:${selectedProjectId}:episodeExpand`);
setExpandedEpisodes(saved ? new Set(JSON.parse(saved) as string[]) : new Set());
} catch {
setExpandedEpisodes(new Set());
}
try {
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
} catch {
setGroupMode("flat");
}
setExpandRestored(false);
setStatusFilter(new Set());
setUnreadOnly(false);
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
setGroupMode(mode);
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
// Scenes and groups open fully when their mode is first chosen, so with
// nothing saved yet they come back that way rather than all collapsed.
setExpandedScenes(
new Set(readStoredList(`shotStatus:${selectedProjectId}:sceneExpand`) ?? shots.map((s) => s.scene))
);
setExpandedGroups(
new Set(readStoredList(`shotStatus:${selectedProjectId}:groupExpand`) ?? shots.map((s) => s.shotGroup?.id ?? ""))
);
setExpandRestored(true);
// Shots are read only for the defaults; a refresh after an edit must not
// reset what the user has opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProjectId]);
const saveExpanded = (kind: "episodeExpand" | "sceneExpand" | "groupExpand", open: Set<string>) => {
if (!selectedProjectId) return;
try {
localStorage.setItem(`shotStatus:${selectedProjectId}:${kind}`, JSON.stringify([...open]));
} catch {}
};
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
const toggleEpisode = (ep: string) =>
setExpandedEpisodes((prev) => {
const next = new Set(prev);
next.has(ep) ? next.delete(ep) : next.add(ep);
if (selectedProjectId) {
try {
localStorage.setItem(`shotStatus:${selectedProjectId}:episodeExpand`, JSON.stringify([...next]));
} catch {}
}
saveExpanded("episodeExpand", next);
return next;
});
@@ -812,6 +1231,7 @@ export function ShotStatusClient({
setExpandedScenes((prev) => {
const next = new Set(prev);
next.has(sc) ? next.delete(sc) : next.add(sc);
saveExpanded("sceneExpand", next);
return next;
});
@@ -819,13 +1239,22 @@ export function ShotStatusClient({
setExpandedGroups((prev) => {
const next = new Set(prev);
next.has(g) ? next.delete(g) : next.add(g);
saveExpanded("groupExpand", next);
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 (mode === "scene") {
const all = new Set(shots.map((s) => s.scene));
setExpandedScenes(all);
saveExpanded("sceneExpand", all);
}
if (mode === "group") {
const all = new Set(shots.map((s) => s.shotGroup?.id ?? ""));
setExpandedGroups(all);
saveExpanded("groupExpand", all);
}
if (selectedProjectId) {
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
}
@@ -854,6 +1283,52 @@ export function ShotStatusClient({
const clearSelection = () => setSelectedIds(new Set());
const toggleStatusFilter = (status: string) => {
const next = new Set(statusFilter);
next.has(status) ? next.delete(status) : next.add(status);
setStatusFilter(next);
// Bulk actions must never reach shots the filter has hidden.
if (next.size > 0) {
setSelectedIds((prev) => {
const kept = new Set<string>();
for (const s of shots)
if (prev.has(s.id) && next.has(s.status) && (!unreadOnly || s.unreadComments)) kept.add(s.id);
return kept;
});
}
};
const visibleShots = shots.filter(
(s) => (statusFilter.size === 0 || statusFilter.has(s.status)) && (!unreadOnly || s.unreadComments)
);
const unreadShotCount = shots.filter((s) => s.unreadComments).length;
const isFiltered = statusFilter.size > 0 || unreadOnly;
const toggleUnreadOnly = () => {
const next = !unreadOnly;
setUnreadOnly(next);
// Bulk actions must never reach shots the filter has hidden.
if (next) {
setSelectedIds((prev) => {
const kept = new Set<string>();
for (const s of shots) if (prev.has(s.id) && s.unreadComments) kept.add(s.id);
return kept;
});
}
};
const handleMarkAllRead = () =>
startMarkingAllRead(async () => {
if (!selectedProjectId) return;
try {
await markProjectCommentsRead(selectedProjectId);
setReadShotIds(new Set(shots.filter((s) => s.unreadComments).map((s) => s.id)));
setUnreadOnly(false);
} catch {
toast({ title: "Could not mark comments read", variant: "destructive" });
}
});
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
try {
const result = await bulkUpdateDueDates([...selectedIds], date, includeTasks);
@@ -868,6 +1343,52 @@ export function ShotStatusClient({
}
};
const handleBulkAssignArtist = async (artistId: string | null) => {
try {
const result = await bulkAssignArtist([...selectedIds], artistId);
toast({
title: result.artistName
? `${result.updated} shot${result.updated !== 1 ? "s" : ""} assigned to ${result.artistName}`
: `${result.updated} shot${result.updated !== 1 ? "s" : ""} unassigned`,
});
setAssignDialogOpen(false);
clearSelection();
router.refresh();
} catch (e) {
toast({
title: "Failed to assign artist",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
}
};
const handleManualDelivery = async (
deliveredOn: string | null,
note: string,
evidence: { uploadUrl: string; sharedWith: string },
) => {
try {
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note, evidence);
if (!result.success) {
// The dialog stays open so the missing link and recipient can be added.
toast({ title: "Not marked delivered", description: result.error, variant: "destructive" });
return;
}
const n =`${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
toast({ title: deliveredOn ? `${n} marked delivered` : `Manual delivery cleared on ${n}` });
setManualDeliveryDialogOpen(false);
clearSelection();
router.refresh();
} catch (e) {
toast({
title: "Failed to update delivery",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
}
};
const handleBulkDelete = async () => {
try {
const result = await bulkDeleteShots([...selectedIds]);
@@ -893,7 +1414,7 @@ export function ShotStatusClient({
const episodeGroups: [string, ShotRow[]][] = isEpisodic
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
for (const shot of visibleShots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
@@ -905,7 +1426,7 @@ export function ShotStatusClient({
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
for (const shot of visibleShots) {
const key = shot.scene || "(No Scene)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
@@ -917,7 +1438,7 @@ export function ShotStatusClient({
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
? (() => {
const map = new Map<string, [string, ShotRow[]]>();
for (const shot of shots) {
for (const shot of visibleShots) {
const id = shot.shotGroup?.id ?? "";
const name = shot.shotGroup?.name ?? "(No Group)";
if (!map.has(id)) map.set(id, [name, []]);
@@ -942,7 +1463,7 @@ export function ShotStatusClient({
const selectionCount = selectedIds.size;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
@@ -950,10 +1471,12 @@ export function ShotStatusClient({
</div>
{/* Project selector */}
<div className="flex items-center gap-3">
{/* Phones: the picker fills the row and "Group by" wraps onto its own
line. From md: one row, as on desktop. */}
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm text-zinc-400 shrink-0">Project:</label>
<Select value={selectedProjectId ?? ""} onValueChange={handleProjectChange}>
<SelectTrigger className="w-72">
<SelectTrigger className="flex-1 min-w-0 md:flex-none md:w-72">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
@@ -966,7 +1489,7 @@ export function ShotStatusClient({
</SelectContent>
</Select>
{selectedProject && !isEpisodic && (
<>
<div className="flex w-full md:w-auto items-center gap-3">
<span className="text-sm text-zinc-500">Group by:</span>
{(["flat", "scene", "group"] as const).map((mode) => (
<button
@@ -982,7 +1505,7 @@ export function ShotStatusClient({
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
</button>
))}
</>
</div>
)}
</div>
@@ -1008,16 +1531,78 @@ export function ShotStatusClient({
<div className="flex flex-wrap items-center gap-2">
{selectionCount === 0 ? (
<>
<span className="text-xs text-zinc-500 self-center">{shots.length} shots total</span>
<span className="text-xs text-zinc-500 self-center">
{isFiltered ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
</span>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
const count = statusCounts[key] ?? 0;
if (!count) return null;
const active = statusFilter.has(key);
return (
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
{count} {cfg.label}
</Badge>
<button
key={key}
type="button"
onClick={() => toggleStatusFilter(key)}
aria-pressed={active}
title={active ? `Stop filtering by ${cfg.label}` : `Show only ${cfg.label}`}
className="rounded-full"
>
<Badge
className={cn(
"gap-1 text-xs cursor-pointer",
cfg.color,
statusFilter.size > 0 && !active && "opacity-40 hover:opacity-70",
active && "ring-1 ring-current"
)}
>
{count} {cfg.label}
</Badge>
</button>
);
})}
{unreadShotCount > 0 && (
<span className="flex items-center gap-1">
<button
type="button"
onClick={toggleUnreadOnly}
aria-pressed={unreadOnly}
title={unreadOnly ? "Show all shots" : "Show only shots with new comments"}
className="rounded-full"
>
<Badge
className={cn(
"gap-1 text-xs cursor-pointer bg-orange-500/15 text-orange-300 border-orange-500/30",
unreadOnly && "ring-1 ring-current"
)}
>
<span className="font-bold">!</span>
{unreadShotCount} with new comments
</Badge>
</button>
<button
type="button"
onClick={handleMarkAllRead}
disabled={markingAllRead}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50"
>
{markingAllRead ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Mark all read
</button>
</span>
)}
{isFiltered && (
<button
type="button"
onClick={() => {
setStatusFilter(new Set());
setUnreadOnly(false);
}}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
<X className="h-3 w-3" />
Clear filter
</button>
)}
</>
) : (
<div className="flex items-center gap-3 rounded-lg border border-blue-500/30 bg-blue-500/10 px-4 py-2">
@@ -1040,10 +1625,22 @@ export function ShotStatusClient({
<CalendarDays className="h-3.5 w-3.5 mr-2" />
Change Due Date
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAssignDialogOpen(true)}>
<UserPlus className="h-3.5 w-3.5 mr-2" />
Assign Artist
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMovReviewDialogOpen(true)}>
<Clapperboard className="h-3.5 w-3.5 mr-2" />
Build MOV Review Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRequestDialogOpen(true)}>
<ClipboardList className="h-3.5 w-3.5 mr-2" />
Log Delivery Request
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
<PackageCheck className="h-3.5 w-3.5 mr-2" />
Delivered Outside Pipeline
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
@@ -1100,6 +1697,7 @@ export function ShotStatusClient({
<span className="text-xs text-zinc-500 font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
<SectionUnreadBadge shots={episodeShots} />
{epDueDate && (
<span className={cn(
"flex items-center gap-1 text-xs",
@@ -1119,6 +1717,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1158,6 +1757,7 @@ export function ShotStatusClient({
<span className="text-xs text-zinc-500 font-normal">
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
</span>
<SectionUnreadBadge shots={sceneShots} />
</button>
</div>
{!collapsed && (
@@ -1168,6 +1768,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1207,6 +1808,7 @@ export function ShotStatusClient({
<span className="text-xs text-zinc-500 font-normal">
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
</span>
<SectionUnreadBadge shots={groupShots} />
</button>
</div>
{!collapsed && (
@@ -1217,6 +1819,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1228,11 +1831,12 @@ export function ShotStatusClient({
) : (
<div className="overflow-x-auto">
<ShotTable
shots={shots}
shots={visibleShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1249,6 +1853,33 @@ export function ShotStatusClient({
count={selectionCount}
/>
{/* Bulk artist assignment dialog */}
<BulkAssignArtistDialog
open={assignDialogOpen}
onClose={() => setAssignDialogOpen(false)}
onApply={handleBulkAssignArtist}
artists={artists}
count={selectionCount}
/>
{/* Manual (pre-pipeline) delivery dialog */}
<ManualDeliveryDialog
open={manualDeliveryDialogOpen}
onClose={() => setManualDeliveryDialogOpen(false)}
onApply={handleManualDelivery}
count={selectionCount}
/>
{/* A client asked for the selected shots */}
{selectedProjectId && (
<LogRequestDialog
open={requestDialogOpen}
onClose={() => setRequestDialogOpen(false)}
onLogged={clearSelection}
preset={{ projectId: selectedProjectId, shotIds: [...selectedIds] }}
/>
)}
{/* Bulk delete dialog */}
<BulkDeleteDialog
open={deleteDialogOpen}
+61 -2
View File
@@ -2,6 +2,8 @@ import { db } from "@/lib/db";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ShotStatusClient } from "./ShotStatusClient";
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
import { getUnreadCommentSummaries, type UnreadCommentSummary } from "@/lib/comment-reads";
async function getProjects() {
return db.project.findMany({
@@ -19,8 +21,17 @@ async function getEpisodeDueDatesForProject(projectId: string) {
});
}
/** Everyone a shot can be handed to — clients and deactivated accounts excluded. */
async function getAssignableArtists() {
return db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true },
orderBy: { name: "asc" },
});
}
async function getShotsForProject(projectId: string) {
return db.shot.findMany({
const shots = await db.shot.findMany({
where: { projectId },
orderBy: [
{ episode: { sort: "asc", nulls: "last" } },
@@ -42,13 +53,57 @@ async function getShotsForProject(projectId: string) {
description: true,
isKeyShot: true,
isScaled: true,
manualDeliveredAt: true,
manualDeliveryNote: true,
manualDeliverySharedWith: 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 } },
// Every export, for the packages each went into; the newest live one
// is what is on its way.
exports: {
orderBy: [{ versionNumber: "desc" }, { createdAt: "desc" }],
select: {
versionString: true,
status: true,
deliveryItems: {
select: {
versionString: true,
package: {
select: {
kind: true,
status: true,
packageNumber: true,
createdAt: true,
builtAt: true,
uploadedAt: true,
deliveredAt: true,
sharedWith: true,
},
},
},
},
},
},
},
});
return shots.map(({ exports, ...shot }) => ({
...shot,
delivery: summariseShotDelivery({
packages: exports.flatMap((e) =>
e.deliveryItems.map((item) => ({ ...item.package, versionString: item.versionString }))
),
manual: {
deliveredAt: shot.manualDeliveredAt,
sharedWith: shot.manualDeliverySharedWith,
note: shot.manualDeliveryNote,
},
latestExport: exports.find((e) => e.status !== "CANCELLED" && e.status !== "ARCHIVED") ?? null,
}),
}));
}
export default async function ShotStatusPage({
@@ -62,11 +117,14 @@ export default async function ShotStatusPage({
const { projectId } = await searchParams;
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const [projects, shots, episodeDueDates] = await Promise.all([
const [projects, shotRows, episodeDueDates, artists, unread] = await Promise.all([
getProjects(),
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
canManage ? getAssignableArtists() : Promise.resolve([]),
projectId ? getUnreadCommentSummaries(session.user.id, projectId) : Promise.resolve({} as Record<string, UnreadCommentSummary>),
]);
const shots = shotRows.map((shot) => ({ ...shot, unreadComments: unread[shot.id] ?? null }));
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
@@ -77,6 +135,7 @@ export default async function ShotStatusPage({
selectedProject={selectedProject}
shots={shots as any}
episodeDueDates={episodeDueDates}
artists={artists}
canManage={canManage}
/>
);
+1 -1
View File
@@ -110,7 +110,7 @@ export function TasksPageClient({
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function UsersPage() {
}));
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
<Users className="h-5 w-5 text-amber-400" />
+124
View File
@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { PLATE_EXTS, parsePlateFilename } from "@/lib/batch-upload-parser";
export type PlatePreviewItemStatus =
| "match" // shot found → a new footage plate will be added
| "duplicate" // the shot already has a plate with this exact filename
| "no-match" // no shot in this project matches the filename
| "unsupported"; // extension is not an accepted video format
export interface PlatePreviewItem {
fileName: string;
shotCode: string | null;
shotId: string | null;
/** Label the new plate will be created with */
label: string;
status: PlatePreviewItemStatus;
/** Number of plates the matched shot already has */
existingPlateCount: number;
}
/**
* POST /api/batch-upload/plates
* Body: { projectId: string; fileNames: string[] }
*
* Previews which shot each footage plate file will be attached to, derived
* from the filename. e.g. "DH2_VFX_058_120_REF.mov" → shot "DH2_VFX_058_120"
* with the label "REF". Nothing is uploaded.
*/
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { projectId, fileNames } = body as { projectId?: unknown; fileNames?: unknown };
if (!projectId || typeof projectId !== "string" || !Array.isArray(fileNames)) {
return NextResponse.json(
{ error: "projectId and fileNames[] are required" },
{ status: 400 }
);
}
const shots = await db.shot.findMany({
where: { projectId },
select: {
id: true,
shotCode: true,
footagePlates: { select: { fileName: true } },
},
});
const shotCodes = shots.map((s) => s.shotCode);
const shotByCode = new Map(shots.map((s) => [s.shotCode, s]));
// Several files in one batch can land on the same shot, so keep a running
// count per shot to number the "Plate N" fallback labels correctly.
const allocated = new Map<string, number>();
const items: PlatePreviewItem[] = fileNames.map((fileName: string) => {
const parsed = parsePlateFilename(fileName, shotCodes);
if (!PLATE_EXTS.has(parsed.ext)) {
return {
fileName,
shotCode: null,
shotId: null,
label: "",
status: "unsupported" as const,
existingPlateCount: 0,
};
}
const shot = parsed.shotCode ? shotByCode.get(parsed.shotCode) ?? null : null;
if (!shot) {
return {
fileName,
shotCode: null,
shotId: null,
label: "",
status: "no-match" as const,
existingPlateCount: 0,
};
}
const existingPlateCount = shot.footagePlates.length;
// Re-running a batch shouldn't silently pile up identical plates.
const isDuplicate = shot.footagePlates.some(
(p) => p.fileName.toLowerCase() === fileName.toLowerCase()
);
if (isDuplicate) {
return {
fileName,
shotCode: shot.shotCode,
shotId: shot.id,
label: parsed.label,
status: "duplicate" as const,
existingPlateCount,
};
}
const takenSoFar = allocated.get(shot.id) ?? 0;
allocated.set(shot.id, takenSoFar + 1);
return {
fileName,
shotCode: shot.shotCode,
shotId: shot.id,
label: parsed.label || `Plate ${existingPlateCount + takenSoFar + 1}`,
status: "match" as const,
existingPlateCount,
};
});
return NextResponse.json({ items });
}
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export const maxDuration = 30;
/**
* POST /api/batch-upload/plates/upload
* Body: { projectId, shotId, key, fileName, fileSize?, label? }
*
* Commits a footage plate whose bytes were already uploaded straight to
* Hetzner via POST /api/upload/presign, so large .mov plates never pass
* through this server or the reverse proxy.
*/
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 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { projectId, shotId, key, fileName, fileSize, label } = body as {
projectId?: unknown;
shotId?: unknown;
key?: unknown;
fileName?: unknown;
fileSize?: unknown;
label?: unknown;
};
if (
typeof projectId !== "string" ||
typeof shotId !== "string" ||
typeof key !== "string" ||
typeof fileName !== "string" ||
!projectId ||
!shotId ||
!key ||
!fileName
) {
return NextResponse.json(
{ error: "projectId, shotId, key and fileName are required" },
{ status: 400 }
);
}
// The key must be one this app handed out via /api/upload/presign.
if (!key.startsWith("videos/")) {
return NextResponse.json({ error: "Invalid storage key" }, { 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 last = await db.footagePlate.findFirst({
where: { shotId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const size = typeof fileSize === "number" && Number.isFinite(fileSize) && fileSize > 0
? BigInt(Math.round(fileSize))
: null;
const plate = await db.footagePlate.create({
data: {
shotId,
fileUrl: `/api/files/${key}`,
fileKey: key,
fileName,
fileSize: size,
label: typeof label === "string" ? label.trim().slice(0, 100) : "",
sortOrder: (last?.sortOrder ?? -1) + 1,
},
});
revalidatePath(`/projects/${projectId}`);
revalidatePath(`/projects/${projectId}/shots/${shotId}`);
return NextResponse.json({
success: true,
plateId: plate.id,
label: plate.label,
});
}
+15 -2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack";
import { notifyCommentsAdded } from "@/lib/notifications";
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
export async function POST(
@@ -28,8 +29,8 @@ export async function POST(
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
shot: { select: { projectId: true, shotCode: true, artistId: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, assignedArtistId: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
@@ -70,6 +71,18 @@ export async function POST(
});
}
await notifyCommentsAdded({
notes: [{
versionId,
shotCode,
frameNumber,
artistIds: [version.shot?.artistId, version.task?.assignedArtistId],
}],
authorId: user.id,
authorName: user.name ?? user.email,
origin: "client",
}).catch((err) => console.error("[client comment] notify failed", err));
// canEdit mirrors the GET shape — a note you just wrote is always your own.
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
}
+6 -1
View File
@@ -48,6 +48,7 @@ export async function GET(
clientCode: true,
episode: true,
sequence: true,
scene: true,
description: true,
status: true,
shotApprovalStatus: true,
@@ -124,9 +125,13 @@ export async function GET(
// 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 }) => ({
// The scene is our numbering too, and an alias does not have to share it
// (SNV's "SNV_101_0463_CP_VFX" is our scene 001), so an aliased shot goes
// out without one rather than with a number the client never used.
shots: shots.map(({ generatedThumbnailUrl, clientCode, scene, ...shot }) => ({
...shot,
shotCode: clientShotCode({ ...shot, clientCode }),
scene: clientShotCode({ ...shot, clientCode }) === shot.shotCode ? scene || null : null,
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
tasks: shot.tasks.map((task) => ({
...task,
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken } from "@/lib/review-auth";
import { generateHetznerDownloadUrl } from "@/lib/storage";
import { clientHighResFilename } from "@/lib/shot-code";
/**
* GET /api/client/[token]/versions/[versionId]/highres/download
* The version's ProRes, for a client reviewer: same visibility rule as the
* version itself (GET ../route.ts), and named with the client's shot code.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string; versionId: string }> }
) {
const { token, versionId } = await params;
const result = await validateReviewToken(token, req);
if (result.type !== "ok") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const version = await db.version.findUnique({
where: { id: versionId },
select: {
highResKey: true,
highResFilename: true,
isClientVisible: true,
shot: { select: { projectId: true, shotCode: true, clientCode: true } },
task: {
select: {
projectId: true,
shot: { select: { shotCode: true, clientCode: true } },
},
},
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== result.session.projectId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Task-only versions need explicit sharing, as in the version route
if (!version.shot && !version.isClientVisible) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!version.highResKey) {
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
}
const shot = version.shot ?? version.task?.shot ?? null;
const url = await generateHetznerDownloadUrl(
version.highResKey,
clientHighResFilename(version.highResFilename ?? "high-res.mov", shot)
);
return NextResponse.json({ url });
}
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
import { clientHighResFilename, clientShotCode, clientTaskTitle } from "@/lib/shot-code";
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
export async function GET(
@@ -95,13 +95,21 @@ export async function GET(
// The shot the title and breadcrumb are drawn from — either relation.
const namingShot = version.shot ?? ((version.task as any)?.shot ?? null);
// High Res: this version's own ProRes (uploaded by the render worker) wins;
// the shot's manually uploaded file is the fallback for older versions.
const versionHighRes = !!version.highResKey;
const serializedVersion = {
...version,
fileSize: version.fileSize?.toString() ?? null,
highResKey: undefined, // never expose a storage key
highResSize: version.highResSize?.toString() ?? null,
// Top-level high-res fields so the client works for both shot and task versions
hasHighRes: !!(highResShot?.highResKey),
highResShotId: highResShot?.highResKey ? (highResShot.id ?? null) : null,
highResFilename: highResShot?.highResFilename ?? null,
hasHighRes: versionHighRes || !!(highResShot?.highResKey),
highResSource: versionHighRes ? "version" : highResShot?.highResKey ? "shot" : null,
highResShotId: !versionHighRes && highResShot?.highResKey ? (highResShot.id ?? null) : null,
highResFilename: versionHighRes
? clientHighResFilename(version.highResFilename ?? "high-res.mov", namingShot)
: highResShot?.highResFilename ?? null,
// Expose only whether a high-res file exists on the shot relation, never the storage key
shot: version.shot
? {
@@ -2,20 +2,30 @@ 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";
import { recordShareSchema } from "@/lib/render-pipeline/delivery-handoff";
// ── 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.
// The upload link has been sent to the client. Takes who it was sent to, and
// moves every packaged export to DELIVERED — the end of the pipeline.
export async function POST(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ packageId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { packageId } = await params;
const result = await markDelivered(packageId, user.id);
const parsed = recordShareSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await markDelivered(packageId, parsed.data, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { markUploaded } from "@/lib/render-pipeline/deliveries";
import { recordUploadSchema } from "@/lib/render-pipeline/delivery-handoff";
// ── POST /api/deliveries/{id}/uploaded ───────────────────────────────────────
//
// The folder is on the client's portal. Records where, so the package shows as
// waiting to be shared rather than waiting to be uploaded.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ packageId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { packageId } = await params;
const parsed = recordUploadSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await markUploaded(packageId, parsed.data, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { cancelDeliveryRequest } from "@/lib/render-pipeline/delivery-requests";
import { cancelRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
// ── POST /api/delivery-requests/{id}/cancel ──────────────────────────────────
//
// The only way a request leaves the awaiting list without being delivered, and
// it has to say why.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ requestId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { requestId } = await params;
const parsed = cancelRequestSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await cancelDeliveryRequest(requestId, parsed.data.reason, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { getRequestOptions } from "@/lib/render-pipeline/delivery-requests";
// ── GET /api/delivery-requests/options?projectId= ────────────────────────────
//
// The episodes a request can be logged against.
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const projectId = new URL(req.url).searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
return NextResponse.json(await getRequestOptions(projectId));
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin, requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { createDeliveryRequest, listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
import { createRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
// ── GET /api/delivery-requests · POST /api/delivery-requests ─────────────────
//
// What clients have asked for, and how much of each ask has gone out.
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const { searchParams } = new URL(req.url);
const requests = await listDeliveryRequests({
projectId: searchParams.get("projectId") ?? undefined,
openOnly: searchParams.get("open") === "1",
});
return NextResponse.json({ requests });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
export async function POST(req: NextRequest) {
try {
const user = await requirePipelineAdmin();
const parsed = createRequestSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await createDeliveryRequest(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,62 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
import { isArtifactKey } from "@/lib/render-pipeline/artifact-kinds";
import { db } from "@/lib/db";
import { abortHetznerMultipartUpload, completeHetznerMultipartUpload } from "@/lib/storage";
// ── POST /api/ext/render/jobs/{jobId}/artifact-complete (E20b) ───────────────
//
// Closes a multipart upload started by artifact-presign: stitches the parts
// (by part number + ETag) into the final object, or with `abort: true` throws
// the stored parts away after the worker gave up.
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;
key?: string;
uploadId?: string;
parts?: { partNumber?: number; etag?: string }[];
abort?: boolean;
};
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.key || !isArtifactKey(body.key)) throw new PipelineError(422, "key is not a worker artifact key");
if (!body.uploadId) throw new PipelineError(400, "uploadId is required");
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?)");
}
if (body.abort) {
await abortHetznerMultipartUpload(body.key, body.uploadId);
return NextResponse.json({ ok: true, aborted: true });
}
const parts = (body.parts ?? []).filter(
(p): p is { partNumber: number; etag: string } =>
typeof p.partNumber === "number" && typeof p.etag === "string" && p.etag.length > 0
);
if (parts.length === 0) throw new PipelineError(422, "parts are required");
await completeHetznerMultipartUpload(body.key, body.uploadId, parts);
return NextResponse.json({ ok: true, key: body.key, url: `/api/files/${body.key}` });
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -3,20 +3,29 @@ 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";
import { ARTIFACT_KIND_FOLDERS } from "@/lib/render-pipeline/artifact-kinds";
import {
createHetznerMultipartUpload,
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/.
// metadata/log → renders/, highres (the delivery MOV) → highres/ beside the
// manually uploaded High Res files.
//
// `multipart: true` + `size` (worker ≥ 1.7.2) returns an S3 multipart upload
// instead — an uploadId and one presigned URL per part — which the worker
// fills in parallel and closes with artifact-complete (E20b). A single PUT
// from the studio is capped near 0.1 MB/s; parallel parts scale with the
// number of connections. Older workers never ask and get the single PUT.
const KIND_FOLDERS: Record<string, string> = {
preview: "videos",
thumbnail: "image",
metadata: "renders",
log: "renders",
};
const KIND_FOLDERS = ARTIFACT_KIND_FOLDERS;
const MIN_PART = 8 * 1024 * 1024;
const MAX_PARTS = 10000; // S3 limit
export async function POST(
req: NextRequest,
@@ -26,7 +35,14 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: { machineId?: string; kind?: string; fileName?: string; contentType?: string };
let body: {
machineId?: string;
kind?: string;
fileName?: string;
contentType?: string;
multipart?: boolean;
size?: number;
};
try {
body = await req.json();
} catch {
@@ -48,6 +64,14 @@ export async function POST(
}
const key = `${folder}/${randomUUID()}-${sanitizeFileName(body.fileName)}`;
if (body.multipart && typeof body.size === "number" && body.size > MIN_PART) {
const partSize = Math.max(MIN_PART, Math.ceil(body.size / MAX_PARTS));
const partCount = Math.ceil(body.size / partSize);
const { uploadId, partUrls } = await createHetznerMultipartUpload(key, body.contentType, partCount);
return NextResponse.json({ key, url: `/api/files/${key}`, uploadId, partSize, partUrls });
}
const presignedUrl = await generateHetznerPresignedUploadUrl(key, body.contentType);
return NextResponse.json({ presignedUrl, key, url: `/api/files/${key}` });
} catch (err) {
@@ -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 { attachHighRes, type AttachHighResInput } from "@/lib/render-pipeline/preview";
// ── POST /api/ext/render/jobs/{jobId}/attach-highres (E13b) ──────────────────
//
// The preview's ProRes MOV, uploaded in the background after finalize (worker
// ≥ 1.7.1). Attaches it to the version as its High Res download, or records on
// the export timeline that the upload failed.
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: AttachHighResInput & { 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 attachHighRes(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
+12
View File
@@ -50,6 +50,12 @@ export async function GET(req: NextRequest) {
thumbnailUrl: true,
posterUrl: true,
fps: true,
duration: true,
frameCount: true,
hqFileUrl: true,
handleHead: true,
handleTail: true,
pictureStartFrame: true,
approvalStatus: true,
mimeType: true,
createdAt: true,
@@ -85,6 +91,12 @@ export async function GET(req: NextRequest) {
thumbnailUrl: latest.thumbnailUrl,
posterUrl: latest.posterUrl,
fps: latest.fps,
duration: latest.duration,
frameCount: latest.frameCount,
hqFileUrl: latest.hqFileUrl,
handleHead: latest.handleHead,
handleTail: latest.handleTail,
pictureStartFrame: latest.pictureStartFrame,
approvalStatus: latest.approvalStatus,
taskTitle: latest.taskTitle,
},
+5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getProjectPosters } from "@/lib/project-poster";
export async function GET(req: Request) {
const session = await auth();
@@ -53,9 +54,13 @@ export async function GET(req: Request) {
statsMap.set(row.projectId, existing);
}
// One still per project for the card art, chosen from the shots themselves.
const posters = await getProjectPosters(projects.map((p) => p.id));
const projectsWithStats = projects.map((p) => ({
...p,
shotStats: statsMap.get(p.id) ?? { total: 0, approved: 0, inProgress: 0 },
poster: posters.get(p.id) ?? null,
}));
return NextResponse.json({ projects: projectsWithStats });
+2 -1
View File
@@ -7,7 +7,7 @@ import { listExports } from "@/lib/render-pipeline/exports";
// ── GET /api/render/queue (E21) ──────────────────────────────────────────────
//
// Exports list for the Render Queue page. Optional filters: projectId,
// episode, status; standard pagination. Defaults to all statuses so the queue
// episode, status, q (shot/client code search); standard pagination. Defaults to all statuses so the queue
// page can show history too — the UI filters live states client-side.
export async function GET(req: NextRequest) {
@@ -19,6 +19,7 @@ export async function GET(req: NextRequest) {
projectId: searchParams.get("projectId") ?? undefined,
episode: searchParams.get("episode") ?? undefined,
shotId: searchParams.get("shotId") ?? undefined,
search: searchParams.get("q")?.slice(0, 100) || undefined,
status:
status && (Object.values(ExportStatus) as string[]).includes(status)
? (status as ExportStatus)
+34
View File
@@ -73,6 +73,40 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
}
/**
* Reactivate a review link: push its expiry `expiresInDays` from now and turn
* it back on. The token is unchanged, so a URL the client already has works
* again rather than a new one having to be sent.
*/
export async function PATCH(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
const body = await req.json().catch(() => ({}));
const expiresInDays = Number(body.expiresInDays ?? 30);
if (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 365) {
return NextResponse.json({ error: "expiresInDays must be 1–365" }, { status: 400 });
}
const existing = await db.reviewSession.findUnique({ where: { id }, select: { id: true } });
if (!existing) return NextResponse.json({ error: "Review link not found" }, { status: 404 });
const reviewSession = await db.reviewSession.update({
where: { id },
data: { expiresAt: addDays(new Date(), expiresInDays), isActive: true },
});
return NextResponse.json({ session: reviewSession });
}
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session?.user) {
+31 -2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { sortShotsForList } from "@/lib/shot-order";
export async function GET(
req: NextRequest,
@@ -38,9 +39,35 @@ export async function GET(
const project = await db.project.findUnique({
where: { id: shot.projectId },
select: { name: true },
select: {
name: true,
projectType: true,
shots: {
orderBy: { createdAt: "asc" },
select: {
id: true,
shotCode: true,
episode: true,
scene: true,
shotNumber: true,
shotGroup: { select: { name: true } },
},
},
},
});
// Neighbours in the project page's shot list order, for prev/next navigation
const orderedShots = project ? sortShotsForList(project.shots, project.projectType) : [];
const shotIndex = orderedShots.findIndex((s) => s.id === shotId);
const neighbour = (i: number) =>
shotIndex >= 0 && orderedShots[i] ? { id: orderedShots[i].id, shotCode: orderedShots[i].shotCode } : null;
const shotNav = {
prev: neighbour(shotIndex - 1),
next: neighbour(shotIndex + 1),
position: shotIndex + 1,
total: orderedShots.length,
};
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { shotId },
@@ -82,7 +109,7 @@ export async function GET(
session.user.role as string
);
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
// Serialize BigInt fields (fileSize, highResSize) so JSON.stringify doesn't throw
const shotSerialized = {
...shot,
footagePlates: shot.footagePlates.map((p) => ({
@@ -92,12 +119,14 @@ export async function GET(
versions: shot.versions.map((v) => ({
...v,
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
highResSize: v.highResSize != null ? v.highResSize.toString() : null,
})),
};
return NextResponse.json({
shot: shotSerialized,
projectName: project?.name ?? "",
shotNav,
canApprove,
canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string),
tasks,
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { generateHetznerDownloadUrl } from "@/lib/storage";
/**
* GET /api/versions/[versionId]/highres/download — short-lived presigned URL
* for this version's ProRes (the delivery MOV the render worker uploaded).
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const version = await db.version.findUnique({
where: { id: versionId },
select: { highResKey: true, highResFilename: true },
});
if (!version?.highResKey) {
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
}
const url = await generateHetznerDownloadUrl(
version.highResKey,
version.highResFilename ?? "high-res.mov"
);
return NextResponse.json({ url });
}
+244 -159
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useScrollMemory } from '@/hooks/use-scroll-memory';
import Link from 'next/link';
import Image from 'next/image';
import {
@@ -18,6 +19,7 @@ import {
Check,
Search,
X,
Layers,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Montserrat } from 'next/font/google';
@@ -51,6 +53,7 @@ interface ClientShot {
shotCode: string;
episode: string | null;
sequence: string | null;
scene: string | null;
description: string | null;
status: string;
thumbnailUrl: string | null;
@@ -127,7 +130,6 @@ const TASK_TYPE_LABELS: Record<string, string> = {
GENERAL: 'Task',
};
const SCROLL_KEY = 'client-portal-scroll';
const QUEUE_KEY = 'client-portal-queue';
const STATE_KEY = 'client-portal-state';
const PREFS_KEY_PREFIX = 'client-portal-prefs-';
@@ -136,6 +138,26 @@ function getLatestVersion(task: ClientTask): ClientVersion | undefined {
return task.versions[0];
}
const NO_SCENE = '__none__';
/** Shots split into scenes in scene order, with unscened shots last. */
function groupShotsByScene<T extends ClientShot>(shots: T[]): [string, T[]][] {
const map = new Map<string, T[]>();
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()).sort(([a], [b]) => {
if (a === NO_SCENE) return 1;
if (b === NO_SCENE) return -1;
return a.localeCompare(b, undefined, { numeric: true });
});
}
/** Collapse key for a scene, kept in the same set as the episodes. */
const sceneKey = (episode: string, scene: string) => `${episode}::${scene}`;
export default function ClientPortalPage({
params,
}: {
@@ -152,8 +174,14 @@ export default function ClientPortalPage({
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
const [groupByScene, setGroupByScene] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const [episodeFilter, setEpisodeFilter] = useState<string | null>(null);
// Filters and collapsed episodes set the page height, so the scroll
// position is restored only once they are back.
const [viewRestored, setViewRestored] = useState(false);
const pageRef = useRef<HTMLDivElement>(null);
useScrollMemory(token ? `clientPortal:${token}` : null, pageRef, viewRestored);
const toggleEpisode = (ep: string) => {
setCollapsedEpisodes((prev) => {
@@ -189,9 +217,10 @@ export default function ClientPortalPage({
if (savedState) {
sessionStorage.removeItem(STATE_KEY);
try {
const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState);
const { statusFilter: sf, sortMode: sm, groupByScene: gs, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState);
if (sf !== undefined) setStatusFilter(sf);
if (sm !== undefined) setSortMode(sm);
if (gs !== undefined) setGroupByScene(gs);
if (sq !== undefined) setSearchQuery(sq ?? '');
if (ef !== undefined) setEpisodeFilter(ef);
if (ce !== undefined) setCollapsedEpisodes(new Set(ce));
@@ -200,22 +229,17 @@ export default function ClientPortalPage({
try {
const prefs = localStorage.getItem(PREFS_KEY_PREFIX + token);
if (prefs) {
const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(prefs);
const { statusFilter: sf, sortMode: sm, groupByScene: gs, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(prefs);
if (sf !== undefined) setStatusFilter(sf);
if (sm !== undefined) setSortMode(sm);
if (gs !== undefined) setGroupByScene(gs);
if (sq !== undefined) setSearchQuery(sq ?? '');
if (ef !== undefined) setEpisodeFilter(ef);
if (ce !== undefined) setCollapsedEpisodes(new Set(ce));
}
} catch { /* ignore */ }
}
const saved = sessionStorage.getItem(SCROLL_KEY);
if (saved) {
sessionStorage.removeItem(SCROLL_KEY);
requestAnimationFrame(() => {
window.scrollTo(0, parseInt(saved, 10));
});
}
setViewRestored(true);
}
}, [loading, token]);
@@ -227,13 +251,14 @@ export default function ClientPortalPage({
JSON.stringify({
statusFilter,
sortMode,
groupByScene,
searchQuery,
episodeFilter,
collapsedEpisodes: Array.from(collapsedEpisodes),
}),
);
} catch { /* ignore */ }
}, [token, statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes, loading]);
}, [token, statusFilter, sortMode, groupByScene, searchQuery, episodeFilter, collapsedEpisodes, loading]);
const loadProject = (t: string) => {
setLoading(true);
@@ -310,6 +335,8 @@ export default function ClientPortalPage({
// Group shots by episode; null episode → "Shots"
const isEpisodic = shots.some((s) => s.episode != null);
const hasScenes = shots.some((s) => s.scene);
const showScenes = groupByScene && hasScenes;
const shotsByEpisode = shots.reduce<Record<string, ClientShot[]>>(
(acc, shot) => {
const key = shot.episode ?? 'Shots';
@@ -410,10 +437,11 @@ export default function ClientPortalPage({
Object.entries(shotsByEpisode)
.filter(([episode]) => episodeFilter === null || episodeFilter === episode)
.forEach(([, epShots]) => {
epShots
const visible = epShots
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
.filter((shot) => shot.tasks.length > 0)
.filter(shotMatchesSearch)
.filter(shotMatchesSearch);
(showScenes ? groupShotsByScene(visible).flatMap(([, sc]) => sc) : visible)
.forEach((shot) => {
shot.tasks.forEach((task) => {
const ver = getLatestVersion(task);
@@ -429,13 +457,13 @@ export default function ClientPortalPage({
};
const handleReviewClick = () => {
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
sessionStorage.setItem(QUEUE_KEY, JSON.stringify(getReviewQueue()));
sessionStorage.setItem(
STATE_KEY,
JSON.stringify({
statusFilter,
sortMode,
groupByScene,
searchQuery,
episodeFilter,
collapsedEpisodes: Array.from(collapsedEpisodes),
@@ -443,8 +471,149 @@ export default function ClientPortalPage({
);
};
// Approved / needs changes / awaiting counts for an episode or scene header.
const statusPills = (tasks: ClientTask[]) => {
const nApproved = tasks.filter(
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
).length;
const nChanges = tasks.filter((t) =>
['REJECTED', 'NEEDS_CHANGES'].includes(
getLatestVersion(t)?.approvalStatus ?? '',
),
).length;
const nPending = tasks.length - nApproved - nChanges;
return (
<div className="flex items-center gap-2 shrink-0">
{nApproved > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
<CheckCircle2 className="h-3 w-3" />
{nApproved}
</span>
)}
{nChanges > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
{nChanges}
</span>
)}
{nPending > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
<Clock className="h-3 w-3" />
{nPending}
</span>
)}
</div>
);
};
const renderShot = (shot: ClientShot) => (
<div key={shot.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-3">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="flex items-center gap-1.5 group/shotcode">
<p className="font-mono text-sm font-semibold text-zinc-300">
{shot.shotCode}
{shot.description && (
<span className="font-sans font-normal text-zinc-500 ml-2">
{shot.description}
</span>
)}
</p>
<button
onClick={() => copyToClipboard(shot.shotCode, `shot-${shot.id}`)}
className="shrink-0 p-0.5 rounded opacity-0 group-hover/shotcode:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy shot code"
>
{copiedId === `shot-${shot.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
{shot.tasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey =
ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
onClick={handleReviewClick}
className={cn(
'flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-2 p-3 sm:p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<div className="min-w-0 w-full sm:w-auto sm:flex-1">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white min-w-0 break-words">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
{ver.createdAt && (
<span className="text-xs text-zinc-600 hidden sm:block">
{formatDate(ver.createdAt)}
</span>
)}
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors ml-auto sm:ml-0" />
</Link>
);
})}
</div>
</div>
);
return (
<div className="min-h-screen bg-zinc-950 text-white">
<div ref={pageRef} className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 bg-zinc-900">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
{/* Logo */}
@@ -454,7 +623,7 @@ export default function ClientPortalPage({
</div>
<div className={montserrat.className}>
<span className="block text-2xl font-light text-white leading-none">
<span className="block text-2xl font-light text-white leading-none whitespace-nowrap">
TWO TALES
</span>
@@ -464,7 +633,7 @@ export default function ClientPortalPage({
</div>
</div>
{sessionLabel && (
<span className="text-sm text-zinc-400">{sessionLabel}</span>
<span className="ml-4 min-w-0 truncate text-right text-sm text-zinc-400" title={sessionLabel}>{sessionLabel}</span>
)}
</div>
</header>
@@ -620,6 +789,20 @@ export default function ClientPortalPage({
</div>
<div className="flex items-center gap-2">
{hasScenes && sortMode === 'episode' && (
<button
onClick={() => setGroupByScene((prev) => !prev)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
groupByScene
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
<Layers className="h-3 w-3" />
{groupByScene ? 'Grouped by Scene' : 'Group by Scene'}
</button>
)}
<button
onClick={() =>
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
@@ -687,7 +870,7 @@ export default function ClientPortalPage({
/>
</div>
)}
<div className="flex items-center gap-1.5 group/itemlabel">
<div className="flex min-w-0 items-center gap-1.5 group/itemlabel">
<p className="font-mono text-xs text-zinc-500">
{item.label}
{item.description && (
@@ -698,7 +881,7 @@ export default function ClientPortalPage({
</p>
<button
onClick={() => copyToClipboard(item.label, `label-${task.id}`)}
className="shrink-0 p-0.5 rounded opacity-0 group-hover/itemlabel:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover/itemlabel:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy name"
>
{copiedId === `label-${task.id}` ? (
@@ -709,7 +892,7 @@ export default function ClientPortalPage({
</button>
</div>
{ver?.createdAt && (
<p className="ml-auto text-xs text-zinc-600">
<p className="ml-auto shrink-0 whitespace-nowrap text-xs text-zinc-600">
{formatDate(ver.createdAt)}
</p>
)}
@@ -718,19 +901,19 @@ export default function ClientPortalPage({
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
onClick={handleReviewClick}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-2 p-3 sm:p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<div className="flex-1 min-w-0">
<div className="min-w-0 w-full sm:w-auto sm:flex-1">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white">
<p className="text-sm font-medium text-white min-w-0 break-words">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
@@ -759,7 +942,7 @@ export default function ClientPortalPage({
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors ml-auto sm:ml-0" />
</Link>
</div>
);
@@ -780,15 +963,6 @@ export default function ClientPortalPage({
const isCollapsed = collapsedEpisodes.has(episode);
const epTasks = filteredEpShots.flatMap((s) => s.tasks);
const epApproved = epTasks.filter(
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
).length;
const epChanges = epTasks.filter((t) =>
['REJECTED', 'NEEDS_CHANGES'].includes(
getLatestVersion(t)?.approvalStatus ?? '',
),
).length;
const epPending = epTasks.length - epApproved - epChanges;
return (
<div key={episode} className="rounded-xl border border-zinc-800 overflow-hidden">
@@ -812,137 +986,48 @@ export default function ClientPortalPage({
{filteredEpShots.length === 1 ? 'shot' : 'shots'}
</span>
</div>
{/* Per-episode progress pills */}
<div className="flex items-center gap-2 shrink-0">
{epApproved > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
<CheckCircle2 className="h-3 w-3" />
{epApproved}
</span>
)}
{epChanges > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
{epChanges}
</span>
)}
{epPending > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
<Clock className="h-3 w-3" />
{epPending}
</span>
)}
</div>
{statusPills(epTasks)}
</button>
{/* Collapsible shot list */}
{!isCollapsed && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40">
{filteredEpShots.map((shot) => (
<div key={shot.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-3">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="flex items-center gap-1.5 group/shotcode">
<p className="font-mono text-sm font-semibold text-zinc-300">
{shot.shotCode}
{shot.description && (
<span className="font-sans font-normal text-zinc-500 ml-2">
{shot.description}
</span>
)}
</p>
<button
onClick={() => copyToClipboard(shot.shotCode, `shot-${shot.id}`)}
className="shrink-0 p-0.5 rounded opacity-0 group-hover/shotcode:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy shot code"
>
{copiedId === `shot-${shot.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
{shot.tasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey =
ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
onClick={handleReviewClick}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
{showScenes
? groupShotsByScene(filteredEpShots).map(([scene, sceneShots]) => {
const key = sceneKey(episode, scene);
const sceneCollapsed = collapsedEpisodes.has(key);
return (
<div key={key}>
<button
className="w-full flex items-center gap-3 px-5 py-2.5 bg-zinc-900/60 hover:bg-zinc-800/60 transition-colors text-left"
onClick={() => toggleEpisode(key)}
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-zinc-500 shrink-0 transition-transform duration-200',
sceneCollapsed && '-rotate-90',
)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
{ver.createdAt && (
<span className="text-xs text-zinc-600 hidden sm:block">
{formatDate(ver.createdAt)}
</span>
)}
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
<span className="text-xs font-medium uppercase tracking-wider text-zinc-400">
{scene === NO_SCENE ? 'No scene' : `Scene ${scene}`}
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
))}
<span className="ml-3 text-xs text-zinc-600">
{sceneShots.length}{' '}
{sceneShots.length === 1 ? 'shot' : 'shots'}
</span>
</div>
{statusPills(sceneShots.flatMap((s) => s.tasks))}
</button>
{!sceneCollapsed && (
<div className="divide-y divide-zinc-800/60 border-t border-zinc-800/60">
{sceneShots.map(renderShot)}
</div>
)}
</div>
);
})
: filteredEpShots.map(renderShot)}
</div>
)}
</div>
@@ -1007,7 +1092,7 @@ export default function ClientPortalPage({
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
+50 -12
View File
@@ -28,6 +28,8 @@ import {
Clock,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Copy,
Check,
Download,
@@ -36,6 +38,7 @@ import {
} from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player";
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
import { HandleBadge } from "@/components/player/HandleBadge";
interface Comment {
id: string;
@@ -43,6 +46,8 @@ interface Comment {
timestamp: number;
text: string;
isResolved: boolean;
/** "WHATSAPP" = client feedback sent by message and logged by the studio */
source?: string | null;
createdAt: string;
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
canEdit?: boolean;
@@ -56,6 +61,14 @@ interface Version {
fileUrl: string;
fps: number;
duration: number | null;
frameCount?: number | null;
/** High quality review MP4 — the player's quality switch. */
hqFileUrl?: string | null;
/** Where the High Res download comes from: this version's ProRes, or the shot's legacy file. */
highResSource?: "version" | "shot" | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
approvalStatus: string;
notes: string | null;
// Top-level high-res flags (normalised from either shot or task.shot)
@@ -274,10 +287,17 @@ export default function ClientReviewPage({
const handleDownloadHighRes = async () => {
const shotId = version?.highResShotId ?? version?.shot?.id;
if (!shotId || !token) return;
if (!version || !token) return;
const endpoint =
version.highResSource === "version"
? `/api/client/${token}/versions/${version.id}/highres/download`
: shotId
? `/api/client/${token}/shots/${shotId}/highres/download`
: null;
if (!endpoint) return;
setDownloadingHighRes(true);
try {
const res = await fetch(`/api/client/${token}/shots/${shotId}/highres/download`);
const res = await fetch(endpoint);
if (!res.ok) throw new Error("Could not get download link");
const { url } = await res.json();
// Open the presigned URL — the Content-Disposition header forces a download
@@ -357,9 +377,11 @@ export default function ClientReviewPage({
return (
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
{/* Header */}
<header className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
{/* Phones: back + shot + prev/next on the first row, status and decision
buttons on a second full-width row. From md: the three-column grid. */}
<header className="flex flex-wrap items-center gap-2 md:grid md:grid-cols-[1fr_auto_1fr] px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
{/* Left: back + shot path */}
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1 md:flex-none">
<Link
href={`/client/${token}`}
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm shrink-0"
@@ -387,7 +409,7 @@ export default function ClientReviewPage({
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
<span className="text-zinc-600">/</span>
{contextCode && (
<span className="font-mono font-semibold">{contextCode}</span>
<span className="font-mono font-semibold truncate">{contextCode}</span>
)}
{taskName && (
<>
@@ -417,11 +439,11 @@ export default function ClientReviewPage({
</div>
</div>
{/* Center: status badge + decision buttons */}
<div className="flex items-center gap-2 shrink-0">
{/* Center: status badge + decision buttons (their own row on phones) */}
<div className="order-last md:order-none flex w-full md:w-auto items-center gap-2 shrink-0">
<span
className={cn(
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1 shrink-0",
"text-xs px-2.5 py-1 rounded-full border inline-flex items-center gap-1 shrink-0 mr-auto md:mr-0",
approvalStyle
)}
>
@@ -509,6 +531,11 @@ export default function ClientReviewPage({
comments={comments as any}
versionId={version.id}
onAddComment={handlePlayerAddComment}
handles={version}
hqVideoUrl={version.hqFileUrl}
// The director watches the cut as cut; handles are still one
// toggle away, and flagged whenever the playhead is in one.
defaultEditRangeOnly
saveAnnotationFn={async (data) => {
const res = await fetch(`/api/client/${token}/annotation`, {
method: "POST",
@@ -542,10 +569,17 @@ export default function ClientReviewPage({
className="flex items-center justify-center h-10 w-full border-b border-zinc-800 hover:bg-zinc-800/60 transition-colors shrink-0"
title={commentsOpen ? "Collapse notes" : "Expand notes"}
>
{/* Below the video on phones, so down/up there and right/left from md. */}
{commentsOpen ? (
<ChevronRight className="h-4 w-4 text-zinc-400" />
<>
<ChevronDown className="h-4 w-4 text-zinc-400 md:hidden" />
<ChevronRight className="h-4 w-4 text-zinc-400 hidden md:block" />
</>
) : (
<ChevronLeft className="h-4 w-4 text-zinc-400" />
<>
<ChevronUp className="h-4 w-4 text-zinc-400 md:hidden" />
<ChevronLeft className="h-4 w-4 text-zinc-400 hidden md:block" />
</>
)}
{!commentsOpen && (
<MessageSquare className="h-4 w-4 text-zinc-400 mt-1" />
@@ -628,11 +662,14 @@ export default function ClientReviewPage({
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 font-medium">
{comment.author.name ?? comment.author.email.split("@")[0]}
{comment.source === "WHATSAPP"
? "Client feedback"
: comment.author.name ?? comment.author.email.split("@")[0]}
</span>
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
</span>
<HandleBadge frame={comment.frameNumber} />
</div>
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
{comment.canEdit && (
@@ -681,8 +718,9 @@ export default function ClientReviewPage({
{commentsOpen && commentFrame !== null ? (
<div className="border-t border-zinc-800 p-3 space-y-2">
<p className="text-xs text-zinc-500 font-mono">
<p className="flex items-center gap-2 text-xs text-zinc-500 font-mono">
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
<HandleBadge frame={commentFrame} />
</p>
<Textarea
placeholder="Add your note..."
+102 -15
View File
@@ -28,17 +28,20 @@ import { submitApproval } from "@/actions/approvals";
import { internallyApproveShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
import { VersionHandlesButton } from "@/components/versions/VersionHandlesButton";
import {
CheckCircle2,
XCircle,
AlertCircle,
ChevronDown,
ChevronUp,
ChevronRight,
ChevronLeft,
ArrowLeft,
Film,
ShieldCheck,
MessageSquare,
Download,
} from "lucide-react";
interface ReviewPageClientProps {
@@ -49,12 +52,21 @@ interface ReviewPageClientProps {
fps: number;
duration: number | null;
frameCount: number | null;
handleHead: number | null;
handleTail: number | null;
pictureStartFrame: number | null;
hqFileUrl: string | null;
highResKey: string | null;
highResFilename: string | null;
highResSize: bigint | number | null;
approvalStatus: string;
notes: string | null;
isClientVisible: boolean;
shot?: {
id: string;
shotCode: string;
shotApprovalStatus: string;
sharedWithClient: boolean;
project: { id: string; name: string; code: string };
versions: {
id: string;
@@ -72,7 +84,7 @@ interface ReviewPageClientProps {
id: string;
title: string;
project: { id: string; name: string; code: string };
shot?: { id: string; shotCode: string } | null;
shot?: { id: string; shotCode: string; shotApprovalStatus: string; sharedWithClient: boolean } | null;
asset?: { id: string; assetCode: string; name: string } | null;
versions: {
id: string;
@@ -96,6 +108,11 @@ interface ReviewPageClientProps {
userRole?: string;
}
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
return `${Math.max(1, Math.round(bytes / 1e6))} MB`;
}
const APPROVAL_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
@@ -124,11 +141,31 @@ export function ReviewPageClient({
const [isInternallyApproving, setIsInternallyApproving] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(true);
const [downloadingProRes, setDownloadingProRes] = useState(false);
const playerRef = useRef<ReviewPlayerRef>(null);
// The shot this version belongs to, directly or through its task. Approval
// and client sharing are shot-level: the client portal only lists shots
// marked sharedWithClient, so sharing has to go through the shot.
const reviewShot = version.shot ?? version.task?.shot ?? null;
const { toast } = useToast();
const router = useRouter();
const handleDownloadProRes = async () => {
setDownloadingProRes(true);
try {
const res = await fetch(`/api/versions/${version.id}/highres/download`);
if (!res.ok) throw new Error("Could not get download link");
const { url } = await res.json();
window.open(url, "_blank", "noopener,noreferrer");
} catch {
toast({ title: "Download failed", variant: "destructive" });
} finally {
setDownloadingProRes(false);
}
};
const handleAddComment = useCallback(() => {
// Pause player first
playerRef.current?.pause();
@@ -193,7 +230,7 @@ export function ReviewPageClient({
};
const handleInternalApprove = async () => {
const shotId = version.shot?.id;
const shotId = reviewShot?.id;
if (!shotId) return;
setIsInternallyApproving(true);
try {
@@ -231,8 +268,9 @@ export function ReviewPageClient({
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
{/* Top bar */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
{/* Top bar — phones: shot + version on the first row, actions wrap to a
second row. From md: one row. */}
<div className="flex flex-wrap md:flex-nowrap items-center gap-x-3 gap-y-2 px-4 py-2 border-b border-border bg-card shrink-0">
<Link
href={backHref}
className="text-muted-foreground hover:text-foreground transition-colors"
@@ -240,14 +278,14 @@ export function ReviewPageClient({
<ArrowLeft className="h-4 w-4" />
</Link>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<span className="text-xs text-muted-foreground hidden sm:block">
{project?.code}
</span>
<span className="text-muted-foreground">/</span>
{contextCode && (
<>
<span className="font-mono text-sm font-semibold">{contextCode}</span>
<span className="font-mono text-sm font-semibold truncate">{contextCode}</span>
<span className="text-muted-foreground">/</span>
</>
)}
@@ -274,6 +312,38 @@ export function ReviewPageClient({
</DropdownMenu>
</div>
{/* ProRes — this version's delivery MOV, uploaded by the render worker */}
{version.highResKey && (
<Button
size="sm"
variant="ghost"
className="h-7 shrink-0 gap-1 text-xs text-sky-400"
disabled={downloadingProRes}
onClick={handleDownloadProRes}
title={
(version.highResFilename ?? "ProRes") +
(version.highResSize ? ` · ${formatBytes(Number(version.highResSize))}` : "")
}
>
<Download className="h-3 w-3" />
<span className="hidden lg:inline">{downloadingProRes ? "Getting link…" : "ProRes"}</span>
</Button>
)}
{/* Handles — outside the breadcrumb so it never gets squeezed */}
{userRole !== "CLIENT" && (
<div className="shrink-0">
<VersionHandlesButton
key={version.id}
versionId={version.id}
handleHead={version.handleHead}
handleTail={version.handleTail}
pictureStartFrame={version.pictureStartFrame}
onSaved={() => router.refresh()}
/>
</div>
)}
{/* Approval status */}
<span
className={cn(
@@ -284,12 +354,17 @@ export function ReviewPageClient({
{version.approvalStatus.replace("_", " ")}
</span>
{/* Actions: their own full-width row on phones; md:contents puts them
back in the top bar's single row, exactly as before. */}
{(canShare || canApprove) && (
<div className="flex w-full items-center justify-end gap-3 md:contents">
{/* Share with Client */}
{canShare && (
<ShareWithClientButton
versionId={version.id}
isAlreadyShared={version.isClientVisible}
onShared={() => {/* router.refresh() handled inside component */}}
shot={reviewShot}
onShared={() => router.refresh()}
/>
)}
@@ -314,7 +389,8 @@ export function ReviewPageClient({
<XCircle className="h-3 w-3" />
<span className="hidden sm:inline">Reject</span>
</Button>
{version.shot?.id && (
{/* Same rule as the shot page: only while the shot awaits internal approval. */}
{reviewShot?.shotApprovalStatus === "PENDING" && (
<Button
size="sm"
className="h-7 text-xs gap-1 bg-sky-600 hover:bg-sky-500 text-white"
@@ -329,10 +405,12 @@ export function ReviewPageClient({
)}
</div>
)}
</div>
)}
</div>
{/* Main content — player + comments */}
<div className="flex flex-1 overflow-hidden">
{/* Main content — player + comments: stacked on phones, side by side from md */}
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
{/* Player — 70% */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
@@ -344,26 +422,35 @@ export function ReviewPageClient({
versionId={version.id}
onAddComment={handleAddComment}
onAnnotationSaved={handleAnnotationSaved}
handles={version}
hqVideoUrl={version.hqFileUrl}
/>
</div>
{/* Comment panel — collapsible */}
<div
className={cn(
"shrink-0 flex flex-col border-l border-border transition-all duration-200",
commentsOpen ? "w-80 xl:w-96" : "w-10"
"shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-border transition-all duration-200",
commentsOpen ? "h-[45%] md:h-auto md:w-80 xl:w-96" : "h-10 md:h-auto md:w-10"
)}
>
{/* Collapse toggle */}
{/* Collapse toggle — the panel sits below the video on phones, so
the arrow points down/up there and right/left from md. */}
<button
onClick={() => setCommentsOpen((o) => !o)}
className="flex items-center justify-center h-10 w-full border-b border-border hover:bg-muted/50 transition-colors shrink-0"
title={commentsOpen ? "Collapse comments" : "Expand comments"}
>
{commentsOpen ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<>
<ChevronDown className="h-4 w-4 text-muted-foreground md:hidden" />
<ChevronRight className="h-4 w-4 text-muted-foreground hidden md:block" />
</>
) : (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<>
<ChevronUp className="h-4 w-4 text-muted-foreground md:hidden" />
<ChevronLeft className="h-4 w-4 text-muted-foreground hidden md:block" />
</>
)}
{!commentsOpen && (
<MessageSquare className="h-4 w-4 text-muted-foreground mt-1" />
+1 -1
View File
@@ -45,7 +45,7 @@ async function getReviewData(versionId: string) {
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { id: true, shotCode: true } },
shot: { select: { id: true, shotCode: true, shotApprovalStatus: true, sharedWithClient: true } },
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
orderBy: { versionNumber: "desc" },
+93 -9
View File
@@ -6,11 +6,13 @@ import {
useState,
useCallback,
MouseEvent,
type RefObject,
} from "react";
import { useReviewStore } from "@/hooks/use-review-player";
import { saveAnnotation } from "@/actions/annotations";
import { addComment } from "@/actions/comments";
import { useToast } from "@/components/ui/use-toast";
import { letterboxRect } from "@/lib/aspect-overlays";
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
import { v4 as uuidv4 } from "uuid";
@@ -30,6 +32,8 @@ interface AddCommentData {
interface AnnotationCanvasProps {
versionId: string;
/** The video under the canvas — the canvas is sized to its displayed picture. */
videoRef: RefObject<HTMLVideoElement | null>;
frameNumber: number;
fps: number;
isAnnotating: boolean;
@@ -42,6 +46,20 @@ interface AnnotationCanvasProps {
addCommentFn?: (data: AddCommentData) => Promise<void>;
}
/**
* A shape as held in memory. Legacy (v1.0) shapes keep their points relative to
* the player box they were drawn in; `legacyBox` is that box, used to map them
* onto the picture once the video's intrinsic size is known.
*/
type LoadedShape = AnnotationShape & { legacyBox?: { w: number; h: number } };
function shapesOf(drawingData: AnnotationDrawingData | undefined): LoadedShape[] {
const shapes = drawingData?.shapes ?? [];
if (!drawingData || drawingData.version === "2.0") return shapes;
const legacyBox = { w: drawingData.canvasWidth, h: drawingData.canvasHeight };
return shapes.map((s) => ({ ...s, legacyBox }));
}
type DrawingState = {
isDrawing: boolean;
currentShape: AnnotationShape | null;
@@ -50,6 +68,7 @@ type DrawingState = {
export function AnnotationCanvas({
versionId,
videoRef,
frameNumber,
fps,
isAnnotating,
@@ -73,10 +92,10 @@ export function AnnotationCanvas({
// Per-frame saved shapes — persists across frame navigation so annotations
// remain visible without depending on a parent API refetch.
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, AnnotationShape[]>>(() => {
const map: Record<number, AnnotationShape[]> = {};
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, LoadedShape[]>>(() => {
const map: Record<number, LoadedShape[]> = {};
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
const shapes = a.drawingData?.shapes ?? [];
const shapes = shapesOf(a.drawingData);
map[a.frameNumber] = [...(map[a.frameNumber] ?? []), ...shapes];
}
return map;
@@ -114,10 +133,10 @@ export function AnnotationCanvas({
setSavedShapesByFrame((prev) => {
// Build map from server data
const serverMap: Record<number, AnnotationShape[]> = {};
const serverMap: Record<number, LoadedShape[]> = {};
const serverShapeIds = new Set<string>();
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
const shapes = a.drawingData?.shapes ?? [];
const shapes = shapesOf(a.drawingData);
serverMap[a.frameNumber] = [...(serverMap[a.frameNumber] ?? []), ...shapes];
for (const s of shapes) serverShapeIds.add(s.id);
}
@@ -145,6 +164,65 @@ export function AnnotationCanvas({
}, [existingAnnotations]);
// ── Picture rect ─────────────────────────────────────────────────────────
// The canvas is laid exactly over the displayed picture (not the letterboxed
// player box), so normalized points are fractions of the image itself and
// stay locked to it at any player size. Same measurement as AspectOverlay;
// clientWidth/Height ignore the zoom transform, which the canvas shares.
const [pictureRect, setPictureRect] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const measure = () => {
const rect = letterboxRect(
video.clientWidth,
video.clientHeight,
video.videoWidth,
video.videoHeight
);
setPictureRect((prev) =>
prev &&
rect &&
prev.left === rect.left &&
prev.top === rect.top &&
prev.width === rect.width &&
prev.height === rect.height
? prev
: rect
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(video);
video.addEventListener("loadedmetadata", measure);
return () => {
observer.disconnect();
video.removeEventListener("loadedmetadata", measure);
};
}, [videoRef]);
/** A shape's points as fractions of the picture (maps legacy box-relative ones). */
const pictureSpacePoints = useCallback(
(shape: LoadedShape): AnnotationPoint[] => {
const box = shape.legacyBox;
const video = videoRef.current;
if (!box || !video) return shape.points;
const r = letterboxRect(box.w, box.h, video.videoWidth, video.videoHeight);
if (!r) return shape.points; // no metadata yet — best effort
return shape.points.map((p) => ({
x: (p.x * box.w - r.left) / r.width,
y: (p.y * box.h - r.top) / r.height,
}));
},
[videoRef]
);
// ── Coordinate normalization ─────────────────────────────────────────────
const toNormalized = (e: MouseEvent): AnnotationPoint => {
const canvas = canvasRef.current!;
@@ -164,7 +242,7 @@ export function AnnotationCanvas({
const renderShape = useCallback(
(
ctx: CanvasRenderingContext2D,
shape: AnnotationShape,
shape: LoadedShape,
w: number,
h: number
) => {
@@ -176,7 +254,7 @@ export function AnnotationCanvas({
ctx.lineJoin = "round";
ctx.globalAlpha = 0.85;
const pts = shape.points.map((p) => toPixel(p, w, h));
const pts = pictureSpacePoints(shape).map((p) => toPixel(p, w, h));
switch (shape.tool) {
case "freehand": {
@@ -244,7 +322,7 @@ export function AnnotationCanvas({
}
ctx.globalAlpha = 1;
},
[]
[pictureSpacePoints]
);
const redraw = useCallback(() => {
@@ -352,7 +430,7 @@ export function AnnotationCanvas({
shapes: [finishedShape],
canvasWidth: canvas?.offsetWidth ?? 1920,
canvasHeight: canvas?.offsetHeight ?? 1080,
version: "1.0",
version: "2.0",
};
const doSaveAnnotation = saveAnnotationFn
@@ -447,6 +525,12 @@ export function AnnotationCanvas({
ref={canvasRef}
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
style={{
...(pictureRect && {
left: pictureRect.left,
top: pictureRect.top,
width: pictureRect.width,
height: pictureRect.height,
}),
cursor: isAnnotating ? "crosshair" : "default",
touchAction: isAnnotating ? "none" : "auto",
}}
@@ -21,11 +21,14 @@ import {
ArrowRight,
RotateCcw,
ImageIcon,
Film,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useToast } from "@/components/ui/use-toast";
import { PLATE_EXTS, plateContentType } from "@/lib/batch-upload-parser";
import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route";
import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/route";
import type { PlatePreviewItem } from "@/app/api/batch-upload/plates/route";
interface Project {
id: string;
@@ -40,6 +43,12 @@ interface UploadState {
error?: string;
}
/** Footage plates are large, so they also carry per-file upload progress. */
interface PlateUploadState extends UploadState {
/** 0–1, only meaningful while status is "uploading" */
progress?: number;
}
interface BatchUploadClientProps {
projects: Project[];
}
@@ -76,6 +85,25 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
setThumbUploadComplete(false);
};
// ── Footage plate upload state ─────────────────────────────────────────────
const plateInputRef = useRef<HTMLInputElement>(null);
const [plateFiles, setPlateFiles] = useState<File[]>([]);
const [plateIsDragging, setPlateIsDragging] = useState(false);
const [platePreview, setPlatePreview] = useState<PlatePreviewItem[] | null>(null);
const [plateLoadingPreview, setPlateLoadingPreview] = useState(false);
const [plateUploadStates, setPlateUploadStates] = useState<Record<string, PlateUploadState>>({});
const [plateIsUploading, setPlateIsUploading] = useState(false);
const [plateUploadComplete, setPlateUploadComplete] = useState(false);
const acceptPlate = (f: File) => PLATE_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? "");
const resetPlates = () => {
setPlateFiles([]);
setPlatePreview(null);
setPlateUploadStates({});
setPlateUploadComplete(false);
};
const reset = () => {
setFiles([]);
setPreview(null);
@@ -324,6 +352,136 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
setThumbUploadComplete(true);
};
// ── Footage plate handlers ─────────────────────────────────────────────────
const handlePlateDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setPlateIsDragging(false);
const dropped = Array.from(e.dataTransfer.files).filter(acceptPlate);
if (dropped.length > 0) {
setPlateFiles(dropped);
setPlatePreview(null);
setPlateUploadStates({});
setPlateUploadComplete(false);
} else {
toast({
title: "No supported files",
description: "Only .mov, .mp4, .mxf, .avi, .mkv and .webm files are accepted.",
});
}
}, [toast]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePlateInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files ?? []).filter(acceptPlate);
if (selected.length > 0) {
setPlateFiles(selected);
setPlatePreview(null);
setPlateUploadStates({});
setPlateUploadComplete(false);
}
e.target.value = "";
};
const fetchPlatePreview = async () => {
if (!projectId || plateFiles.length === 0) return;
setPlateLoadingPreview(true);
try {
const res = await fetch("/api/batch-upload/plates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, fileNames: plateFiles.map((f) => f.name) }),
});
if (!res.ok) throw new Error("Preview failed");
const data = await res.json();
setPlatePreview(data.items);
const states: Record<string, PlateUploadState> = {};
for (const item of data.items as PlatePreviewItem[]) states[item.fileName] = { status: "pending" };
setPlateUploadStates(states);
} catch {
toast({ title: "Preview failed", variant: "destructive" });
} finally {
setPlateLoadingPreview(false);
}
};
const startPlateUpload = async () => {
if (!platePreview || !projectId) return;
setPlateIsUploading(true);
const matched = platePreview.filter((i) => i.status === "match");
for (const item of matched) {
const file = plateFiles.find((f) => f.name === item.fileName);
if (!file) continue;
setPlateUploadStates((prev) => ({
...prev,
[item.fileName]: { status: "uploading", progress: 0 },
}));
try {
// Step 1 — presigned PUT URL. Plates are multi-GB, so the bytes go
// straight to Hetzner and never touch this server or the proxy.
const presignRes = await fetch("/api/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
contentType: plateContentType(file.name),
folder: "videos",
}),
});
if (!presignRes.ok) {
const d = await presignRes.json().catch(() => ({ error: "Failed to get upload URL" }));
throw new Error((d as { error?: string }).error ?? "Failed to get upload URL");
}
const { presignedUrl, key } = (await presignRes.json()) as {
presignedUrl: string;
key: string;
};
// Step 2 — PUT directly to storage, tracking progress via XHR.
await putWithProgress(presignedUrl, file, (fraction) => {
setPlateUploadStates((prev) => ({
...prev,
[item.fileName]: { status: "uploading", progress: fraction },
}));
});
// Step 3 — commit the plate record against the matched shot.
const commitRes = await fetch("/api/batch-upload/plates/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
shotId: item.shotId,
key,
fileName: file.name,
fileSize: file.size,
label: item.label,
}),
});
if (!commitRes.ok) {
const d = await commitRes.json().catch(() => ({ error: "Failed to save plate" }));
throw new Error((d as { error?: string }).error ?? "Failed to save plate");
}
setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } }));
} catch (err) {
setPlateUploadStates((prev) => ({
...prev,
[item.fileName]: {
status: "error",
error: err instanceof Error ? err.message : "Upload failed",
},
}));
}
}
setPlateIsUploading(false);
setPlateUploadComplete(true);
};
// ── Derived counts ─────────────────────────────────────────────────────────
const uploadable = preview?.filter(
@@ -359,7 +517,11 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
value={projectId}
onValueChange={(v) => {
setProjectId(v);
// Every preview is resolved against a specific project, so none
// of them survive a project switch.
reset();
resetThumbs();
resetPlates();
}}
>
<SelectTrigger className="w-80 bg-zinc-800 border-zinc-700">
@@ -735,12 +897,260 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
</Card>
);
})()}
{/* ── Footage plate bulk upload ───────────────────────────────── */}
<div className="border-t border-zinc-800 pt-2">
<h2 className="text-base font-semibold text-white">Bulk Footage Plate Upload</h2>
<p className="text-zinc-400 text-sm mt-1">
Drop plate movies here to add them to a shot's Footage tab. The shot is taken
from the start of the filename and the remainder becomes the plate label —{" "}
<span className="text-zinc-300 font-mono text-xs">DH2_VFX_058_120_REF.mov</span>{" "}
becomes the plate <span className="text-zinc-300">REF</span> on shot{" "}
<span className="text-zinc-300 font-mono text-xs">DH2_VFX_058_120</span>.
</p>
</div>
{/* Plate drop zone */}
{!plateUploadComplete && (
<Card>
<CardContent className="pt-5">
<div
onDragOver={(e) => { e.preventDefault(); setPlateIsDragging(true); }}
onDragLeave={() => setPlateIsDragging(false)}
onDrop={handlePlateDrop}
onClick={() => plateInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none",
plateIsDragging
? "border-emerald-400 bg-emerald-400/5"
: "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30"
)}
>
<Film className="h-9 w-9 text-zinc-500 mx-auto mb-3" />
<p className="text-zinc-300 font-medium">Drop footage plates here</p>
<p className="text-zinc-500 text-sm mt-1">
MOV, MP4, MXF, AVI, MKV, WebM · or click to browse
</p>
{plateFiles.length > 0 && (
<p className="text-emerald-400 text-sm mt-3 font-medium">
{plateFiles.length} file{plateFiles.length !== 1 ? "s" : ""} selected
</p>
)}
<input
ref={plateInputRef}
type="file"
multiple
accept=".mov,.mp4,.m4v,.mxf,.avi,.mkv,.webm,video/*"
className="hidden"
onChange={handlePlateInput}
/>
</div>
{plateFiles.length > 0 && !platePreview && (
<div className="mt-4 flex items-center gap-3">
<Button onClick={fetchPlatePreview} disabled={plateLoadingPreview}>
{plateLoadingPreview ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Loading preview…</>
) : (
<>Preview<ChevronRight className="h-4 w-4 ml-2" /></>
)}
</Button>
<Button variant="ghost" onClick={resetPlates} disabled={plateLoadingPreview}>Clear</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Plate preview table */}
{platePreview && !plateUploadComplete && (() => {
const matched = platePreview.filter((i) => i.status === "match");
const skippedPlates = platePreview.filter((i) => i.status !== "match");
const dupes = platePreview.filter((i) => i.status === "duplicate");
const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length;
const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length;
const plateDone = plateSuccess + plateErrors;
const plateProgress = matched.length > 0 ? (plateDone / matched.length) * 100 : 0;
return (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base font-medium text-zinc-200">
Footage plate preview —{" "}
<span className="text-zinc-400 font-normal">
{matched.length} matched · {skippedPlates.length} skipped
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0 mt-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">File</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Matched Shot</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Plate label</th>
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-32">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{platePreview.map((item) => {
const state = plateUploadStates[item.fileName];
return (
<tr key={item.fileName} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<FileVideo className="h-4 w-4 text-zinc-500 shrink-0" />
<span className="text-zinc-200 font-mono text-xs truncate max-w-[240px]">{item.fileName}</span>
</div>
</td>
<td className="px-6 py-3">
{item.shotCode ? (
<span className="font-mono text-xs text-zinc-300">
{item.shotCode}
{item.existingPlateCount > 0 && (
<span className="text-zinc-600 ml-1.5 font-sans">
({item.existingPlateCount} plate{item.existingPlateCount !== 1 ? "s" : ""})
</span>
)}
</span>
) : (
<span className="text-xs text-zinc-600 italic">
{item.status === "unsupported" ? "unsupported file" : "no match"}
</span>
)}
</td>
<td className="px-6 py-3">
{item.status === "match" ? (
<span className="text-zinc-300 text-xs">{item.label}</span>
) : (
<span className="text-zinc-700 text-xs">—</span>
)}
</td>
<td className="px-4 py-3">
{item.status === "duplicate" ? (
<span className="flex items-center gap-1 text-xs text-zinc-500" title="This shot already has a plate with this filename">
<XCircle className="h-3.5 w-3.5" />already added
</span>
) : item.status !== "match" ? (
<span className="flex items-center gap-1 text-xs text-zinc-500"><XCircle className="h-3.5 w-3.5" />skip</span>
) : !state || state.status === "pending" ? (
<span className="flex items-center gap-1 text-xs text-emerald-400"><ArrowRight className="h-3.5 w-3.5" />add plate</span>
) : state.status === "uploading" ? (
<span className="flex items-center gap-1 text-xs text-amber-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{Math.round((state.progress ?? 0) * 100)}%
</span>
) : state.status === "success" ? (
<span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle2 className="h-3.5 w-3.5" />done</span>
) : (
<span className="flex items-center gap-1 text-xs text-red-400" title={state.error}><XCircle className="h-3.5 w-3.5" />error</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{plateIsUploading && (
<div className="px-6 py-3 border-t border-zinc-800">
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
<span>Uploading {Math.min(plateDone + 1, matched.length)} of {matched.length}…</span>
<span>{Math.round(plateProgress)}%</span>
</div>
<Progress value={plateProgress} className="h-1.5" />
</div>
)}
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
<p className="text-zinc-500 text-xs">
{matched.length} plate{matched.length !== 1 ? "s" : ""} will be added
{skippedPlates.length > 0 && (
<span className="text-zinc-600">
{" "}· {skippedPlates.length} skipped
{dupes.length > 0 && ` (${dupes.length} already uploaded)`}
</span>
)}
</p>
<div className="flex gap-3 shrink-0">
<Button variant="ghost" size="sm" onClick={resetPlates} disabled={plateIsUploading}>Change files</Button>
<Button size="sm" onClick={startPlateUpload} disabled={plateIsUploading || matched.length === 0}>
{plateIsUploading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading…</>
) : (
`Upload ${matched.length} plate${matched.length !== 1 ? "s" : ""}`
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
})()}
{/* Plate done summary */}
{plateUploadComplete && (() => {
const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length;
const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length;
return (
<Card>
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div>
<p className="text-white font-semibold text-lg">Footage plates uploaded</p>
<p className="text-zinc-400 text-sm mt-1">
{plateSuccess} added{plateErrors > 0 && <span className="text-red-400"> · {plateErrors} failed</span>}
</p>
</div>
<Button variant="outline" onClick={resetPlates} className="mt-2">
<RotateCcw className="h-4 w-4 mr-2" />Upload more plates
</Button>
</CardContent>
</Card>
);
})()}
</>
)}
</div>
);
}
// ── Helpers ────────────────────────────────────────────────────────────────────
/**
* PUT a file to a presigned storage URL, reporting upload progress.
*
* `fetch` gives no upload progress, and footage plates are big enough that a
* silent multi-minute wait per file is unusable — hence XHR.
*/
function putWithProgress(
url: string,
file: File,
onProgress: (fraction: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", plateContentType(file.name));
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else if (xhr.status === 403)
reject(new Error("Storage upload failed — check Hetzner CORS / bucket policy"));
else reject(new Error(`Storage upload failed (HTTP ${xhr.status})`));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
xhr.send(file);
});
}
// ── Sub-components ─────────────────────────────────────────────────────────────
function PreviewRow({
+62 -1
View File
@@ -2,8 +2,15 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ExternalLink, Copy, Check, Trash2, Clock, Film } from "lucide-react";
import { ExternalLink, Copy, Check, Trash2, Clock, Film, RotateCcw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/components/ui/use-toast";
import { cn, formatRelativeDate } from "@/lib/utils";
@@ -28,9 +35,36 @@ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";
export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [reactivatingId, setReactivatingId] = useState<string | null>(null);
const { toast } = useToast();
const router = useRouter();
// Same token, new expiry — the URL the client already has starts working again.
const handleReactivate = async (id: string, days: number) => {
setReactivatingId(id);
try {
const res = await fetch(`/api/review-sessions?id=${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiresInDays: days }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error);
}
toast({ title: "Review link reactivated", description: `Valid for ${days} more days — same URL.` });
router.refresh();
} catch (e) {
toast({
title: "Failed to reactivate link",
description: e instanceof Error && e.message ? e.message : undefined,
variant: "destructive",
});
} finally {
setReactivatingId(null);
}
};
const handleCopy = async (token: string, id: string) => {
const url = `${APP_URL}/client/${token}`;
await navigator.clipboard.writeText(url);
@@ -122,6 +156,33 @@ export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
<span className="text-xs text-zinc-500 mr-2">
{session.accessCount} view{session.accessCount !== 1 ? "s" : ""}
</span>
{expired && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 gap-1.5 text-emerald-400 hover:bg-emerald-500/10 border-emerald-500/30"
disabled={reactivatingId === session.id}
>
{reactivatingId === session.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
Reactivate
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="text-xs text-zinc-500">Valid for</DropdownMenuLabel>
{[7, 14, 30, 60, 90].map((d) => (
<DropdownMenuItem key={d} onClick={() => handleReactivate(session.id, d)}>
{d} days
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
size="sm"
variant="outline"
+14 -1
View File
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate } from "@/lib/utils";
import { frameToTimecode } from "@/lib/frame-utils";
import { HandleBadge } from "@/components/player/HandleBadge";
import {
CheckCircle2,
Circle,
@@ -151,6 +152,7 @@ export function CommentPanel({
<span className="text-xs text-muted-foreground">
{frameToTimecode(activeCommentFrame, fps)}
</span>
<HandleBadge frame={activeCommentFrame} />
</div>
<Textarea
ref={inputRef}
@@ -340,14 +342,25 @@ function CommentThread({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
{comment.source === "WHATSAPP"
? "Client feedback"
: comment.author?.name ?? comment.author?.email ?? "Deleted User"}
</span>
{comment.source === "WHATSAPP" && (
<span
className="rounded border border-indigo-500/20 bg-indigo-500/10 px-1 py-px text-[10px] text-indigo-400"
title={`Pasted from WhatsApp by ${comment.author?.name ?? comment.author?.email ?? "someone"}`}
>
via {comment.author?.name?.split(" ")[0] ?? "WhatsApp"}
</span>
)}
<button
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
onClick={() => onJumpToFrame(comment.frameNumber)}
>
F{String(comment.frameNumber).padStart(4, "0")}
</button>
<HandleBadge frame={comment.frameNumber} />
<span className="text-xs text-muted-foreground ml-auto">
{formatRelativeDate(comment.createdAt)}
</span>
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { format, formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import type { AwaitingDelivery as AwaitingDeliveryRow } from "@/lib/dashboard/ops";
import type { DeliveryRequestView } from "@/lib/render-pipeline/delivery-requests";
import { DELIVERABLE_LABEL, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
const STAGE_LABEL: Record<AwaitingDeliveryRow["stage"], string> = {
AWAITING_UPLOAD: "Not uploaded",
AWAITING_SHARE: "Uploaded — link not shared",
};
const SEVERITY_STYLES: Record<AwaitingDeliveryRow["severity"], string> = {
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
critical: "bg-red-500/15 text-red-400 border-red-500/30",
};
const ROW_CLASS =
"flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0 hover:bg-zinc-900/40";
const PILL_CLASS = "shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap";
/**
* Everything a client is owed: what they asked for that has not fully gone
* out, and built packages they do not have yet. A row only leaves this list
* when the upload location and who the link went to are on record (or a
* request is cancelled with a reason) — never because time passed or someone
* remembers doing it.
*/
export function AwaitingDelivery({
requests,
packages,
}: {
requests: DeliveryRequestView[];
packages: AwaitingDeliveryRow[];
}) {
if (requests.length === 0 && packages.length === 0) {
return (
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-4 text-sm text-zinc-500">
Nothing is owed to a client.
</div>
);
}
return (
<div className="rounded-lg border border-amber-500/30 bg-card overflow-hidden">
{requests.map((r) => (
<Link key={r.id} href="/pipeline/deliveries" className={ROW_CLASS}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white">
<span className="font-medium">Request #{r.requestNumber}</span>{" "}
<span className="text-zinc-300">
{DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable}
{r.episode ? ` for ${r.episode}` : ""}
</span>{" "}
<span className="text-zinc-500">{r.projectCode}</span>
</div>
<div className="truncate text-xs text-zinc-500">
asked by {r.requestedBy} {formatDistanceToNowStrict(r.createdAt)} ago
{r.dueDate ? ` · due ${format(r.dueDate, "d MMM")}` : ""}
</div>
</div>
<span className={cn(PILL_CLASS, SEVERITY_STYLES[r.severity])}>
{r.deliveredCount} of {r.lineCount} delivered
</span>
</Link>
))}
{packages.map((p) => (
<Link key={p.id} href="/pipeline/deliveries" className={ROW_CLASS}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white">
<span className="font-mono font-medium">#{p.packageNumber}</span>{" "}
<span className="text-zinc-300">{p.name}</span>{" "}
<span className="text-zinc-500">
{p.projectCode}
{p.episode ? ` · ${p.episode}` : ""}
</span>
</div>
<div className="truncate text-xs text-zinc-500">
{p.itemCount} shot{p.itemCount === 1 ? "" : "s"} · built {formatDistanceToNowStrict(p.builtAt)} ago
</div>
</div>
<span className={cn(PILL_CLASS, SEVERITY_STYLES[p.severity])}>{STAGE_LABEL[p.stage]}</span>
</Link>
))}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import { EXPORT_STATUS_STYLES, statusLabel } from "@/app/(dashboard)/pipeline/status-colors";
import type { BlockedExport } from "@/lib/dashboard/ops";
/**
* The exception list: exports that have stopped moving and need a person.
*
* Read by scanning down the left edge — the severity rail says which rows are
* dead in the water (a failed state, which nothing retries) versus merely
* overdue (still nominally in flight, or waiting on a QC pass). The reason
* column exists so the answer to "what happened" doesn't require opening
* anything.
*/
export function BlockedQueue({ blocked }: { blocked: BlockedExport[] }) {
const critical = blocked.filter((b) => b.severity === "critical").length;
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
{blocked.length === 0
? "Nothing blocked"
: `${blocked.length} export${blocked.length === 1 ? "" : "s"} stalled`}
{critical > 0 && (
<span className="ml-2 text-xs font-normal text-red-400">{critical} failed</span>
)}
</h3>
<Link href="/pipeline" className="text-xs text-zinc-400 hover:text-amber-400">
Open render queue →
</Link>
</div>
{blocked.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-zinc-500">
Every export is either moving or done.
</p>
) : (
<div className="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="w-[3px] p-0" />
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium whitespace-nowrap">Stuck</th>
<th className="px-4 py-2.5 font-medium">Why</th>
<th className="px-4 py-2.5 font-medium">Artist</th>
</tr>
</thead>
<tbody>
{blocked.map((b) => (
<tr key={b.exportId} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="w-[3px] p-0">
<div
className={cn(
"h-9 w-[3px]",
b.severity === "critical" ? "bg-red-500" : "bg-amber-500"
)}
/>
</td>
<td className="px-4 py-2.5">
<Link
href={`/pipeline/exports/${b.exportId}`}
className="font-mono text-[13px] font-medium text-white hover:text-amber-400"
>
{b.shotCode}{" "}
<span className="text-zinc-500">{b.versionString}</span>
</Link>
<div className="text-xs text-zinc-500">
{b.projectCode}
{b.episode ? ` · ep ${b.episode}` : ""}
</div>
</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[b.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
>
{statusLabel(b.status)}
</span>
</td>
<td
className={cn(
"px-4 py-2.5 tabular-nums whitespace-nowrap",
b.severity === "critical" ? "text-red-400" : "text-zinc-300"
)}
>
{formatDistanceToNowStrict(b.since)}
</td>
{/* The reason can be an aerender error several lines long;
it is clamped here and given in full on hover. */}
<td className="px-4 py-2.5 text-zinc-400 max-w-md" title={b.reason}>
<span className="line-clamp-2">{b.reason}</span>
</td>
<td className="px-4 py-2.5 text-zinc-300 whitespace-nowrap">
{b.artistName ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import Link from "next/link";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
import type { CountdownVerdict, EpisodeCountdown } from "@/lib/dashboard/ops";
/**
* Are we going to make the date? One card per episode, sorted by how soon.
*
* The bar is the episode's shots by where they actually are, and the row under
* it is the only comparison that matters: shots per day still needed against
* shots per day the studio has managed this week.
*/
const VERDICT: Record<CountdownVerdict, { rail: string; chip: string; label: string }> = {
overdue: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Overdue" },
behind: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Behind" },
stalled: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Nothing delivered this week" },
tight: { rail: "bg-amber-500", chip: "text-amber-400 bg-amber-500/10", label: "No slack" },
ahead: { rail: "bg-green-500", chip: "text-green-400 bg-green-500/10", label: "On track" },
complete: { rail: "bg-emerald-500", chip: "text-emerald-400 bg-emerald-500/10", label: "All delivered" },
};
/** Two decimals is false precision for a shot count; one is the real resolution. */
const perDay = (n: number) => n.toFixed(1);
export function DeliveryCountdown({ episodes }: { episodes: EpisodeCountdown[] }) {
if (episodes.length === 0) {
return (
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-8 text-center text-sm text-zinc-500">
No delivery dates set. Add them on a project&apos;s Episodes tab to see the countdown here.
</div>
);
}
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{episodes.map((ep) => {
const verdict = VERDICT[ep.verdict];
const pct = (n: number) => (ep.total === 0 ? 0 : (n / ep.total) * 100);
return (
<Link
key={`${ep.projectId}:${ep.episode ?? ""}`}
href={`/shot-status?projectId=${ep.projectId}`}
className="group relative flex flex-col gap-3.5 overflow-hidden rounded-lg border border-zinc-800 bg-card p-4 pl-[18px] transition-colors hover:border-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
>
{/* Severity rail — the card itself stays neutral so only the
episode in trouble pulls the eye. */}
<span className={cn("absolute inset-y-0 left-0 w-[3px]", verdict.rail)} aria-hidden="true" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-white">
{ep.projectCode}
{ep.episode && <span className="font-normal text-zinc-500"> · ep {ep.episode}</span>}
</div>
<div className="mt-0.5 text-xs text-zinc-400">
Due {format(ep.dueDate, "EEE d MMM yyyy")}
</div>
</div>
<div className="shrink-0 text-right">
<b className="block text-3xl font-bold leading-none tabular-nums text-white">
{Math.abs(ep.daysLeft)}
</b>
<small className="text-[11px] uppercase tracking-wide text-zinc-500">
{ep.daysLeft < 0 ? "days over" : "days left"}
</small>
</div>
</div>
<div>
<div
className="flex h-2 overflow-hidden rounded-full bg-zinc-800"
role="img"
aria-label={`${ep.delivered} of ${ep.total} shots delivered, ${ep.inQc} through QC, ${ep.inProgress} in progress`}
>
<i className="block h-full bg-emerald-500" style={{ width: `${pct(ep.delivered)}%` }} />
<i className="block h-full bg-amber-500" style={{ width: `${pct(ep.inQc)}%` }} />
<i className="block h-full bg-blue-500" style={{ width: `${pct(ep.inProgress)}%` }} />
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-400">
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-emerald-500" /> {ep.delivered} delivered
</span>
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-amber-500" /> {ep.inQc} in QC
</span>
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-blue-500" /> {ep.inProgress} in progress
</span>
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-zinc-700" /> {ep.notStarted} not started
</span>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-zinc-800/60 pt-3">
<div>
<div className="text-[11px] text-zinc-500">Needed / day</div>
<div className="font-mono text-[15px] tabular-nums text-white">
{perDay(ep.requiredPerDay)}
</div>
</div>
<div>
<div className="text-[11px] text-zinc-500">Last 7 days</div>
<div className="font-mono text-[15px] tabular-nums text-white">
{perDay(ep.actualPerDay)}
</div>
</div>
<span className={cn("rounded-full px-2.5 py-1 text-xs font-semibold", verdict.chip)}>
{ep.verdict === "behind" && ep.daysBehind
? `${ep.daysBehind} day${ep.daysBehind === 1 ? "" : "s"} behind`
: ep.verdict === "overdue" && ep.daysBehind
? `${ep.daysBehind} day${ep.daysBehind === 1 ? "" : "s"} over`
: verdict.label}
</span>
</div>
</Link>
);
})}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import { formatEta } from "@/app/(dashboard)/pipeline/status-colors";
import type { FarmStatus } from "@/lib/dashboard/ops";
/**
* Farm health: who is up, what is waiting, and who is running an old worker.
*
* The version row is not trivia — a worker's version is what decides whether
* it has a given fix, and a machine that quietly stayed behind keeps producing
* the bug everyone else has stopped seeing.
*/
export function FarmStrip({ farm }: { farm: FarmStatus }) {
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
{farm.online} of {farm.total} online
</h3>
<Link href="/pipeline/machines" className="text-xs text-zinc-400 hover:text-amber-400">
Machines →
</Link>
</div>
{farm.machines.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-zinc-500">
No machines have registered yet.
</p>
) : (
farm.machines.map((m) => (
<div
key={m.id}
className="flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0"
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
m.status === "ONLINE"
? "bg-green-500 ring-4 ring-green-500/15"
: m.status === "DISABLED"
? "bg-zinc-600"
: "bg-zinc-500"
)}
title={m.status}
/>
<span className="w-24 shrink-0 font-mono text-[12.5px] text-white">{m.name}</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-400">
{m.currentJob
? `${m.currentJob.shotCode ?? "job"} ${m.currentJob.versionString ?? ""} · ${Math.round(
(m.currentJob.progress ?? 0) * 100
)}% · ETA ${formatEta(m.currentJob.etaSeconds)}`
: m.status === "ONLINE"
? "Idle"
: m.lastSeenAt
? `Offline · last seen ${formatDistanceToNowStrict(new Date(m.lastSeenAt))} ago`
: "Never seen"}
</span>
<span
className={cn(
"shrink-0 font-mono text-[11.5px]",
farm.latestWorkerVersion && m.workerVersion !== farm.latestWorkerVersion
? "text-amber-400"
: "text-zinc-500"
)}
>
{m.workerVersion ?? "—"}
</span>
</div>
))
)}
<div className="grid grid-cols-3 border-t border-zinc-800/60">
<div className="border-r border-zinc-800/60 px-4 py-2.5">
<b className="block font-mono text-lg font-semibold leading-tight text-white">
{farm.queued.render}
</b>
<small className="text-xs text-zinc-500">renders queued</small>
</div>
<div className="border-r border-zinc-800/60 px-4 py-2.5">
<b className="block font-mono text-lg font-semibold leading-tight text-white">
{farm.queued.preview}
</b>
<small className="text-xs text-zinc-500">previews queued</small>
</div>
<div className="px-4 py-2.5">
<b className="block font-mono text-lg font-semibold leading-tight text-white">
{farm.oldestQueuedMs == null
? "—"
: formatDistanceToNowStrict(new Date(Date.now() - farm.oldestQueuedMs))}
</b>
<small className="text-xs text-zinc-500">oldest wait</small>
</div>
</div>
{farm.behindMachines.length > 0 && (
<div className="flex gap-2 border-t border-amber-500/20 bg-amber-500/[0.07] px-4 py-2.5 text-xs text-amber-400">
<span aria-hidden="true">▲</span>
<span>
{farm.behindMachines.length} machine
{farm.behindMachines.length === 1 ? " is" : "s are"} behind{" "}
<span className="font-mono">{farm.latestWorkerVersion}</span> —{" "}
{farm.behindMachines
.map((m) => `${m.name} (${m.workerVersion ?? "unknown"})`)
.join(", ")}
</span>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
/** The dashboard's way in to logging what a client just asked for. */
export function LogRequestButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Log request
</Button>
<LogRequestDialog open={open} onClose={() => setOpen(false)} onLogged={() => router.refresh()} />
</>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import type { QcQueueEntry } from "@/lib/dashboard/ops";
/**
* What is waiting on someone to watch the frames, oldest first.
*
* Everything upstream of QC is worked by the farm; this is the one stage that
* only drains when a person sits down to it, which is why depth here is worth
* a panel of its own rather than a filter on the queue page.
*/
export function QcQueue({ entries, staleHours }: { entries: QcQueueEntry[]; staleHours: number }) {
const oldest = entries[0];
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
{entries.length === 0
? "QC queue empty"
: `${entries.length} waiting on QC`}
{oldest && (
<span className="ml-2 text-xs font-normal text-zinc-500">
oldest {formatDistanceToNowStrict(oldest.since)}
</span>
)}
</h3>
<Link
href="/pipeline?status=READY_FOR_QC"
className="text-xs text-zinc-400 hover:text-amber-400"
>
Open QC →
</Link>
</div>
{entries.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-zinc-500">
Nothing is waiting on a QC pass.
</p>
) : (
<div className="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">Waiting</th>
<th className="px-4 py-2.5 font-medium">Frames</th>
<th className="px-4 py-2.5 font-medium">Submitted by</th>
</tr>
</thead>
<tbody>
{entries.map((e) => {
const stale = Date.now() - e.since.getTime() > staleHours * 3600_000;
return (
<tr key={e.exportId} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="px-4 py-2.5">
<Link
href={`/pipeline/exports/${e.exportId}`}
className="font-mono text-[13px] font-medium text-white hover:text-amber-400"
>
{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 font-mono text-[13px] text-zinc-300">
{e.versionString}
</td>
<td
className={cn(
"px-4 py-2.5 tabular-nums whitespace-nowrap",
stale ? "text-amber-400" : "text-zinc-300"
)}
>
{formatDistanceToNowStrict(e.since)}
</td>
<td className="px-4 py-2.5 font-mono text-[13px] tabular-nums text-zinc-400 whitespace-nowrap">
{e.frameStart}–{e.frameEnd}
</td>
<td className="px-4 py-2.5 text-zinc-300 whitespace-nowrap">
{e.submittedBy ?? "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import { DELIVERY_STATUS_STYLES } from "@/app/(dashboard)/pipeline/status-colors";
import type { DeliverySummary } from "@/lib/dashboard/ops";
/**
* The last few packages, and whether any of them went wrong.
*
* A failed build is otherwise silent — the exports stay READY_FOR_DELIVERY and
* nothing reaches the delivery volume, but no one finds out until the client
* asks where the shots are.
*/
export function RecentDeliveries({ deliveries }: { deliveries: DeliverySummary[] }) {
const problems = deliveries.filter((d) => d.status === "FAILED" || d.stuck).length;
return (
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
<h3 className="text-sm font-semibold text-white">
Recent packages
{problems > 0 && (
<span className="ml-2 text-xs font-normal text-red-400">
{problems} need{problems === 1 ? "s" : ""} attention
</span>
)}
</h3>
<Link href="/pipeline/deliveries" className="text-xs text-zinc-400 hover:text-amber-400">
All deliveries →
</Link>
</div>
{deliveries.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-zinc-500">No packages built yet.</p>
) : (
deliveries.map((d) => (
<Link
key={d.id}
href="/pipeline/deliveries"
className="flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0 hover:bg-zinc-900/40"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white">
<span className="font-mono font-medium">#{d.packageNumber}</span>{" "}
<span className="text-zinc-500">
{d.projectCode}
{d.episode ? ` · ${d.episode}` : ""}
{d.kind === "MOV_REVIEW" ? " · review" : ""}
</span>
</div>
<div className="truncate text-xs text-zinc-500" title={d.errorMessage ?? undefined}>
{d.status === "FAILED" && d.errorMessage
? d.errorMessage
: `${d.itemCount} shot${d.itemCount === 1 ? "" : "s"} · ${d.atLabel} ${formatDistanceToNowStrict(d.at)} ago`}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
// A build that never finished reads as a problem, not as work
// still in progress.
d.stuck
? "bg-red-500/15 text-red-400 border-red-500/30"
: DELIVERY_STATUS_STYLES[d.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
>
{d.stuck ? "STUCK" : d.status}
</span>
</Link>
))
)}
</div>
);
}
+68 -50
View File
@@ -1,12 +1,11 @@
"use client";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
Clock,
AlertCircle,
CheckCircle2,
XCircle,
AlertTriangle,
ClipboardCheck,
PackageCheck,
Eye,
TrendingUp,
} from "lucide-react";
import type { DashboardStats } from "@/types";
@@ -15,47 +14,54 @@ interface StatsCardsProps {
stats: DashboardStats;
}
/**
* Five numbers that can go down. Each one links to the page that lists what it
* counts — a figure with no way through to the rows behind it is wallpaper.
*/
export function StatsCards({ stats }: StatsCardsProps) {
const cards = [
{
label: "Awaiting Review",
value: stats.awaitingReview,
icon: Clock,
color: "text-amber-400",
bg: "bg-amber-500/10",
ring: "ring-amber-500/20",
},
{
label: "Needs Revisions",
value: stats.needsRevisions,
icon: AlertCircle,
color: "text-orange-400",
bg: "bg-orange-500/10",
ring: "ring-orange-500/20",
},
{
label: "Approved",
value: stats.approved,
icon: CheckCircle2,
color: "text-emerald-400",
bg: "bg-emerald-500/10",
ring: "ring-emerald-500/20",
},
{
label: "Overdue Tasks",
value: stats.tasksOverdue ?? stats.overdue,
icon: XCircle,
label: "Blocked",
value: stats.blocked,
href: "#blocked",
icon: AlertTriangle,
color: "text-red-400",
bg: "bg-red-500/10",
ring: "ring-red-500/20",
// The only card that earns emphasis when it is non-zero: everything else
// here is a queue depth, this one is work that has stopped.
emphasise: stats.blocked > 0,
},
{
label: "Active Projects",
value: stats.activeProjects,
icon: TrendingUp,
label: "In QC",
value: stats.inQc,
href: "/pipeline?status=READY_FOR_QC",
icon: ClipboardCheck,
color: "text-amber-400",
bg: "bg-amber-500/10",
},
{
label: "Ready to deliver",
value: stats.readyToDeliver,
href: "/pipeline/deliveries",
icon: PackageCheck,
color: "text-green-400",
bg: "bg-green-500/10",
},
{
label: "With the client",
value: stats.awaitingClientReview,
href: "/playlist",
icon: Eye,
color: "text-blue-400",
bg: "bg-blue-500/10",
ring: "ring-blue-500/20",
},
{
label: "Active projects",
value: stats.activeProjects,
href: "/projects",
icon: TrendingUp,
color: "text-zinc-400",
bg: "bg-zinc-500/10",
},
];
@@ -64,19 +70,31 @@ export function StatsCards({ stats }: StatsCardsProps) {
{cards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.label}>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className={cn("p-2 rounded-md", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
<Link key={card.label} href={card.href} className="group rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500">
<Card
className={cn(
"h-full transition-colors group-hover:border-zinc-700",
card.emphasise && "border-red-500/40"
)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className={cn("p-2 rounded-md", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
</div>
<p className="text-3xl font-bold text-white">
{card.value}
</p>
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
</CardContent>
</Card>
<p
className={cn(
"text-3xl font-bold tabular-nums",
card.emphasise ? "text-red-400" : "text-white"
)}
>
{card.value}
</p>
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
import type { WhoseCourt as WhoseCourtData } from "@/lib/dashboard/ops";
/**
* Which waits are the client's and which are ours.
*
* Two numbers rather than one, because they call for opposite responses: a
* version unanswered for a fortnight is a phone call, while notes nobody has
* picked up is a scheduling problem.
*/
export function WhoseCourt({ court }: { court: WhoseCourtData }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 rounded-lg border border-zinc-800 bg-card overflow-hidden">
<Side
title="Waiting on the client"
subtitle="Sent for review, no verdict yet"
side={court.client}
empty="Nothing is with the client."
className="border-b border-zinc-800/60 sm:border-b-0 sm:border-r"
/>
<Side
title="Waiting on us"
subtitle="Notes in, still unresolved"
side={court.us}
empty="No open notes."
/>
</div>
);
}
function Side({
title,
subtitle,
side,
empty,
className,
}: {
title: string;
subtitle: string;
side: WhoseCourtData["client"];
empty: string;
className?: string;
}) {
return (
<div className={`px-4 py-3 ${className ?? ""}`}>
<h4 className="text-xs font-semibold text-white">{title}</h4>
<p className="text-[11px] text-zinc-500">{subtitle}</p>
<b className="mt-2 mb-2 block font-mono text-2xl font-bold leading-none tabular-nums text-white">
{side.count}
</b>
{side.items.length === 0 ? (
<p className="text-xs text-zinc-500">{empty}</p>
) : (
<ul className="flex flex-col gap-1.5">
{side.items.map((item) => (
<li key={item.id} className="flex items-baseline justify-between gap-3 text-xs">
<Link
href={item.href}
className="truncate font-mono text-[11.5px] text-zinc-200 hover:text-amber-400"
>
{item.shotCode}
</Link>
<span className="shrink-0 tabular-nums text-zinc-500">{item.label}</span>
</li>
))}
</ul>
)}
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { DELIVERABLES, DELIVERABLE_LABEL, parseShotCodes, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
const SELECT_CLASS = "w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-200";
interface ProjectOption {
id: string;
name: string;
code: string;
}
/**
* Log what a client asked for. The ask arrives over chat, so this has to be
* quicker than deciding to do it later: a project, the shots, who asked.
*
* With `preset` the shots are already chosen (the shot-status selection);
* otherwise they come from an episode or a pasted list of codes.
*/
export function LogRequestDialog({
open,
onClose,
onLogged,
preset,
defaultProjectId,
}: {
open: boolean;
onClose: () => void;
onLogged: () => void;
preset?: { projectId: string; shotIds: string[] };
defaultProjectId?: string;
}) {
const { toast } = useToast();
const [projects, setProjects] = useState<ProjectOption[]>([]);
const [projectId, setProjectId] = useState("");
const [episodes, setEpisodes] = useState<{ episode: string; shotCount: number }[]>([]);
const [mode, setMode] = useState<"episode" | "codes">("episode");
const [episode, setEpisode] = useState("");
const [codes, setCodes] = useState("");
const [deliverable, setDeliverable] = useState<Deliverable>("EXR");
const [requestedBy, setRequestedBy] = useState("");
const [dueDate, setDueDate] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setProjectId(preset?.projectId ?? defaultProjectId ?? "");
setMode("episode");
setEpisode("");
setCodes("");
setDeliverable("EXR");
setRequestedBy("");
setDueDate("");
setNote("");
setError(null);
if (preset) return;
fetch("/api/projects")
.then((res) => (res.ok ? res.json() : { projects: [] }))
.then((body) => setProjects((body.projects ?? body) as ProjectOption[]))
.catch(() => setProjects([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
setEpisodes([]);
setEpisode("");
if (!open || preset || !projectId) return;
let stale = false;
fetch(`/api/delivery-requests/options?projectId=${encodeURIComponent(projectId)}`)
.then((res) => (res.ok ? res.json() : { episodes: [] }))
.then((body) => {
if (!stale) setEpisodes(body.episodes ?? []);
})
.catch(() => {});
return () => {
stale = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, projectId]);
const codeCount = parseShotCodes(codes).length;
const hasShots = preset ? preset.shotIds.length > 0 : mode === "episode" ? Boolean(episode) : codeCount > 0;
const canSave = Boolean(projectId) && hasShots && requestedBy.trim().length > 0 && !saving;
async function save() {
setSaving(true);
setError(null);
const res = await fetch("/api/delivery-requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
...(preset ? { shotIds: preset.shotIds } : mode === "episode" ? { episode } : { shotCodes: codes }),
deliverable,
requestedBy,
dueDate: dueDate || null,
note: note || null,
}),
});
const body = await res.json().catch(() => ({}));
setSaving(false);
if (!res.ok) {
// Shown in place rather than as a toast: an unmatched code has to be
// fixed in the text right above it.
setError(body.error ?? res.statusText);
return;
}
toast({
title: `Request #${body.request.requestNumber} logged`,
description: `${body.request.lineCount} shot${body.request.lineCount === 1 ? "" : "s"} awaiting delivery`,
});
onLogged();
onClose();
}
return (
<Dialog open={open} onOpenChange={(o) => !o && !saving && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Log delivery request</DialogTitle>
<DialogDescription>
What the client asked for. It stays under Awaiting delivery until every shot has been uploaded and the
link shared.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{preset ? (
<p className="text-sm text-zinc-300">
{preset.shotIds.length} selected shot{preset.shotIds.length === 1 ? "" : "s"}
</p>
) : (
<>
<div className="space-y-1.5">
<Label htmlFor="request-project">Project</Label>
<select
id="request-project"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className={SELECT_CLASS}
>
<option value="">Select a project…</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.code})
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-4 text-sm text-zinc-200">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={mode === "episode"} onChange={() => setMode("episode")} />
Whole episode
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={mode === "codes"} onChange={() => setMode("codes")} />
Paste shot codes
</label>
</div>
{mode === "episode" ? (
<select
aria-label="Episode"
value={episode}
onChange={(e) => setEpisode(e.target.value)}
disabled={!projectId}
className={SELECT_CLASS}
>
<option value="">{projectId ? "Select an episode…" : "Pick a project first"}</option>
{episodes.map((e) => (
<option key={e.episode} value={e.episode}>
{e.episode} — {e.shotCount} shots
</option>
))}
</select>
) : (
<div className="space-y-1">
<Textarea
aria-label="Shot codes"
value={codes}
onChange={(e) => setCodes(e.target.value)}
rows={4}
placeholder="Paste from the client's message — our codes or theirs, any separator"
className="font-mono text-xs"
/>
<p className="text-xs text-zinc-500">
{codeCount} code{codeCount === 1 ? "" : "s"} found
</p>
</div>
)}
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="request-deliverable">Wanted</Label>
<select
id="request-deliverable"
value={deliverable}
onChange={(e) => setDeliverable(e.target.value as Deliverable)}
className={SELECT_CLASS}
>
{DELIVERABLES.map((d) => (
<option key={d} value={d}>
{DELIVERABLE_LABEL[d]}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-due">Due (optional)</Label>
<Input
id="request-due"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="[color-scheme:dark]"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-by">Asked by</Label>
<Input
id="request-by"
placeholder="Who at the client asked"
value={requestedBy}
onChange={(e) => setRequestedBy(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-note">Note (optional)</Label>
<Input id="request-note" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
{error && <p className="text-sm text-red-400 break-words">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={save} disabled={!canSave}>
Log request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+13 -2
View File
@@ -12,8 +12,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { getInitials } from "@/lib/utils";
import { LogOut, User, Settings } from "lucide-react";
import { LogOut, User, Settings, Menu } from "lucide-react";
import Link from "next/link";
import { useMobileNav } from "@/components/layout/MobileNav";
interface HeaderProps {
title?: string;
@@ -23,11 +24,21 @@ interface HeaderProps {
export function Header({ title, breadcrumbs }: HeaderProps) {
const { data: session } = useSession();
const user = session?.user;
const { setOpen: setMobileNavOpen } = useMobileNav();
return (
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-6">
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-4 md:px-6">
{/* Left: Title / Breadcrumbs */}
<div className="flex items-center gap-2 min-w-0">
{/* Phones only: the sidebar is hidden below md and opens as a drawer. */}
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="md:hidden -ml-2 rounded-md p-2 text-zinc-400 hover:bg-zinc-800 hover:text-white"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
{breadcrumbs ? (
<nav className="flex items-center gap-1.5 text-sm">
{breadcrumbs.map((crumb, i) => (

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