Skip to content

Commit 525c597

Browse files
vanceingallsVai
andcommitted
feat(producer): add shaderTransitionWorkerPool
hf#677 follow-up. 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 and the hybrid path in PR 4 wires it up. Why a separate pool: the shader blend is a hot inner loop iterated over every pixel of every transition frame at 16bpc. Running it on the main event loop was previously the dominant wall-time bottleneck once DOM capture parallelism was reached. Moving it to `worker_threads` unblocks the main thread for CDP dispatch and pipelined decode/blit. Pieces: * `shaderTransitionWorker.ts` — the worker entry. Imports `TRANSITIONS` + `crossfade` from `@hyperframes/engine/shader-transitions` (the subpath added here on the engine package). Like the alpha-blit subpath, this is a zero-import TS file so it survives the `new Worker(<path>)` loader boundary without dragging in the producer module graph. * `shaderTransitionWorkerPool.ts` — fixed-size pool with `run()` API. Uses `transferList` so the 16bpc HDR `from`/`to`/`out` buffers move by ownership across the boundary instead of being copied. * `shaderTransitionWorkerPool.test.ts` — 6 vitest tests pinning byte- equivalence across all 15 shaders against an inline reference, transferList correctness, and pool lifecycle (concurrent dispatch + termination). All pass. Build wiring: * `packages/cli/tsup.config.ts`: third tsup entry emits `dist/shaderTransitionWorker.js` alongside `dist/cli.js`. Same rationale as the pngDecodeBlitWorker entry in PR 2 — the pool's `new Worker(<path>)` resolver probes for that file next to its loaded module. * `packages/producer/build.mjs`: fourth esbuild entry emits the worker as `dist/services/shaderTransitionWorker.js` for direct producer consumers. Adds the `@hyperframes/engine/shader-transitions` workspace alias to the existing `workspaceAliasPlugin`. * `packages/engine/package.json`: adds `./shader-transitions` subpath export pointing at `src/utils/shaderTransitions.ts`. The file is already import-free so the worker can consume the TS source directly. No behavior change in any render. PR 3 of 5 in the hf#732 decomposition stack; stacked on top of PR 2 (pngDecodeBlit pool). -- Vai Co-Authored-By: Vai <vai@heygen.com>
1 parent 92bccfd commit 525c597

6 files changed

Lines changed: 860 additions & 1 deletion

File tree

‎packages/cli/tsup.config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export default defineConfig({
1919
entry: {
2020
cli: "src/cli.ts",
2121
pngDecodeBlitWorker: "../producer/src/services/pngDecodeBlitWorker.ts",
22+
// hf#677/#732: shader-blend worker. Same `new Worker(<path>)`
23+
// bundling rationale as `pngDecodeBlitWorker` above.
24+
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
2225
},
2326
format: ["esm"],
2427
outDir: "dist",
@@ -72,6 +75,14 @@ var __dirname = __hf_dirname(__filename);`,
7275
// `alphaBlit.ts` is import-free (only zlib) so the worker survives
7376
// the worker_thread loader boundary directly via this TS source.
7477
"@hyperframes/engine/alpha-blit": resolve(__dirname, "../engine/src/utils/alphaBlit.ts"),
78+
// hf#677 follow-up: the shader-blend worker imports from
79+
// `@hyperframes/engine/shader-transitions` (subpath export) — a
80+
// standalone TS file with zero internal imports that survives the
81+
// worker_thread loader boundary.
82+
"@hyperframes/engine/shader-transitions": resolve(
83+
__dirname,
84+
"../engine/src/utils/shaderTransitions.ts",
85+
),
7586
};
7687
options.loader = { ...options.loader, ".browser.js": "text" };
7788
},

‎packages/engine/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"types": "./src/index.ts",
1313
"exports": {
1414
".": "./src/index.ts",
15-
"./alpha-blit": "./src/utils/alphaBlit.ts"
15+
"./alpha-blit": "./src/utils/alphaBlit.ts",
16+
"./shader-transitions": "./src/utils/shaderTransitions.ts"
1617
},
1718
"scripts": {
1819
"build": "tsc",

‎packages/producer/build.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const workspaceAliasPlugin = {
2424
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
2525
path: resolve(scriptDir, "../engine/src/utils/alphaBlit.ts"),
2626
}));
27+
build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
28+
path: resolve(scriptDir, "../engine/src/utils/shaderTransitions.ts"),
29+
}));
2730
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
2831
path: resolve(scriptDir, "../core/src/index.ts"),
2932
}));
@@ -74,6 +77,22 @@ await Promise.all([
7477
entryPoints: ["src/services/pngDecodeBlitWorker.ts"],
7578
outfile: "dist/services/pngDecodeBlitWorker.js",
7679
}),
80+
// Shader-blend worker (hf#677 follow-up). Loaded by
81+
// `shaderTransitionWorkerPool.createShaderTransitionWorkerPool` via
82+
// `new Worker(<path>)`. Same bundling rationale as the
83+
// `pngDecodeBlitWorker` entry above.
84+
build({
85+
bundle: true,
86+
platform: "node",
87+
target: "node22",
88+
format: "esm",
89+
external: ["puppeteer", "esbuild", "postcss"],
90+
plugins: [workspaceAliasPlugin],
91+
minify: false,
92+
sourcemap: true,
93+
entryPoints: ["src/services/shaderTransitionWorker.ts"],
94+
outfile: "dist/services/shaderTransitionWorker.js",
95+
}),
7796
]);
7897

7998
// Copy core runtime artifacts so the producer can find them at dist/
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Worker entry point for off-main-thread shader-blend execution.
3+
*
4+
* The hf#677 follow-up moved the layered transition pipeline (dual-scene
5+
* seek/mask/screenshot) onto per-worker DOM sessions, but the per-pixel JS
6+
* shader-blend at the tail of `processLayeredTransitionFrame` still ran on
7+
* the orchestrator's main event loop. Complex shaders (`domain-warp`,
8+
* `swirl-vortex`, `glitch`) iterate every pixel of the rgb48le buffer with
9+
* multiple noise/sample calls per pixel — hundreds of milliseconds per call
10+
* — so N concurrent DOM workers all firing shader-blends saturated the
11+
* single Node thread. The empirical worker-count sweep on the #677 fixture
12+
* (w=1=218s, w=2=183s, w=6=184s, w=12=188s) flattens after w=2, which is the
13+
* single-threaded-downstream signature.
14+
*
15+
* This worker runs `TRANSITIONS[shader](from, to, output, w, h, p)` on a
16+
* dedicated Node `worker_threads` Worker. The pool dispatches one frame at
17+
* a time per worker. The rgb48le scratch Buffers are moved in and out via
18+
* `transferList` — zero-copy at the ArrayBuffer level — so the only
19+
* per-frame cost is the postMessage round-trip (~sub-millisecond on the
20+
* 2.4 MB 854×480 buffers) plus the shader-blend itself.
21+
*
22+
* Lifecycle:
23+
*
24+
* 1. Pool constructor spawns N of these workers up front.
25+
* 2. Main thread posts `{ shader, bufferA, bufferB, output, width, height,
26+
* progress }` with `transferList: [bufferA, bufferB, output]`. The three
27+
* ArrayBuffers are detached on the sender; the caller must NOT touch
28+
* them until the worker replies.
29+
* 3. Worker wraps each ArrayBuffer as a Node Buffer view (zero-copy),
30+
* invokes `TRANSITIONS[shader] ?? crossfade`, and posts `{ ok: true,
31+
* output }` back with `transferList: [output]`. (The two input ArrayBuffers
32+
* are also returned so the main thread can re-attach them to the worker's
33+
* `LayeredTransitionBuffers` slot for reuse on the next frame.)
34+
* 4. On unknown shader / runtime exception, worker posts `{ ok: false, error,
35+
* bufferA, bufferB, output }` — all three are still transferred back so
36+
* the caller can release them.
37+
*
38+
* The worker holds no per-frame state. It is shared across DOM-session
39+
* workers and across the entire render — only spawned once at render start
40+
* and terminated at render end.
41+
*/
42+
43+
import { parentPort } from "node:worker_threads";
44+
// Import the shader-blend table from a dedicated `./shader-transitions`
45+
// subpath export of `@hyperframes/engine` rather than the package root.
46+
// Rationale:
47+
//
48+
// 1. `shaderTransitions.ts` is fully self-contained (no internal imports).
49+
// Going through engine's root index pulls in the rest of the engine
50+
// graph, which fails under `worker_threads` + tsx in dev/test: the
51+
// tsx loader's `.js → .ts` rewrite does NOT survive the Worker
52+
// boundary, so internal specifiers like `./config.js` from `index.ts`
53+
// fail to resolve. The subpath sidesteps that by pointing the
54+
// resolver straight at the import-free file.
55+
//
56+
// 2. In the production esbuild bundle (build.mjs entry
57+
// `src/services/shaderTransitionWorker.ts`) the workspace alias plugin
58+
// redirects `@hyperframes/engine/shader-transitions` to the same TS
59+
// source and bundles it inline, so behavior is identical.
60+
import { TRANSITIONS, crossfade } from "@hyperframes/engine/shader-transitions";
61+
62+
interface ShaderJobRequest {
63+
shader: string;
64+
bufferA: ArrayBuffer;
65+
bufferB: ArrayBuffer;
66+
output: ArrayBuffer;
67+
width: number;
68+
height: number;
69+
progress: number;
70+
}
71+
72+
interface ShaderJobOk {
73+
ok: true;
74+
bufferA: ArrayBuffer;
75+
bufferB: ArrayBuffer;
76+
output: ArrayBuffer;
77+
}
78+
79+
interface ShaderJobErr {
80+
ok: false;
81+
error: string;
82+
bufferA: ArrayBuffer;
83+
bufferB: ArrayBuffer;
84+
output: ArrayBuffer;
85+
}
86+
87+
export type ShaderJobResult = ShaderJobOk | ShaderJobErr;
88+
89+
if (!parentPort) {
90+
// Defensive — this module is only meaningful inside a worker_thread.
91+
// If imported on the main thread (e.g. by an accidental top-level test),
92+
// do nothing rather than throwing, so static analysis stays clean.
93+
// eslint-disable-next-line no-console
94+
console.warn("[shaderTransitionWorker] no parentPort; module loaded on main thread");
95+
} else {
96+
parentPort.on("message", (msg: ShaderJobRequest) => {
97+
const { shader, bufferA, bufferB, output, width, height, progress } = msg;
98+
// Re-wrap the transferred ArrayBuffers as Node Buffers. Buffer.from(ab)
99+
// is a zero-copy view over the same underlying memory — no allocation,
100+
// no data copy. The shader functions are typed to take Buffer and use
101+
// its readUInt16LE/writeUInt16LE API.
102+
const bufA = Buffer.from(bufferA);
103+
const bufB = Buffer.from(bufferB);
104+
const out = Buffer.from(output);
105+
106+
try {
107+
const fn = TRANSITIONS[shader] ?? crossfade;
108+
fn(bufA, bufB, out, width, height, progress);
109+
const reply: ShaderJobOk = {
110+
ok: true,
111+
bufferA,
112+
bufferB,
113+
output,
114+
};
115+
parentPort!.postMessage(reply, [bufferA, bufferB, output]);
116+
} catch (err) {
117+
const reply: ShaderJobErr = {
118+
ok: false,
119+
error: err instanceof Error ? err.message : String(err),
120+
bufferA,
121+
bufferB,
122+
output,
123+
};
124+
parentPort!.postMessage(reply, [bufferA, bufferB, output]);
125+
}
126+
});
127+
}

0 commit comments

Comments
 (0)