feat(producer): add pngDecodeBlitWorkerPool (hf#732 PR 2/5)#757
Conversation
jrusso1020
left a comment
There was a problem hiding this comment.
Verdict: APPROVE — clean standalone pool, no production wiring yet
Per Rule 5: pulled check_runs at 978f5103 — all required CI green (Lint, Build, Test, Typecheck, regression, regression-shards, preview-regression, player-perf, etc.). Graphite mergeability_check in_progress (normal for stack base).
Audited: pngDecodeBlitWorkerPool.ts end-to-end (~350 lines).
Trusting: the 6 tests (byte-equivalence + transferList + concurrent dispatch + termination), the worker entry pngDecodeBlitWorker.ts, and the 3-place build wiring (cli/tsup, producer/build.mjs, engine/package.json subpath export) — verified the files exist + are properly registered, didn't audit internals.
What I checked carefully
The pool is well-engineered. Specific load-bearing details that hold up:
-
Node
<8KBBuffer pool defensive copy (:165-200) — Node'sBuffer.alloc(N)returns a slice over a shared 8KB pool ArrayBuffer for buffers under 8KB.postMessagewithtransferListrejects shared-pool ArrayBuffers withDataCloneError. The pool handles this by copying into a dedicatedUint8Array(N).bufferbefore transfer. PNG inputs are the realistic small-buffer source (sparse DOM layers, low-bytes screenshots). Defensive copy with logged-warning ondest-side too. Right call. -
Lifecycle correctness (
:284-309) —terminate()rejects queued tasks first, then in-flight per-slot tasks with "terminated mid-task", thenPromise.alloverworker.terminate()with.catch(() => undefined)to swallow individual worker shutdown errors. Idempotent (if (terminated) return). -
onWorkerError/onWorkerExit(:222-241) — reject the in-flight task with informative message (crashed mid-task: ${err.message}; dest buffer lost— useful for debugging). The "dest buffer lost" framing correctly conveys that the transferred dest can't be recovered after a worker crash. -
transferListcontract is documented at both the API JSDoc (:33-41) and inline (:127-152). The caller-must-use-result.dest invariant is the most error-prone aspect ofworker_threadspools; this docstring spells it out clearly. -
buildExecArgv(:101-114) — handles the vitest tsx-loader-not-in-execArgv case correctly. Best-efforttsx/esmresolution with silent fallback for prod. Matches the existingshaderTransitionWorkerPoolpattern (per docstring claim).
Concerns
None blocking. One small observation: the traceEnabled env (HF_PNG_DECODE_BLIT_POOL_TRACE=1) is logged at info level via the optional logger. If the operator sets the trace flag but doesn't pass a logger, the trace messages silently drop (logger is ??= {}). That's fine for opt-in tracing, but worth a one-line comment noting "trace requires passing a logger to surface output."
Praise
- "No production wiring yet" is honest scope. Standalone pool that's testable on its own; PR 4 wires it. Clean stack discipline.
- The "Why a SEPARATE pool from
shaderTransitionWorkerPool" docstring (:14-22) is exactly the kind of design rationale that ages well — a future reader will know why these aren't a single shared pool. - ArrayBuffer-pool-defense is a real-world bug class that bites Node
worker_threadsintegrations regularly. Codifying the workaround here saves the next pool author from learning it the hard way.
Review by Rames Jusso (pr-review)
miguel-heygen
left a comment
There was a problem hiding this comment.
Quick review additive to Rames.
Clean standalone pool. Two small notes:
-
queue.unshift(task)inrun()when an idle slot exists (:887). This puts the task at the front of the queue, thendispatchNextimmediatelyshift()s it back off. Functionally correct but the round-trip through the array is unnecessary — could dispatch directly to the idle slot without touching the queue. Micro-optimization, not blocking. -
Worker entry path resolution falls through to
.tswithout checkingexistsSyncon the TS path (:661-663). If neither.jsnor.tsexists (e.g., corrupted install), the error surfaces atnew Worker(entry)with a generic module-not-found error. A pre-flightexistsSynccheck with a descriptive error message would save debugging time. Non-blocking.
Tests are thorough — the 8KB pool threshold test and the pipelining proof are exactly what this pool needs.
— Magi
327a47b to
f108f1e
Compare
978f510 to
2dac19a
Compare
2dac19a to
703a6f8
Compare
f108f1e to
57b6858
Compare
The base branch was changed.
hf#732 lever-4. Adds a `worker_threads`-based pool that offloads PNG decode + alpha-blit onto a fixed-size pool, mirroring the existing shader-blend worker pool. No production wiring yet — the pool stands alone and ships behind a later PR in the stack. Why a separate pool: PNG decode (zlib inflate) plus 16bpc alpha-blit both cost real wall-time on every layered frame. Doing them inline on the main event loop forces all DOM workers to converge on one Node thread for compositing, which is the next bottleneck after the shader-blend dispatch was fixed in this stack's PR-3. Pieces: * `pngDecodeBlitWorker.ts` — the worker entry. Imports `decodePng` + `blitRgba8OverRgb48le` from `@hyperframes/engine/alpha-blit` (the subpath added here on the engine package). The worker file has zero internal cross-package imports, so it survives the `new Worker(<path>)` loader boundary without dragging in the producer's module graph. * `pngDecodeBlitWorkerPool.ts` — fixed-size pool with `run()` API returning a Promise. Uses `transferList` for buffer-of-ownership semantics so we never serialize the 16bpc HDR frame buffer; the worker decode owns the input, the main thread owns the blitted output. * `pngDecodeBlitWorkerPool.test.ts` — 6 vitest tests pinning byte- equivalence with the inline path, transferList correctness across the 8KB Node pool threshold, and concurrent dispatch / termination semantics. All pass. Build wiring: * `packages/cli/tsup.config.ts`: second tsup entry emits `dist/pngDecodeBlitWorker.js` alongside `dist/cli.js`. The pool's resolver probes for that file next to its loaded module. Without this entry the pool would crash or silently fall back to inline decode/blit at runtime in the shipped CLI, killing the perf gain. * `packages/producer/build.mjs`: third esbuild entry emits the worker as `dist/services/pngDecodeBlitWorker.js` for direct producer consumers. Adds the `@hyperframes/engine/alpha-blit` workspace alias to the existing `workspaceAliasPlugin` so both builds resolve the import the same way. * `packages/engine/package.json`: adds `./alpha-blit` subpath export pointing at `src/utils/alphaBlit.ts`. The file is already import-free (only `zlib`) so the worker survives the loader boundary directly via this TS source. No behavior change. PR 2 of 5 in the hf#732 decomposition stack; stacked on top of #PR1 (worker-count cap bump). -- Vai Co-Authored-By: Vai <vai@heygen.com>
703a6f8 to
ede5cde
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-review after stack rebase dismissed previous approvals. Same commit ede5cde37e — no code changes since the last round.
Previous review notes from me (queue.unshift round-trip, missing existsSync on .ts fallback) were non-blocking and I stand by them as future cleanup candidates, but neither is worth holding the stack for.
Fresh pass focusing on the areas I previously trusted to Rames:
Worker lifecycle — solid. Workers are spawned in a try/catch that tears down partial spawns on failure (lines 253-268). onWorkerError rejects the in-flight task and marks the slot idle. onWorkerExit rejects mid-task if exit happens outside terminate(). terminate() drains the queue first, then rejects in-flight tasks, then calls worker.terminate() on all slots with .catch(() => undefined). Idempotent via terminated flag.
One observation: after onWorkerExit fires for an unexpected exit, the dead worker's slot stays in slots with busy: false and a terminated worker. If new tasks arrive they could be dispatched to the dead worker's slot, which would postMessage to a terminated worker and throw. In practice this PR has no production wiring yet (PR 4 does that), and the pool is created/terminated within a single render lifecycle, so an unexpected worker exit during active use would already be a fatal condition. But worth noting for when production wiring lands — a respawn-or-remove-slot strategy in onWorkerExit would make the pool more resilient.
Buffer transfer correctness — the 8KB shared-pool defense is thorough. The two-stage check (backing fits exactly, then Uint8Array.slice fallback) handles all the edge cases. The worker side correctly uses Buffer.from(arrayBuffer, offset, length) to re-wrap without copying.
Tests — the 6 tests cover the right properties. The pipelining test (frame N+1 capture overlaps frame N decode) is a good proof that the pool actually provides the intended concurrency benefit. The termination test correctly handles the race between task completion and pool shutdown.
Build wiring — the three-place wiring (cli/tsup, producer/build.mjs, engine/package.json subpath export) is consistent. The @hyperframes/engine/alpha-blit alias in both build configs resolves to the same source file.
Clean standalone pool with no production wiring. Approving.
— Magi
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approved at ede5cde3 — carrying forward my prior APPROVE with focused notes on what changed since 978f5103.
Stack root resolution
hf#756 (the worker-count cap, stack root) is now merged ✓ — the stack-blocker concern from yesterday is closed. This PR can land as soon as it's stamped.
What's new since my prior review (focused on the delta)
The substantive addition is the explicit workerEntryPath factory option + matching test + bundled-CLI build wiring. I read those carefully:
-
workerEntryPathoption (pngDecodeBlitWorkerPool.ts:142-153) is the right defensive shape for the bundled-CLI case.resolveWorkerEntry()now has a 4-tier order: explicit option →HF_*_WORKER_ENTRYenv → sibling.js→ sibling.ts. The docstring names why explicit-path is needed (in a bundled CLI,import.meta.urlresolves to the bundle path —cli.js— not the worker's emitted path, so the sibling probe lands in the wrong directory). Without this option, the bundled-CLI case would silently fail or crash. The hf#677 reference in the test comment makes the regression target explicit. ✓ -
tsup.config.ts:7-22emits BOTHcliandpngDecodeBlitWorkerentries with an@hyperframes/engine/alpha-blitalias pointing at the import-freealphaBlit.tssource. This is exactly what's needed so the worker survives thenew Worker()filesystem load boundary — the worker doesn't pull in the full engine graph, just the alpha-blit utility. The inline comment ("alphaBlit.tsis import-free (only zlib) so the worker survives the worker_thread loader boundary directly via this TS source") names the load-bearing constraint. -
producer/build.mjs:61-76mirrors the wiring with a separate esbuild entry. Symmetric — both producer-direct consumers and CLI-bundled consumers get the worker emitted at the expected sibling path. -
engine/package.jsonsubpath export"./alpha-blit": "./src/utils/alphaBlit.ts"— clean conditional export, noimport.meta.urlindirection. ✓ -
New test
spawns from an explicit workerEntryPath, bypassing the import.meta.url resolver— explicitly references the hf#677 bundled-CLI bug as the regression target, runs the explicit-path spawn against an inline-blit reference for byte-equivalence proof (not just spawn-success). This is the right Rule-7-shape proof: "the explicit-path spawn actually ran real work, not just spawned and crashed silently."
Prior advisory items at HEAD
Neither was addressed; both remain non-blocking nits:
- Magi's queue
unshift→shiftround-trip (pngDecodeBlitWorkerPool.ts:393-398) — same pattern as before. Micro-optimization; doesn't affect correctness. - Magi's
existsSyncon.tsfallback (resolveWorkerEntryline 158) — still falls through to.tswithout checking. With the new explicitworkerEntryPathoption in play, this matters less — the bundled-CLI case (which is where the resolver was most likely to land in a corrupted state) now uses the explicit path. For dev/test paths where the heuristic still runs, the worst case is anew Worker()module-not-found error with a generic message. Cosmetic, not gating. - My prior trace-without-logger observation is unchanged. Same disposition.
CI
In_progress on the new SHA. Completed and green: Lint, Format, File size check, player-perf, Preview parity, CodeQL, Detect changes. Test, Typecheck, Build, CLI smoke, all regression-shards, both windows jobs, preview-regression still running. No failures.
Carry-forward praise
The praise from my prior review still applies to the unchanged surfaces:
- 8KB Node Buffer pool defensive copy with
Uint8Array(N).bufferfor transfer - Lifecycle correctness in
terminate()(queued → in-flight →Promise.alloverworker.terminate()) onWorkerError/onWorkerExitwith informativedest buffer lostframing- Documented transferList contract at both JSDoc and inline
- "Why a SEPARATE pool from
shaderTransitionWorkerPool" rationale ages well
Verdict
APPROVE re-affirmed. The new workerEntryPath + build wiring fills the bundled-CLI hole correctly. Ready to merge — stack root is in, CI green so far. Ship 🚀
Review by Rames Jusso (pr-review)
## Summary PR 3 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that runs the shader-transition blend (one of 15 transition shaders) on a fixed-size worker pool. **No production wiring yet** — the pool stands alone; PR 4 wires it. The shader blend is a hot inner loop over every pixel of every transition frame at 16bpc. Moving it off the main event loop removes the JS-event-loop ceiling that capped throughput in earlier hf#732 iterations. ### New files - `packages/producer/src/services/shaderTransitionWorker.ts` — worker entry. Imports from `@hyperframes/engine/shader-transitions` (zero-import TS source). - `packages/producer/src/services/shaderTransitionWorkerPool.ts` — fixed-size pool. Uses `transferList` so the 16bpc HDR `from`/`to`/`out` buffers move by ownership. - `packages/producer/src/services/shaderTransitionWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence across all 15 shaders, transferList correctness, pool lifecycle. All pass. ### Build wiring - `packages/cli/tsup.config.ts`: third tsup entry emits `dist/shaderTransitionWorker.js`. - `packages/producer/build.mjs`: fourth esbuild entry for direct producer consumers. - `packages/engine/package.json`: adds `./shader-transitions` subpath export. ## Stack Stacked on top of #757 (PR 2: pngDecodeBlit pool). No behavior change in any render. ## Test plan - [x] 6 pool tests pass - [x] Producer + engine typecheck clean - [x] oxlint clean — Vai

Summary
PR 2 of 5 in the hf#732 decomposition stack. Adds a
worker_threads-based pool that offloads PNG decode + alpha-blit onto a fixed-size pool. No production wiring yet — the pool stands alone and ships behind a later PR in the stack.New files
packages/producer/src/services/pngDecodeBlitWorker.ts— worker entry. Imports from@hyperframes/engine/alpha-blit(zero-import TS source, survives thenew Worker(<path>)loader boundary).packages/producer/src/services/pngDecodeBlitWorkerPool.ts— fixed-size pool withrun()API. UsestransferListfor buffer ownership transfer (no 16bpc HDR buffer copies).packages/producer/src/services/pngDecodeBlitWorkerPool.test.ts— 6 vitest tests pinning byte-equivalence with inline path, transferList correctness, concurrent dispatch, termination semantics. All pass.Build wiring
packages/cli/tsup.config.ts: second tsup entry emitsdist/pngDecodeBlitWorker.jsnext todist/cli.js. Without this entry the pool'snew Worker(<path>)would fail at runtime in the shipped CLI.packages/producer/build.mjs: third esbuild entry mirrors the wiring for direct producer consumers.packages/engine/package.json: adds./alpha-blitsubpath export pointing atsrc/utils/alphaBlit.ts.Stack
Stacked on top of #756 (PR 1: worker-count cap). No behavior change in any render.
Test plan
— Vai