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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Two faults in how a package's folder is named, both of which put unrelated
files in one directory on the delivery volume.
The default layout had no {showId}, so every show's episode 101 resolved to
{deliveryRoot}/101/{packageName}. The token existed and was populated — it
just never appeared in the path. With hardlinked packages that means two
shows delivering the same episode number on the same day write into the same
folder, and identically named shots overwrite each other. reviewDir had the
same hole, and is worse there: reviewNameFor scopes its lookup by projectId,
so a second show computes the bare base name and lands on the first show's
folder instead of suffixing past it.
createDeliveryPackage also never checked for an existing package of the same
name. packageNameTemplate is date-only and packageNumber is a per-project
counter that is not in the template, so a second delivery on the same day got
an identical name and rootPath and built on top of the first. MOV reviews
already did this correctly, so nextReviewName moves to deliveries.ts as
nextPackageName and both paths share it; the review-side name is kept as an
alias because it reads better at that call site.
Putting {showId} ahead of {episode} exposes an empty segment on non-episodic
shows, so paths go through tidyPath, which collapses it and leaves the UNC
root alone.
Historic packages are unaffected: buildDeliveryJobManifest reads rootPath and
configSnapshot off the record, so only new packages use the new layout. A
project with its own deliveryConfig.layout in the database still overrides
these defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
loadQCQueue called /api/ext/qc/queue with no limit, so it always got the
endpoint's default page of 50. The query orders oldest-first, so a project
with more than 50 exports in READY_FOR_QC showed only the oldest 50 with
nothing to say the list was short.
Ask for 200 — the server's hard ceiling in getQCQueue — and say so in the
status line when the count comes back at the cap, so a truncated queue is
visible rather than silent.
The diagnostics call in checkServerPathReachable stays on the default: it
only reads entries[0].
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app.scheduleTask outlives the panel instance that armed it. The repeating task
id was held in the pipelineState closure, so closing or reloading the panel took
the id with it and left nothing able to cancel the task: it kept firing every
15 s for the rest of the AE session, and every tick landing while a modal dialog
was open raised "Cannot run a script while a modal dialog is waiting for
response."
The id now lives in global scope beside the tick function that scheduleTask
already evaluates there, so the next panel instance can still reach it.
getPollTaskId and setPollTaskId wrap the access and treat anything that is not a
positive number as no task. Panel startup cancels whatever a previous instance
left armed before pointing the tick at itself, rather than letting the old task
fire into a fresh, empty state whose shot code went with the closure that armed
it, and panel.onClose stops the poll on the way out.
The export is unaffected either way: it runs on the farm, and the header picks
its status back up from the server on the next Refresh. Section 12.1 of
RenderPipeline2.md records that.
PANEL_VERSION goes to 2.6.1 per the documented rule, so Pipeline → Machines does
not show two workstations on one version behaving differently.
This change was authored in the working tree, not by Claude; it is committed
here alongside the status and client-portal work at the author's request, and
the message was written from reading the diff. It has not been exercised in a
live After Effects session as part of this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Notes left through a review link were write-once. A client who mistyped a
timecode or changed their mind had no way to correct it and had to add a second
note contradicting the first, which the studio then had to reconcile by hand.
PATCH and DELETE on comment/[commentId] cover both. Only the reviewer's own
notes are touchable: the studio's comments and those of every other review
session are read-only through a link, whatever the holder sends. Ownership is
re-checked server-side on every write, so the canEdit flag now returned by the
versions route only decides whether to offer the controls and grants nothing.
Refusals are 404 rather than 403 throughout, since a link holder has no business
learning which comment ids exist outside their own notes.
Only the text is editable. The frame a note is pinned to is what the studio
works from, so moving it would silently repoint feedback someone may already
have actioned; a note about a different frame is a new note. Deleting a drawing
note also removes its strokes, mirroring deleteComment in actions/comments.ts,
and the page reloads the player afterwards so the frame does not keep showing
marks that no longer exist. Delete goes through a confirmation that shows the
note and says plainly that it disappears for the studio too.
Worth knowing before this reaches a show: everyone holding the same review link
is the same author. A ReviewSession with no email resolves to a token-derived
guest user, so one link sent to three people gives all three one identity and
any of them can now edit or delete the others' notes. That model is not new,
which is why their comments already showed as one name, but edit and delete is
the first feature where it grants real power rather than muddling attribution.
Per-reviewer links with email set are the fix if that is not wanted, and that is
a change to how links are issued rather than to this code. The scope is
documented on reviewClientEmail so the next reader meets it before it surprises
them.
getOrCreateClientUser was copy-pasted into the comment and approve routes with
the fallback-email expression spelled out in three places. It is one set of
helpers in review-auth.ts now, including findReviewUser, a read-only variant so
that merely opening a review link does not conjure a User row just to answer
"is this theirs?", and ownsComment, the single rule shared by the route that
offers the controls and the route that enforces them.
Nine tests cover that rule directly: internal users' notes stay read-only, a
session that has never posted owns nothing, and an authorless comment — one
whose User was deleted, since Comment.authorId is SetNull — cannot be claimed by
anyone. The routes themselves are not covered, so exercise edit and delete
against a real review link before handing one to a client.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requesting changes on a shot and then re-rendering it through the render queue
left every layer of the app still showing the old verdict. Three independent
causes, all on the pipeline path.
finalizePreview inherited the superseded version's approvalStatus
unconditionally. The carry-over exists so a re-export of a signed-off shot does
not reappear as awaiting review, but it applied equally to NEEDS_CHANGES and
REJECTED, so the render queued to address a note arrived already wearing that
note and nothing registered that the work had been redone. carryApproval now
carries only APPROVED.
The pipeline never moved the task. One left on CHANGES by a review or a QC fail
stayed there through every re-render, with no way out but a manual kanban drag.
A genuinely new version now moves it to INTERNAL_REVIEW, as actions/versions.ts
and batch-upload already did. A replaced version is excluded: regeneratePreview
re-delivers the same version after a slate fix and is not a new submission.
The pipeline never called recalcShotStatus at all, so shot boards went stale the
moment a render changed a task. finalizePreview and the QC fail path both
recalculate now; the latter already documented the REVISIONS derivation it was
relying on and never triggering.
Shot.status is derived from tasks and approval flags, but six call sites wrote
it directly with the status they expected: internallyApproveShot,
shareWithClient, unshareFromClient, clientApproveShot, unapproveShot and the
client approve route. Rule 1 of the derivation is that any CHANGES task outranks
an approval, so a shot could be pushed past open rework and then snap back to
REVISIONS on the next unrelated recalc, which is close to unreportable as a bug.
They now set the approval inputs and let recalcShotStatus derive.
unshareFromClient additionally hardcoded READY_FOR_CLIENT even for a
client-approved shot. updateShotStatus keeps its direct write as the deliberate
manual override, now documented as transient for the same reason.
markShotForRevisions replaces the duplicated change-request logic. Both copies
marked all non-DONE tasks CHANGES, which matches nothing on the shot this is
most often called for: one out for client review precisely because all its tasks
are finished. The request then derived to INTERNAL_REVIEW and had no visible
effect anywhere. When nothing is picked up it now reopens the task behind the
shot's latest version, which is the deliverable the reviewer was looking at.
A client's shot-level change request also marks the versions and writes Approval
rows now. The dashboard counters, the version badges and the portal's own
filters all read Version.approvalStatus, so a shot-level verdict was invisible
everywhere those look.
clearStaleShotApproval covers the reverse case: uploading a new version onto an
approved shot moved the task to INTERNAL_REVIEW and changed nothing visible,
because the derivation short-circuits on shotApprovalStatus. The shot kept
reading COMPLETE while carrying work nobody had looked at. This is the one
user-visible behaviour change here, so watch the boards on a live show: an
approved shot now drops back to INTERNAL_REVIEW on a new upload. sharedWithClient
is deliberately left alone, since revoking client access stays a separate act.
submitApproval and unapproveVersion are transactional now. The verdict, the
version, the task and the derived shot status are one decision, and a failure
partway through left the three layers disagreeing with no way to tell which one
was right.
KanbanCard compared approvalStatus against "CHANGES_REQUESTED", which is not a
member of the enum, so the changes-requested icon never rendered at all.
Thirteen tests cover carryApproval, markShotForRevisions including the all-DONE
regression, and clearStaleShotApproval. What is not covered is the pipeline
transaction itself, which needs a database: queue a render against a shot in
REVISIONS and confirm the task and shot both move before trusting this on a
show.
Version.reviewStatus is still written nowhere while being published through
ext/shots/lookup and EXT_API_REFERENCE.md as a permanent "PENDING". Left alone
deliberately: removing a field from that contract could break the connector.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build Shot produced comps starting at 00:00:00:00 because the panel never
actually read the EXR timecode. getFootageStartFrames ranked a fragile "New
Comp from Selection" probe first: it toggles the Project panel via the Window
menu to get focus, and where that does not land the menu item is greyed out,
executeCommand silently does nothing, and the probe returns null. A shot with
no timecodeStart then fell through the API rank to zero.
The worker already reads EXR headers properly in ExrHeader.cs, and its
validation ranks the plate's own timeCode above the API value, which stores the
editorial in-point and sits a handle-length ahead of the plate by design. Shots
were therefore being built from rank 3 and validated against rank 1. That is a
timecode desync waiting to reach a delivery, not just a cosmetic start frame.
readExrTimecode ports the worker's reader to ExtendScript and becomes rank 1:
one short header read, walking `name\0 type\0 int32 size data` for timeCode and
falling back to timeCodeString where a writer omitted the packed attribute.
decodePackedTimecode mirrors Timecode.FromPacked, decoding the SMPTE 12M BCD a
byte at a time so the arithmetic never touches the sign bit. The menu probe is
kept as rank 2 and now runs only when the plate carries no timecode at all, so
the Project-panel toggle is off the normal path.
setCompStartFrame was the second failure. Assigning displayStartFrame on a
version of AE that does not have the property is ignored silently rather than
throwing, so the early return skipped the displayStartTime fallback entirely.
It now reads the value back, falls through when it did not take, and returns
whether anything stuck; buildShotFromCode logs when nothing does. A start frame
that cannot be set is now visible in the diagnostics report instead of a comp
quietly sitting at zero.
A plate whose timecode is present but zero still falls through to the API rank,
keeping the existing frames > 0 convention so the safety net survives. If a
genuine 00:00:00:00 plate should win outright that is a deliberate follow-up.
The parser is covered against synthetic headers, including the 0x18585712
example documented in ExrHeader.cs, drop-frame, truncated and non-EXR input.
What is not yet exercised in a live After Effects session is mainSource.file
resolving to the sequence's first frame and displayStartFrame taking on a given
AE build, so build one shot and check its start against the plate before
relying on this. Picture-lock automation was reverted for a timecode desync of
the same shape.
PANEL_VERSION goes to 2.6.0 per the documented rule, so Pipeline → Machines
does not show two workstations on one version behaving differently. The
startSource comment in the manifest schema enumerated only the two older
values; the queue-time provenance check keys on /\bAPI\b/ and so already reads
"EXR timecode …" as trustworthy rather than suspect.
The same silent-failure setCompStartFrame still exists in the farm-side
vfxr_build_preview.jsx, which is fed the right value from the manifest but can
drop it the same way. Left for its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shot with no COMP task rendered and previewed fine and then showed
nothing in the app. createExport set Export.taskId from the shot's first
COMP task or null, and finalizePreview registered the review Version with
that same null taskId — so the MP4 was uploaded and stored, but the
Version hung off no task and none of the review flows had anywhere to
draw it. From the artist's side the export completed and produced
nothing.
E1 now resolves the shot's comp task inside the version-allocation
transaction and creates one when there isn't any, titled after the
preview file the export will produce: {shotCode}_cmp_TT_{version}, the
same convention the manual batch upload's create-task path already uses.
renderOutputBase() is exported and buildPreviewManifest takes its `base`
from it, so the task title and the MOV/MP4 name come from one function
rather than two copies of the same template. The title therefore already
carries the version, which makes finalizePreview's rename-to-the-
delivered-version a no-op on that first export rather than an immediate
rename of a task created moments earlier.
The lookup and the create sit inside the transaction so the P2002
version-collision retry rolls a losing attempt's task back with
everything else, instead of leaving a duplicate behind.
recalcShotStatus runs only when a task was actually created: a shot with
no tasks derives WAITING and now has one. Approval-driven statuses still
take precedence, so an already-approved shot stays COMPLETE.
Both the queue-time ExportEvent note and the E1 response record what
happened, so a task appearing under a shot on its own is traceable to the
export that made it.
Exports already in flight with a null taskId keep their orphaned
versions; backfilling those is a separate migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>