Skip to content

Commit 92bccfd

Browse files
authored
feat(producer): add pngDecodeBlitWorkerPool (hf#732 PR 2/5) (#757)
## 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 the `new Worker(<path>)` loader boundary). - `packages/producer/src/services/pngDecodeBlitWorkerPool.ts` — fixed-size pool with `run()` API. Uses `transferList` for 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 emits `dist/pngDecodeBlitWorker.js` next to `dist/cli.js`. Without this entry the pool's `new 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-blit` subpath export pointing at `src/utils/alphaBlit.ts`. ## Stack Stacked on top of #756 (PR 1: worker-count cap). No behavior change in any render. ## Test plan - [x] 6 pool tests pass - [x] Producer + engine typecheck clean - [x] oxlint clean — Vai
1 parent 3c58c23 commit 92bccfd

6 files changed

Lines changed: 909 additions & 2 deletions

File tree

packages/cli/tsup.config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,19 @@ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url),
77
};
88

99
export default defineConfig({
10-
entry: ["src/cli.ts"],
10+
// hf#732 lever-4: emit BOTH the CLI bundle and the PNG decode + alpha-blit
11+
// worker entry. The producer's `pngDecodeBlitWorkerPool` instantiates a
12+
// Node `worker_threads` Worker via `new Worker(<path>)`, which is a
13+
// filesystem load — it cannot share the parent module graph. The pool's
14+
// path resolver probes for `pngDecodeBlitWorker.js` next to its own loaded
15+
// module (which lives inside `dist/cli.js` after the producer is
16+
// `noExternal`'d and bundled in). Without this entry the file would not
17+
// exist at runtime and the pool would either crash or silently fall back
18+
// to inline decode/blit, killing the perf gain.
19+
entry: {
20+
cli: "src/cli.ts",
21+
pngDecodeBlitWorker: "../producer/src/services/pngDecodeBlitWorker.ts",
22+
},
1123
format: ["esm"],
1224
outDir: "dist",
1325
target: "node22",
@@ -56,6 +68,10 @@ var __dirname = __hf_dirname(__filename);`,
5668
esbuildOptions(options) {
5769
options.alias = {
5870
"@hyperframes/producer": resolve(__dirname, "../producer/src/index.ts"),
71+
// hf#732 lever-4: alias for the PNG decode+blit worker's import.
72+
// `alphaBlit.ts` is import-free (only zlib) so the worker survives
73+
// the worker_thread loader boundary directly via this TS source.
74+
"@hyperframes/engine/alpha-blit": resolve(__dirname, "../engine/src/utils/alphaBlit.ts"),
5975
};
6076
options.loader = { ...options.loader, ".browser.js": "text" };
6177
},

packages/engine/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"main": "./src/index.ts",
1212
"types": "./src/index.ts",
1313
"exports": {
14-
".": "./src/index.ts"
14+
".": "./src/index.ts",
15+
"./alpha-blit": "./src/utils/alphaBlit.ts"
1516
},
1617
"scripts": {
1718
"build": "tsc",

packages/producer/build.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const workspaceAliasPlugin = {
2121
build.onResolve({ filter: /^@hyperframes\/engine$/ }, () => ({
2222
path: resolve(scriptDir, "../engine/src/index.ts"),
2323
}));
24+
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
25+
path: resolve(scriptDir, "../engine/src/utils/alphaBlit.ts"),
26+
}));
2427
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
2528
path: resolve(scriptDir, "../core/src/index.ts"),
2629
}));
@@ -55,6 +58,22 @@ await Promise.all([
5558
entryPoints: ["src/server.ts"],
5659
outfile: "dist/public-server.js",
5760
}),
61+
// PNG decode + alpha-blit worker (hf#732 lever-4). Loaded by
62+
// `pngDecodeBlitWorkerPool.createPngDecodeBlitWorkerPool` via
63+
// `new Worker(<path>)`. Must be a separate entry point so the worker
64+
// module is standalone and shares no parent module-graph state.
65+
build({
66+
bundle: true,
67+
platform: "node",
68+
target: "node22",
69+
format: "esm",
70+
external: ["puppeteer", "esbuild", "postcss"],
71+
plugins: [workspaceAliasPlugin],
72+
minify: false,
73+
sourcemap: true,
74+
entryPoints: ["src/services/pngDecodeBlitWorker.ts"],
75+
outfile: "dist/services/pngDecodeBlitWorker.js",
76+
}),
5877
]);
5978

6079
// Copy core runtime artifacts so the producer can find them at dist/
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Worker entry point for off-main-thread PNG decode + alpha blit. The
3+
* companion to `pngDecodeBlitWorkerPool.ts`. See that file for the rationale
4+
* (hf#732 lever-4: overlap Chrome's screenshot with Node's decode+blit).
5+
*
6+
* Lifecycle:
7+
*
8+
* 1. Pool constructor spawns N of these workers up front.
9+
* 2. Main thread posts `{ png, pngOffset, pngLength, dest, destOffset,
10+
* destLength, width, height, transfer }` with `transferList: [png, dest]`.
11+
* Both underlying ArrayBuffers are detached on the sender; the caller
12+
* must NOT touch them until the worker replies.
13+
* 3. Worker wraps each ArrayBuffer as a Node Buffer view (zero-copy),
14+
* runs `decodePng` to get an RGBA8 Uint8Array, then `blitRgba8OverRgb48le`
15+
* to composite the decoded pixels onto the rgb48le `dest` buffer in
16+
* the requested transfer space.
17+
* 4. Worker posts `{ ok, png, dest, decodeMs, blitMs }` back with
18+
* `transferList: [png, dest]`. Both ArrayBuffers return to the main
19+
* thread; the caller swaps `result.dest` into its render state.
20+
* 5. On decode/blit exception, worker posts `{ ok: false, error, png,
21+
* dest }` — both ArrayBuffers still returned so the caller can release
22+
* them.
23+
*
24+
* The worker holds no per-frame state. The intermediate RGBA8 decode
25+
* buffer is allocated per-call and dropped on the worker side.
26+
*
27+
* Import strategy: identical to `shaderTransitionWorker.ts` — use the
28+
* `./alpha-blit` subpath export of `@hyperframes/engine` rather than the
29+
* package root, because the root pulls in the full engine graph and the
30+
* tsx loader's `.js → .ts` rewrite does not survive the Worker boundary
31+
* under dev/test.
32+
*/
33+
34+
import { parentPort } from "node:worker_threads";
35+
import { decodePng, blitRgba8OverRgb48le } from "@hyperframes/engine/alpha-blit";
36+
37+
interface DecodeBlitJobRequest {
38+
png: ArrayBuffer;
39+
pngOffset: number;
40+
pngLength: number;
41+
dest: ArrayBuffer;
42+
destOffset: number;
43+
destLength: number;
44+
width: number;
45+
height: number;
46+
transfer: string;
47+
}
48+
49+
interface DecodeBlitJobOk {
50+
ok: true;
51+
png: ArrayBuffer;
52+
dest: ArrayBuffer;
53+
decodeMs: number;
54+
blitMs: number;
55+
}
56+
57+
interface DecodeBlitJobErr {
58+
ok: false;
59+
error: string;
60+
png: ArrayBuffer;
61+
dest: ArrayBuffer;
62+
}
63+
64+
export type DecodeBlitJobResult = DecodeBlitJobOk | DecodeBlitJobErr;
65+
66+
if (!parentPort) {
67+
// Defensive — this module is only meaningful inside a worker_thread.
68+
// eslint-disable-next-line no-console
69+
console.warn("[pngDecodeBlitWorker] no parentPort; module loaded on main thread");
70+
} else {
71+
parentPort.on("message", (msg: DecodeBlitJobRequest) => {
72+
const { png, pngOffset, pngLength, dest, destOffset, destLength, width, height, transfer } =
73+
msg;
74+
// Re-wrap the transferred ArrayBuffers as Node Buffer views. The
75+
// dispatcher in the pool normalizes inputs to offset-0 ArrayBuffers
76+
// before transfer (avoiding the 8KB shared-pool DataCloneError), so
77+
// pngOffset / destOffset are 0 and pngLength / destLength match the
78+
// backing ArrayBuffer byteLength in practice. We still honor the
79+
// forwarded values so the worker is robust if the dispatcher ever
80+
// changes (e.g. ships a slice over a larger transferred ArrayBuffer).
81+
const pngBuf = Buffer.from(png, pngOffset, pngLength);
82+
const destBuf = Buffer.from(dest, destOffset, destLength);
83+
84+
try {
85+
const decodeStart = Date.now();
86+
const { data: rgba } = decodePng(pngBuf);
87+
const decodeMs = Date.now() - decodeStart;
88+
89+
const blitStart = Date.now();
90+
// `blitRgba8OverRgb48le` accepts the CompositeTransfer string as a
91+
// typed union. The pool's `transfer` field is `string` for transport
92+
// simplicity; the actual values flow through unchanged from the
93+
// orchestrator's HdrCompositeContext and the function validates at
94+
// its own boundary.
95+
blitRgba8OverRgb48le(
96+
rgba,
97+
destBuf,
98+
width,
99+
height,
100+
transfer as Parameters<typeof blitRgba8OverRgb48le>[4],
101+
);
102+
const blitMs = Date.now() - blitStart;
103+
104+
const reply: DecodeBlitJobOk = {
105+
ok: true,
106+
png,
107+
dest,
108+
decodeMs,
109+
blitMs,
110+
};
111+
parentPort!.postMessage(reply, [png, dest]);
112+
} catch (err) {
113+
const reply: DecodeBlitJobErr = {
114+
ok: false,
115+
error: err instanceof Error ? err.message : String(err),
116+
png,
117+
dest,
118+
};
119+
parentPort!.postMessage(reply, [png, dest]);
120+
}
121+
});
122+
}

0 commit comments

Comments
 (0)