Commit Graph

15 Commits

Author SHA1 Message Date
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 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 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 7f066c1372 feat(pipeline): MOV review delivery packages from selected shots
Deploy / deploy (push) Successful in 7m28s
A formal review is shared as a flat, dated folder of the preview MOVs —
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review, then _02, _03
for further reviews the same day. It reuses the delivery package's
machinery end to end: DeliveryPackage/DeliveryItem, the DELIVERY_BUILD
job, the same claim path and the same hardlink-or-copy builder, with
DeliveryPackage.kind telling the two apart.

Three things separate a review from a delivery:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:21 +02:00
twotalesanimation caaa333f02 feat(pipeline): render-derived shot thumbnails, and bulk shot delete
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together:

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:06:55 +02:00
twotalesanimation b151e8cc52 feat(pipeline): per-shot client naming alias for slates, overlays, and delivery
Deploy / deploy (push) Successful in 7m25s
Clients sometimes ship footage under a naming convention that doesn't
match our own shot codes. Adds an optional Shot.clientCode, editable on
the Slate tab: when set, it replaces shotCode only in what the client
actually sees — slate identity, overlay burn-in, and delivered EXR/MOV
file and folder names in Netflix packages — with our own version string
flatly appended (no "_cmp_TT_" studio jargon mixed in). Internal render
paths, EXR filenames on the SAN, comp names, and DB lookups are
untouched. Shots without a client code render identically to before.

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

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

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

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

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

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

Worker 1.5.1.

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

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

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

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

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

Panel 2.4.0, worker 1.4.0.

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

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

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

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

Worker 1.3.0.

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

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

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

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

Panel 2.2.1, worker 1.2.1.

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

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

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

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

Panel 2.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:26:39 +02:00
twotalesanimation feb1c7b3e9 feat(render-pipeline): validation, QC and delivery builder
Completes RenderPipeline2 phases 3-5. Additive throughout: with
validation.enabled=false the pipeline behaves exactly as before.

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

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

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

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

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

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

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

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

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

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

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

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

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:46:53 +02:00