Files
hyperframes/packages/producer/src/services/shaderTransitionWorker.ts
T
Vance Ingalls 30348af3f4 feat(producer): add shaderTransitionWorkerPool (hf#732 PR 3/5) (#758)
## 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
2026-05-13 15:17:58 -07:00

128 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Worker entry point for off-main-thread shader-blend execution.
*
* The hf#677 follow-up moved the layered transition pipeline (dual-scene
* seek/mask/screenshot) onto per-worker DOM sessions, but the per-pixel JS
* shader-blend at the tail of `processLayeredTransitionFrame` still ran on
* the orchestrator's main event loop. Complex shaders (`domain-warp`,
* `swirl-vortex`, `glitch`) iterate every pixel of the rgb48le buffer with
* multiple noise/sample calls per pixel — hundreds of milliseconds per call
* — so N concurrent DOM workers all firing shader-blends saturated the
* single Node thread. The empirical worker-count sweep on the #677 fixture
* (w=1=218s, w=2=183s, w=6=184s, w=12=188s) flattens after w=2, which is the
* single-threaded-downstream signature.
*
* This worker runs `TRANSITIONS[shader](from, to, output, w, h, p)` on a
* dedicated Node `worker_threads` Worker. The pool dispatches one frame at
* a time per worker. The rgb48le scratch Buffers are moved in and out via
* `transferList` — zero-copy at the ArrayBuffer level — so the only
* per-frame cost is the postMessage round-trip (~sub-millisecond on the
* 2.4 MB 854×480 buffers) plus the shader-blend itself.
*
* Lifecycle:
*
* 1. Pool constructor spawns N of these workers up front.
* 2. Main thread posts `{ shader, bufferA, bufferB, output, width, height,
* progress }` with `transferList: [bufferA, bufferB, output]`. The three
* ArrayBuffers are detached on the sender; the caller must NOT touch
* them until the worker replies.
* 3. Worker wraps each ArrayBuffer as a Node Buffer view (zero-copy),
* invokes `TRANSITIONS[shader] ?? crossfade`, and posts `{ ok: true,
* output }` back with `transferList: [output]`. (The two input ArrayBuffers
* are also returned so the main thread can re-attach them to the worker's
* `LayeredTransitionBuffers` slot for reuse on the next frame.)
* 4. On unknown shader / runtime exception, worker posts `{ ok: false, error,
* bufferA, bufferB, output }` — all three are still transferred back so
* the caller can release them.
*
* The worker holds no per-frame state. It is shared across DOM-session
* workers and across the entire render — only spawned once at render start
* and terminated at render end.
*/
import { parentPort } from "node:worker_threads";
// Import the shader-blend table from a dedicated `./shader-transitions`
// subpath export of `@hyperframes/engine` rather than the package root.
// Rationale:
//
// 1. `shaderTransitions.ts` is fully self-contained (no internal imports).
// Going through engine's root index pulls in the rest of the engine
// graph, which fails under `worker_threads` + tsx in dev/test: the
// tsx loader's `.js → .ts` rewrite does NOT survive the Worker
// boundary, so internal specifiers like `./config.js` from `index.ts`
// fail to resolve. The subpath sidesteps that by pointing the
// resolver straight at the import-free file.
//
// 2. In the production esbuild bundle (build.mjs entry
// `src/services/shaderTransitionWorker.ts`) the workspace alias plugin
// redirects `@hyperframes/engine/shader-transitions` to the same TS
// source and bundles it inline, so behavior is identical.
import { TRANSITIONS, crossfade } from "@hyperframes/engine/shader-transitions";
interface ShaderJobRequest {
shader: string;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
width: number;
height: number;
progress: number;
}
interface ShaderJobOk {
ok: true;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
}
interface ShaderJobErr {
ok: false;
error: string;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
}
export type ShaderJobResult = ShaderJobOk | ShaderJobErr;
if (!parentPort) {
// Defensive — this module is only meaningful inside a worker_thread.
// If imported on the main thread (e.g. by an accidental top-level test),
// do nothing rather than throwing, so static analysis stays clean.
// eslint-disable-next-line no-console
console.warn("[shaderTransitionWorker] no parentPort; module loaded on main thread");
} else {
parentPort.on("message", (msg: ShaderJobRequest) => {
const { shader, bufferA, bufferB, output, width, height, progress } = msg;
// Re-wrap the transferred ArrayBuffers as Node Buffers. Buffer.from(ab)
// is a zero-copy view over the same underlying memory — no allocation,
// no data copy. The shader functions are typed to take Buffer and use
// its readUInt16LE/writeUInt16LE API.
const bufA = Buffer.from(bufferA);
const bufB = Buffer.from(bufferB);
const out = Buffer.from(output);
try {
const fn = TRANSITIONS[shader] ?? crossfade;
fn(bufA, bufB, out, width, height, progress);
const reply: ShaderJobOk = {
ok: true,
bufferA,
bufferB,
output,
};
parentPort!.postMessage(reply, [bufferA, bufferB, output]);
} catch (err) {
const reply: ShaderJobErr = {
ok: false,
error: err instanceof Error ? err.message : String(err),
bufferA,
bufferB,
output,
};
parentPort!.postMessage(reply, [bufferA, bufferB, output]);
}
});
}