Skip to content

Commit 75aa2d8

Browse files
vanceingallsVai
andcommitted
perf(producer): hybrid layered/parallel path for SDR shader-transition renders
hf#732 PR 4 of 5. Delivers the bulk of the 2.22x shader-transition speedup by spreading per-frame DOM capture work across N DOM worker sessions and offloading the per-pixel shader-blend onto a `worker_threads` pool (the pool added in PR 3). The hybrid path is gated by `shouldUseHybridLayeredPath`: * SDR content only (HDR raw-frame sources are fd-bound to one worker; per-worker `dup(fd)` is out of scope here). * workerCount >= 2. * Not every frame inside a transition window (parallel workers buy nothing for all-transition compositions). When the gate trips, the hybrid loop spawns `workerCount - 1` extra DOM sessions in addition to the existing `domSession`, allocates per- worker scratch buffers, and partitions the frame range into contiguous slices via `distributeLayeredHybridFrameRanges`. Each worker walks its slice; the shader-blend at the tail of each transition frame is dispatched to the worker pool (with inline fallback when the pool spawn fails). `createFrameReorderBuffer` fences the encoder so out-of- order worker completions hit the muxer in ascending frame-index order. Pool teardown is guaranteed via try/finally on both the success and error paths. The DOM worker sessions and the shader-blend pool both close even when the inner loop throws. Structural change (necessary for the perf change): `captureHdrStage.ts` was 921 lines on main, the inner per-frame loop was 250+ lines, and the file already exceeded the project's 500-line ceiling. Adding the hybrid path on top would push it past 1100 lines and the local pre-commit hook refuses to stage files past 500. Splitting `captureHdrStage.ts` into: * `captureHdrStage.ts` (orchestrator + cleanup invariants) * `captureHdrResources.ts` (HDR video extraction + image decode + dim probing) * `captureHdrFrameShared.ts` (gating predicates, partitioning, per-scene capture primitives shared by both loops) * `captureHdrSequentialLoop.ts` (legacy single-session loop) * `captureHdrHybridLoop.ts` (new multi-worker SDR path) is the simplest way to land the perf change while honoring the project's file-size rule. No behavior change in any pre-existing code path: the sequential loop is byte-equivalent to the previous inline implementation (both consume `captureSceneIntoBuffer` from the shared module, so behavior parity is enforced structurally). `renderOrchestrator.ts` is unchanged. The stage computes its own worker budget via `calculateOptimalWorkers` rather than receiving it through the call signature. Tests: * `captureHdrFrameShared.test.ts` -- 14 vitest tests pinning the hybrid gating predicate (SDR/HDR, all-transition edge, worker budget) and the contiguous-chunking partitioner. All pass. * Producer typecheck clean; oxlint clean. PR 4 of 5 in the hf#732 decomposition stack; stacked on top of PR 3 (shaderTransitionWorkerPool). -- Vai Co-Authored-By: Vai <vai@heygen.com>
1 parent 30348af commit 75aa2d8

7 files changed

Lines changed: 1517 additions & 603 deletions

File tree

‎packages/cli/src/cli.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
#!/usr/bin/env node
22

3+
// ── Worker entry path bootstrap (must run before any producer/engine load) ──
4+
// The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`,
5+
// `shaderTransitionWorkerPool`) live in the producer package and try to
6+
// resolve their worker entry by probing for sibling `.js` files next to
7+
// `import.meta.url`. When this CLI is bundled by tsup, the producer code is
8+
// inlined into `cli.js`, but `import.meta.url` resolves to the producer's
9+
// own dist path (NOT cli.js) on some module-graph layouts — so the sibling
10+
// probe lands in a directory that does not contain the bundled workers.
11+
// We emit the worker entries next to cli.js (see tsup.config.ts) and tell
12+
// the pools where to find them via the published env-var overrides. The
13+
// pools have an explicit `workerEntryPath` factory option as the canonical
14+
// API, but setting the env vars here covers every call site without having
15+
// to thread the path through the renderOrchestrator → captureHdrStage →
16+
// captureHdrHybridLoop chain.
17+
import { dirname, join } from "node:path";
18+
import { fileURLToPath } from "node:url";
19+
import { existsSync } from "node:fs";
20+
21+
(() => {
22+
const here = dirname(fileURLToPath(import.meta.url));
23+
const shader = join(here, "shaderTransitionWorker.js");
24+
const png = join(here, "pngDecodeBlitWorker.js");
25+
if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) {
26+
process.env.HF_SHADER_WORKER_ENTRY = shader;
27+
}
28+
if (!process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY && existsSync(png)) {
29+
process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY = png;
30+
}
31+
})();
32+
333
// ── Fast-path exits ─────────────────────────────────────────────────────────
434
// Check --version before importing anything heavy. This makes
535
// `hyperframes --version` near-instant (~10ms vs ~80ms).
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Tests for the hf#732 hybrid layered-path gating + partitioning predicates.
3+
* These pin the contracts that the dispatcher in `captureHdrStage.ts`
4+
* depends on; both helpers are pure so the tests are cheap to maintain.
5+
*/
6+
7+
import { describe, expect, it } from "vitest";
8+
import {
9+
distributeLayeredHybridFrameRanges,
10+
partitionTransitionFrames,
11+
shouldUseHybridLayeredPath,
12+
} from "./captureHdrFrameShared.js";
13+
14+
describe("shouldUseHybridLayeredPath", () => {
15+
it("returns false for HDR content (HDR raw-frame sources are fd-bound)", () => {
16+
expect(
17+
shouldUseHybridLayeredPath({
18+
hasHdrContent: true,
19+
transitionFramesCount: 30,
20+
totalFrames: 300,
21+
workerCount: 6,
22+
}),
23+
).toBe(false);
24+
});
25+
26+
it("returns false for single-worker budgets (sequential loop is already optimal)", () => {
27+
expect(
28+
shouldUseHybridLayeredPath({
29+
hasHdrContent: false,
30+
transitionFramesCount: 30,
31+
totalFrames: 300,
32+
workerCount: 1,
33+
}),
34+
).toBe(false);
35+
expect(
36+
shouldUseHybridLayeredPath({
37+
hasHdrContent: false,
38+
transitionFramesCount: 30,
39+
totalFrames: 300,
40+
workerCount: 0,
41+
}),
42+
).toBe(false);
43+
});
44+
45+
it("returns false when every frame is inside a transition window", () => {
46+
expect(
47+
shouldUseHybridLayeredPath({
48+
hasHdrContent: false,
49+
transitionFramesCount: 120,
50+
totalFrames: 120,
51+
workerCount: 6,
52+
}),
53+
).toBe(false);
54+
// Transition-frame count strictly greater than total is degenerate and
55+
// should still be rejected (parallel workers can't help).
56+
expect(
57+
shouldUseHybridLayeredPath({
58+
hasHdrContent: false,
59+
transitionFramesCount: 200,
60+
totalFrames: 120,
61+
workerCount: 6,
62+
}),
63+
).toBe(false);
64+
});
65+
66+
it("returns false for empty timelines", () => {
67+
expect(
68+
shouldUseHybridLayeredPath({
69+
hasHdrContent: false,
70+
transitionFramesCount: 0,
71+
totalFrames: 0,
72+
workerCount: 6,
73+
}),
74+
).toBe(false);
75+
});
76+
77+
it("returns true for SDR multi-worker with mixed transition/normal frames", () => {
78+
expect(
79+
shouldUseHybridLayeredPath({
80+
hasHdrContent: false,
81+
transitionFramesCount: 30,
82+
totalFrames: 300,
83+
workerCount: 6,
84+
}),
85+
).toBe(true);
86+
});
87+
88+
it("returns true when there are no transitions at all (pure normal-frame parallelism)", () => {
89+
expect(
90+
shouldUseHybridLayeredPath({
91+
hasHdrContent: false,
92+
transitionFramesCount: 0,
93+
totalFrames: 300,
94+
workerCount: 6,
95+
}),
96+
).toBe(true);
97+
});
98+
});
99+
100+
describe("distributeLayeredHybridFrameRanges", () => {
101+
it("partitions [0, n) into contiguous slices that cover exactly the range", () => {
102+
const ranges = distributeLayeredHybridFrameRanges(300, 6);
103+
expect(ranges.length).toBe(6);
104+
expect(ranges[0]!.start).toBe(0);
105+
let prevEnd = 0;
106+
for (const r of ranges) {
107+
expect(r.start).toBe(prevEnd);
108+
expect(r.end).toBeGreaterThanOrEqual(r.start);
109+
expect(r.end).toBeLessThanOrEqual(300);
110+
prevEnd = r.end;
111+
}
112+
expect(prevEnd).toBe(300);
113+
});
114+
115+
it("does NOT pin all transition frames to one worker (contiguous chunking spreads them)", () => {
116+
// 300 frames, 6 workers → ~50 per worker. Transition window 60-69
117+
// (10 frames) falls in worker 1's slice [50, 100). The transition
118+
// frames are not all assigned to worker 0.
119+
const ranges = distributeLayeredHybridFrameRanges(300, 6);
120+
const worker0 = ranges[0]!;
121+
const transitionInWorker0 = [];
122+
for (let i = 60; i <= 69; i++) {
123+
if (i >= worker0.start && i < worker0.end) transitionInWorker0.push(i);
124+
}
125+
expect(transitionInWorker0.length).toBe(0);
126+
});
127+
128+
it("clamps non-positive workerCount to 1", () => {
129+
const ranges = distributeLayeredHybridFrameRanges(100, 0);
130+
expect(ranges.length).toBe(1);
131+
expect(ranges[0]).toEqual({ start: 0, end: 100 });
132+
const negative = distributeLayeredHybridFrameRanges(100, -5);
133+
expect(negative.length).toBe(1);
134+
});
135+
136+
it("assigns zero-width ranges to workers past the frame budget", () => {
137+
const ranges = distributeLayeredHybridFrameRanges(5, 10);
138+
expect(ranges.length).toBe(10);
139+
expect(ranges.slice(5).every((r) => r.start === r.end)).toBe(true);
140+
});
141+
142+
it("handles zero-frame inputs", () => {
143+
const ranges = distributeLayeredHybridFrameRanges(0, 4);
144+
expect(ranges.length).toBe(4);
145+
expect(ranges.every((r) => r.start === 0 && r.end === 0)).toBe(true);
146+
});
147+
});
148+
149+
describe("partitionTransitionFrames", () => {
150+
it("returns a Set of frame indices that fall inside any transition window", () => {
151+
const ranges = [
152+
{ startFrame: 30, endFrame: 39 },
153+
{ startFrame: 120, endFrame: 125 },
154+
];
155+
const set = partitionTransitionFrames(ranges, 200);
156+
expect(set.size).toBe(10 + 6);
157+
expect(set.has(30)).toBe(true);
158+
expect(set.has(39)).toBe(true);
159+
expect(set.has(40)).toBe(false);
160+
expect(set.has(120)).toBe(true);
161+
expect(set.has(125)).toBe(true);
162+
expect(set.has(126)).toBe(false);
163+
});
164+
165+
it("clamps range endpoints to [0, totalFrames - 1]", () => {
166+
const ranges = [{ startFrame: -5, endFrame: 5 }];
167+
const set = partitionTransitionFrames(ranges, 3);
168+
expect(set.has(-1)).toBe(false);
169+
expect(set.has(0)).toBe(true);
170+
expect(set.has(2)).toBe(true);
171+
expect(set.has(3)).toBe(false);
172+
});
173+
174+
it("returns empty set for non-positive totalFrames", () => {
175+
expect(partitionTransitionFrames([{ startFrame: 0, endFrame: 5 }], 0).size).toBe(0);
176+
expect(partitionTransitionFrames([{ startFrame: 0, endFrame: 5 }], -1).size).toBe(0);
177+
});
178+
});

0 commit comments

Comments
 (0)