mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 08:13:11 +00:00
## 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
128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
/**
|
||
* 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]);
|
||
}
|
||
});
|
||
}
|