-
2026-09-08 -- the page recorder compresses frames on worker threads, and got ~5x faster for it (
net_utils/preview/scripts/playblast.js,viewer.html). Profiled per frame before anything was changed, which is what settled it: compressing one 1280x720 frame to PNG measured 195-220 ms, while the POST and the server's disk write together cost 7.8 ms, the readback and downscale 0.3 ms, and the render 0.04 ms. The recording WAS thetoBlobcall; everything else put together was under 5% of it, and the arithmetic checked out against the real button (210 ms x 151 frames = the 40 s a run actually took). So each snapshot now goes to a pool of workers that compress it and POST it, while the main thread renders the next frame. Measured interleaved, on the same harness and config as the entry below: 39.4s -> 14.7s for the whole button press (2.7x; the remainder is the server's ffmpeg pass, unchanged), and ~5x on the capture itself. A SINGLE worker is already 2.7x -- most of the win is simply not blocking the render loop, not parallelism -- and the curve is flat past four (2 workers 4.4x, 4 workers 5.2x, 8 workers 5.8x, 12 slightly worse), so the pool is half the cores clamped to 2-8. A browser withoutWorkerorOffscreenCanvasfalls back to compressing on the main thread, and a live test takesWorkeraway and records through it, because a fallback nothing exercises is a path that rots until it is someone's only one. This supersedes the resolution claim in the entry below: with the encode behind the render rather than in front of it, 1920 and 1280 now cost the SAME (46.7 ms and 45.1 ms per frame).MAX_EDGEstays 1280, but it now buys file size and wire time -- what a reviewer on a headset over Wi-Fi waits for -- rather than minutes. Four other levers were measured and REJECTED, which is the part worth keeping: (a) sending RAW RGBA and skipping the compression entirely is slower -- 256 ms against 223 ms -- because 3.6 MB per frame costs more on the wire and at the disk than the encode it removes, and it would have needed an input-side seam onVidUtils.compress_video, which Maya uses too; (b)willReadFrequentlyon the capture canvas, the obvious suspect since it is read every frame, changed nothing (202/222 ms hinted against 216/195 ms plain); (c) concurrenttoBlobon the main thread does not parallelise and gets steadily worse (65 -> 76 -> 87 -> 112 ms at 1/2/4/8 in flight) -- the finding that nearly buried the worker pool, since it is a different mechanism; (d) pipelining the POSTs halves a cost that is 8 ms. Render-side waste (the page renders the same pose a second time after the hook; the buffer is 16x the pixels the capture keeps) is real and measured at 0.1 ms -- not worth a change. One hazard, found by accident and now pinned:createImageBitmapsnapshots the WebGL drawing buffer only when issued in the SAME task as the render. With anawaitin between it silently returns a BLANK frame -- a 20 KB PNG where the real one is 190 KB -- and the recording succeeds, the file is written, and neither its size nor its duration says anything is wrong. Issued correctly it is bit-identical to thedrawImageit replaces (max channel delta 0 over a full frame). The live recording tests now decode a frame and assert it is not near-uniform; the guard was checked against a deliberately blank movie, since a guard that cannot fail is not one. -
2026-09-08 -- the recorded movie can carry the shot name, frame and time (
net_utils/preview/scripts/playblast.js,viewer.html).Burn-in: off/on, beside the record button. On the whole-timeline clip it names the shot the playhead is INSIDE -- and'<name> (hold)'through a gap, the same distinction the transport readout draws -- so a note about a frame in a five-shot assembly says which shot it is about; on a single shot the sequence has nothing to say and the clip's own name is used. The frame number is the DCC's own (the clip's start frame plus the offset), zero-padded so the line does not jitter, except on a negative frame where padding would bury the sign; the time is the one the frame was POSED at rather thanindex / fpsrecomputed, because the pose is clamped to the clip's duration and the two part company at the end of a clip whose frame count and duration disagree by a rounding remainder -- which would label a held last frame with a time past the clip it is holding on. Opt-in and OFF by default: it is drawn into the pixels and cannot be taken out again, so a recording is what the reviewer saw unless someone asked for the annotation -- and it cannot be toggled mid-recording, which would annotate half the frames. Drawn on the main thread as a pre-pass rather than inside the encoder, so the worker pool and the main-thread fallback share ONE implementation instead of carrying a copy each (the pool's copy would have had to travel to the workers as source text); the readback it costs measured a fraction of a millisecond, because the expensive part of a frame is compressing it, not moving it.viewer.shotAt(seconds)publishes the label the readout already computed. Verified in the pixels rather than in the filename: the same shot is recorded twice and the foot of the frame compared -- and ADJACENT frames compared with each other, which is the pair that matters. The capture loop issues several frames per animation frame, all drawn on the one pad, so a canvas read LATER than it was handed over would give a run of frames the last one's stamp; distant frames cannot show that, since they land in different ticks and differ either way. A ring of pads was written to defend against it and then measured UNNECESSARY --createImageBitmapandtoBlobboth snapshot at the point they are called, so the pixels are taken before the next frame draws over them -- so the ring came out again and the test stayed, which is the cheaper half and the one that would catch a browser that ever behaved otherwise. -
2026-09-08 --
VidUtils.compress_videoleaked ffmpeg's output pipe on every encode (vid_utils/_vid_utils.py). A barePopen(stdout=PIPE)whose pipe was never closed: oneResourceWarningper encode, and a handle held for as long as the interpreter kept the object -- which in a DCC is the whole session, and this runs once per playblast. Surfaced by the preview recorder's tests. Context-managed now, so the pipe is closed and the child reaped even when the encode raises part way through; reading to EOF and letting__exit__wait() also settlesreturncode, which the oldpoll()loop could miss. Verified withResourceWarningpromoted to an error. -
2026-09-08 -- the page recorder shipped at delivery resolution, which made a five-second shot a minute-long wait (
net_utils/preview/scripts/playblast.js). It captured the canvas's drawing buffer capped to a 1920 long edge. On a 2x display -- the ordinary case, not the extreme one -- that is a 5120x2880 buffer, and a 151-frame (5s) recording measured 66.6s, about 440 ms per frame, for a preview nobody views at 1:1. Every frame is read back off the GPU, scaled, encoded and posted, so the cap is the whole cost. Now 1280: halving the long edge roughly halves the wall time (66.6s -> 24.5s idle; 77.8s -> 39.4s on the same machine under load), for a movie 44% smaller. 960 was measured and rejected -- single digits over 1280, paid for in legibility on a wireframe or a HUD. Two other levers were tried and REVERTED, which is the part worth keeping: (a) JPEG frames post faster (19.1s vs 24.5s) but decode to FULL-range YUV, so ffmpeg carries that through and tags the movieyuvj420p (pc); levels round-trip correctly for a decoder that reads the tag, and a decoder that ignores it renders a lookdev artifact with crushed blacks. Converting back (out_range=tv) makes the scaler touch every frame and costs ~6s -- the entire JPEG saving -- so PNG is both the correct choice and, net, the fast one: it is RGB, which ffmpeg converts to limited-range YUV natively and for free. (b) Adding that sameout_range=tvto the shared encoder filter as a general "be explicit" measure cost +8s on an RGB source and changed the output not at all;_EVEN_DIMENSIONS_FILTERsays so now rather than inviting the next person to re-derive it. On the numbers themselves: the harness is software-rendered and drifted 17% across one session -- the same identical config measured 77.8s and 89.8s inside a single interleaved sweep -- so every claim above is an A/B ratio measured interleaved, and the seconds are context, not a spec. -
2026-09-08 -- the preview server dropped connections under a burst, and it was one request per frame away from mattering (
net_utils/preview/server.py). Found by the page recorder, but the defect is the server's and predates it.SimpleHTTPRequestHandlerdefaults to HTTP/1.0 -- a new TCP connection per request -- andsocketserverdefaults the listen backlog to five. A page that polls a manifest once a second never notices either; a page that posts one request per frame of a recording turns 151 frames into 151 connections, and the browser intermittently gotERR_CONNECTION_TIMED_OUTpart way through: a SYN the full accept queue DROPPED, which is why it presents as a hang rather than as a refusal, and it lands after most of the capture has already been paid for. Measured before the fix: three consecutive 151-frame runs went pass / fail / fail, failing at frame 122 and frame 42. Nowprotocol_version = "HTTP/1.1"(every response path here already sends an accurateContent-Length, and the 204s have no body by definition), so the whole recording rides ONE connection, withtimeout = 30to reap an idle kept-alive connection rather than hold its thread for the life of the server;request_queue_sizeis 128, since an entry is a pending socket rather than a thread. Four consecutive runs clean afterwards, and faster (4.3s vs 4.6-5.0s at best before). A false lead worth recording, because it is the trap this shape sets: making the recorder's lazy accessor take the server's own lock reproduced the failure every time, and removing it "fixed" it -- but the accessor was never the cause. Re-running the unchanged code three times showed pass / fail / fail. An intermittent fault will confirm any hypothesis you bisect against once; the fix here was only trusted after four consecutive clean runs, and the accessor now uses double-checked locking on a lock of its own -- correct for the real (tiny) race, and off the per-frame path where it never belonged. -
2026-09-08 -- the preview page can record the clip it is playing, through the same encode Maya's playblast exporter runs (
vid_utils/sequence_exporter.pyNEW,net_utils/preview/playblast.pyNEW,net_utils/preview/scripts/playblast.jsNEW,server.py,viewer.html). The shared core: the host-independent half ofmayatk.PlayblastExportermoved here as two classes, split where the two callers genuinely differ.SequenceEncoderturns a numbered image sequence into a movie -- the target registry, the CRF mapping, the even-dimension filter H.264 needs, the audio mux and its silent-track warning, the frame collection and the cleanup -- and is all a producer with its own capture loop needs.SequenceExporteradds the plan on top: capture ONCE per image format, derive every encoded output from it, isolate a per-target failure.ExportTarget/CaptureResult/ExportResultcame with them. A host supplies pixels (capture_sequence/capture_still), its timeline (RANGE_MODES+_frame_range_for_mode), its audio (_resolve_audio_source) and its message channels (_notify/_warn); an output kind only that host can produce (Maya's native movie playblast, an Arnold render) is registered inTARGETSand produced through_export_extra_target, so the shared plan never grows a branch per host. The page half:PreviewPlayblastis aSequenceEncoderand NOT aSequenceExporter-- the capture loop is in the browser, so an exporter'scapture_sequencecontract is one it could not honour, and inheriting it to reuse the encode would be an LSP violation dressed up as reuse. The page steps the clip (pose frame N, render, hand the pixels over, ask for N+1) and POSTs one frame per request to/playblast/begin|frame|finish|cancel, held to the same origin check as the settings write because these are routes by which a page reaches a file on disk. Stepped, not sampled: a throttled tab, a headset and a desktop produce the same file at the deliverable's own authoring frame rate, which is what makes the result comparable with a viewport playblast of the same shot rather than merely similar to it. Contiguity is checked before the encode: ffmpeg reads a printf pattern straight through and STOPS at the first gap, so a dropped frame would otherwise encode silently as a short movie. Where it lands: beside the published file when that file is still on disk (an exporter's GLB, or one chosen with External GLB), and in the serve root otherwise -- a scene push's GLB is the bridge's own scratch and is released the moment it is published, so there is nothing to sit beside; either wayGET /playblast/<token>serves it as an attachment, which is the half that matters in a headset. Named for the SOURCE rather than the served asset: the asset is republished under a stablescene.glbso a page can keep one URL, which is exactly what makes it useless as a label. On by itself:AUTO_SCRIPTSmapsplayblasttoanimation_web, so the button appears exactly when the clip picker does -- a checkbox would be missing on the one push a reviewer just watched and wants to send on. Fixed while here:do_POSTnow drains the request body BEFORE judging the route, not after; a body left in the socket desynchronises the next request on a keep-alive connection, which surfaces as the connection dropping on the request AFTER the one that was refused. Review pass, same day, four defects: (1) the scratch sequence was numbered from the clip's authoring frame, which a scene with PRE-ROLL declares as negative --shot.-010.pngmatches no printf pattern and ffmpeg will not take a negative-start_numbereither, so the run failed at the encode having already paid for the whole capture. The frames are deleted the moment the movie exists, so nothing carried that numbering anywhere: the scratch is numbered from zero and the authoring range is reported byfinishinstead, which is the only place it was ever read. (2)finish_playblastforwarded the page's JSON intofinish(**kwargs), so a page could supplystem-- which reachesos.path.join-- through a route whose whole job is writing a file. The stem is now composed server-side and sanitized infinishas well, for the callers a public method cannot vet. (3) The capture size was measured per frame off the canvas, so resizing the window mid-recording changed the frame size part way through, which ffmpeg answers with a garbled encode rather than an error; it is fixed once, at the start. (4) Recording from inside an immersive session is refused rather than recorded wrong --renderer.rendertargets the XR framebuffer there, and the canvas readback would capture the mirror. Verified end to end in headless Edge with real ffmpeg: a 61-frame shot and a 151-frameFULL SEQUENCE, each written as a playable mp4 at the deliverable's rate; 33 live browser tests and 54 unit tests green. -
2026-09-08 -- the sequence readout names the shot the playhead is standing in (
net_utils/preview/viewer.html). ScrubbingFULL SEQUENCE, the readout said only how far in the playhead was; which of the shots that was is the question a reviewer is actually asking, and the picker cannot answer it because it is sitting onFULL SEQUENCEthe whole way through. A gap is labelled as a hold of the shot before it rather than named outright -- the pose on screen is that shot's last frame, held, and naming it plainly would claim it plays through frames it does not cover, which is a bug report waiting to be filed against a shot that is behaving exactly as the whole-timeline clip did. A single clip is not labelled: the picker already names it. The per-frame early-out now compares the label as well as the playhead tick, because scrubbing slowly across a shot boundary is the one case where the tick can repeat while the answer changes -- and it is the case the label exists for. The transport also reaches viewer scripts now (clip,poseAt,playing/setPlaying), which is what lets the recorder above address a clip in authoring frames rather than in seconds.