Skip to content

Commit d7d60d4

Browse files
committed
chore: merge main
2 parents fa95aad + 804a57c commit d7d60d4

54 files changed

Lines changed: 6218 additions & 798 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# Normalize text files to LF on checkout and in the repo, regardless of the
2+
# contributor's OS. Without this, files saved on Windows can land in PRs with
3+
# CRLF endings or a UTF-8 BOM, which makes every line differ at the byte level
4+
# and trips GitHub's "Binary file not shown" diff heuristic.
5+
* text=auto eol=lf
6+
17
# Golden baseline videos for regression tests
28
packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
39

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).

packages/core/src/runtime/init.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,58 @@ describe("initSandboxRuntimeModular", () => {
263263
expect(video.currentTime).toBe(9);
264264
});
265265

266+
it("updates visibility for timed elements inside nested compositions", () => {
267+
const root = document.createElement("div");
268+
root.setAttribute("data-composition-id", "main");
269+
root.setAttribute("data-root", "true");
270+
root.setAttribute("data-start", "0");
271+
root.setAttribute("data-width", "1920");
272+
root.setAttribute("data-height", "1080");
273+
document.body.appendChild(root);
274+
275+
const child = document.createElement("div");
276+
child.setAttribute("data-composition-id", "nested");
277+
child.setAttribute("data-start", "10");
278+
child.setAttribute("data-duration", "10");
279+
root.appendChild(child);
280+
281+
const sceneA = document.createElement("section");
282+
sceneA.id = "scene-a";
283+
sceneA.setAttribute("data-start", "0");
284+
sceneA.setAttribute("data-duration", "4");
285+
child.appendChild(sceneA);
286+
287+
const sceneB = document.createElement("section");
288+
sceneB.id = "scene-b";
289+
sceneB.setAttribute("data-start", "4");
290+
sceneB.setAttribute("data-duration", "4");
291+
child.appendChild(sceneB);
292+
293+
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
294+
main: createMockTimeline(20),
295+
nested: createMockTimeline(8),
296+
};
297+
298+
initSandboxRuntimeModular();
299+
300+
const player = (
301+
window as Window & {
302+
__player?: { seek: (timeSeconds: number) => void };
303+
}
304+
).__player;
305+
expect(player).toBeDefined();
306+
307+
player?.seek(11);
308+
309+
expect(sceneA.style.visibility).toBe("visible");
310+
expect(sceneB.style.visibility).toBe("hidden");
311+
312+
player?.seek(15);
313+
314+
expect(sceneA.style.visibility).toBe("hidden");
315+
expect(sceneB.style.visibility).toBe("visible");
316+
});
317+
266318
it("clamps nested media to the authored host window on seek", () => {
267319
const root = document.createElement("div");
268320
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,27 +1316,12 @@ export function initSandboxRuntimeModular(): void {
13161316
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
13171317
},
13181318
});
1319-
const rootCompId =
1320-
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
13211319
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
13221320
for (const rawNode of visibilityNodes) {
13231321
if (!(rawNode instanceof HTMLElement)) continue;
13241322
const tag = rawNode.tagName.toLowerCase();
13251323
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
13261324

1327-
// Skip elements INSIDE sub-compositions — their visibility is managed by GSAP,
1328-
// not the global time-based adapter. Only manage visibility for:
1329-
// 1. Composition host elements (have data-composition-id themselves)
1330-
// 2. Direct children of root composition (audio, etc.)
1331-
// Skip: elements whose nearest composition ancestor is NOT the root
1332-
const ownCompId = rawNode.getAttribute("data-composition-id");
1333-
if (!ownCompId) {
1334-
// Not a composition host — check if it's inside a sub-composition
1335-
const parentComp = rawNode.closest("[data-composition-id]");
1336-
const parentCompId = parentComp?.getAttribute("data-composition-id") ?? null;
1337-
if (parentCompId && parentCompId !== rootCompId) continue;
1338-
}
1339-
13401325
const start = resolveStartForElement(rawNode, 0);
13411326
let duration = resolveDurationForElement(rawNode);
13421327
const compId = rawNode.getAttribute("data-composition-id");

packages/producer/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,38 @@ This is not chroma keying. There is no green/blue background to remove and no "k
106106

107107
Don't paint a fullscreen background in your HTML. The default body background is overridden to transparent automatically — any `body { background: ... }`, `#root { background: ... }`, or `[data-composition-id] { background: ... }` rule is force-overridden during alpha rendering. Backgrounds on inner elements (cards, scenes, components) are kept.
108108

109+
## Distributed rendering
110+
111+
For renders too large for a single machine, the producer ships a public set of distributed-render primitives. They are pure functions over local file paths — networking and orchestration live in adapter packages (Temporal, AWS Lambda + Step Functions, Cloud Run Jobs, K8s Jobs).
112+
113+
```typescript
114+
import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed";
115+
116+
// Controller-side: produce a self-contained planDir + content-addressed planHash.
117+
const planResult = await plan(
118+
projectDir,
119+
{ fps: 30, width: 1920, height: 1080, format: "mp4" },
120+
"/tmp/plan",
121+
);
122+
123+
// Worker-side: render one chunk. Byte-identical retries on the same
124+
// `(planDir, chunkIndex)` — Temporal / Step Functions retry policies are safe
125+
// to point at this.
126+
const chunk = await renderChunk("/tmp/plan", 0, "/tmp/chunks/0.mp4");
127+
128+
// Controller-side: stitch chunks into the final deliverable.
129+
await assemble(
130+
"/tmp/plan",
131+
["/tmp/chunks/0.mp4", "/tmp/chunks/1.mp4"],
132+
"/tmp/plan/audio.aac",
133+
"/tmp/output.mp4",
134+
);
135+
```
136+
137+
The three activity functions plus their result types are also re-exported from `@hyperframes/producer` so callers that pin the main package don't need a separate subpath import. Supported formats: `mp4` SDR, `mov` ProRes 4444, and `png-sequence`. webm and HDR mp4 trip a typed `FormatNotSupportedInDistributedError` — use the in-process renderer (`executeRenderJob`) for those.
138+
139+
See [`DISTRIBUTED-RENDERING-PLAN.md`](../../DISTRIBUTED-RENDERING-PLAN.md) for the full architecture.
140+
109141
## How it works
110142

111143
1. **Serve** — spins up a local file server for the HTML composition

packages/producer/build.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,23 @@ await Promise.all([
9393
entryPoints: ["src/services/shaderTransitionWorker.ts"],
9494
outfile: "dist/services/shaderTransitionWorker.js",
9595
}),
96+
// `@hyperframes/producer/distributed` subpath — the public distributed
97+
// render primitives (plan / renderChunk / assemble). Bundled as a
98+
// separate entry so adopters that don't need the in-process renderer
99+
// (Lambda chunk workers, CDK constructs, thin orchestrators) can import
100+
// only this surface and skip the rest of the producer's dependency tree.
101+
build({
102+
bundle: true,
103+
platform: "node",
104+
target: "node22",
105+
format: "esm",
106+
external: ["puppeteer", "esbuild", "postcss"],
107+
plugins: [workspaceAliasPlugin],
108+
minify: false,
109+
sourcemap: true,
110+
entryPoints: ["src/distributed.ts"],
111+
outfile: "dist/distributed.js",
112+
}),
96113
]);
97114

98115
// Copy core runtime artifacts so the producer can find them at dist/

packages/producer/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
},
2121
"./server": {
2222
"import": "./dist/public-server.js"
23+
},
24+
"./distributed": {
25+
"import": "./dist/distributed.js",
26+
"types": "./dist/distributed.d.ts"
2327
}
2428
},
2529
"publishConfig": {
@@ -40,10 +44,12 @@
4044
"bench:hdr": "tsx src/benchmark.ts --tags hdr",
4145
"test": "tsx src/regression-harness.ts --exclude-tags transparency",
4246
"test:update": "tsx src/regression-harness.ts --update --exclude-tags transparency",
47+
"test:distributed": "tsx src/regression-harness.ts --exclude-tags transparency --mode=distributed-simulated",
4348
"test:transparency": "tsx src/transparency-test.ts",
4449
"docker:build:test": "docker build -f ../../Dockerfile.test -t hyperframes-producer:test ../..",
4550
"docker:test": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test",
4651
"docker:test:update": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --update",
52+
"docker:test:distributed": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --mode=distributed-simulated",
4753
"prepublishOnly": "echo skip"
4854
},
4955
"dependencies": {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* `@hyperframes/producer/distributed` — the distributed render primitives.
3+
*
4+
* See `DISTRIBUTED-RENDERING-PLAN.md` for the full architecture. The three
5+
* activities (`plan` → `renderChunk` × N → `assemble`) are pure functions
6+
* over local file paths; networking + orchestration live in adapters.
7+
*
8+
* Adopters (AWS Lambda, Cloud Run Jobs, Temporal, K8s Jobs, plain SSH):
9+
*
10+
* ```ts
11+
* import {
12+
* plan,
13+
* renderChunk,
14+
* assemble,
15+
* } from "@hyperframes/producer/distributed";
16+
*
17+
* // Controller-side: produce a self-contained planDir + content-addressed planHash.
18+
* const planResult = await plan(projectDir, config, planDir);
19+
*
20+
* // Worker-side: render one chunk. Byte-identical retries on the same
21+
* // (planDir, chunkIndex) — Temporal / Step Functions retry policies are
22+
* // safe to point at this.
23+
* const chunk = await renderChunk(planDir, chunkIndex, outputChunkPath);
24+
*
25+
* // Controller-side: stitch chunks into the final deliverable.
26+
* await assemble(planDir, chunkPaths, audioPath, outputPath);
27+
* ```
28+
*
29+
* No networking, no AWS SDK, no Temporal SDK — those live in adapter
30+
* packages. This module is library code only.
31+
*/
32+
33+
// ── Plan (Activity A) ───────────────────────────────────────────────────────
34+
export {
35+
// Functions
36+
buildChunkSlices,
37+
measurePlanDirBytes,
38+
plan,
39+
rejectUnsupportedDistributedFormat,
40+
resolveChunkPlan,
41+
// Types
42+
type DistributedRenderConfig,
43+
type PlanResult,
44+
// Constants
45+
DEFAULT_CHUNK_SIZE,
46+
DEFAULT_MAX_PARALLEL_CHUNKS,
47+
PLAN_DIR_SIZE_LIMIT_BYTES,
48+
// Error codes + classes
49+
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
50+
FormatNotSupportedInDistributedError,
51+
PLAN_TOO_LARGE,
52+
PlanTooLargeError,
53+
} from "./services/distributed/plan.js";
54+
55+
// ── RenderChunk (Activity B) ────────────────────────────────────────────────
56+
export {
57+
applyRuntimeEnvSnapshot,
58+
readWebGlVendorInfoFromCanvas,
59+
renderChunk,
60+
// Types
61+
type ChunkResult,
62+
// Error codes + classes
63+
FFMPEG_VERSION_MISMATCH,
64+
PLAN_HASH_MISMATCH,
65+
RenderChunkValidationError,
66+
} from "./services/distributed/renderChunk.js";
67+
68+
// ── Assemble (Activity C) ───────────────────────────────────────────────────
69+
export { assemble, type AssembleResult } from "./services/distributed/assemble.js";
70+
71+
// ── Plan-time shared types from `freezePlan` ───────────────────────────────
72+
// Re-exported so adopters that deserialize a planDir's `meta/encoder.json`
73+
// or `meta/chunks.json` see the same shapes the producer wrote them as.
74+
export type {
75+
ChunkSliceJson,
76+
CompositionMetadataJson,
77+
LockedRenderConfig,
78+
} from "./services/render/stages/freezePlan.js";

packages/producer/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,18 @@ export {
7575
runHyperframeLint,
7676
type PreparedHyperframeLintInput,
7777
} from "./services/hyperframeLint.js";
78+
79+
// ── Distributed render primitives ───────────────────────────────────────────
80+
// The full surface lives at `@hyperframes/producer/distributed`; we
81+
// additionally re-export the three activity functions + their result
82+
// types here so callers that pin `@hyperframes/producer` don't need a
83+
// separate subpath import.
84+
export {
85+
assemble,
86+
plan,
87+
renderChunk,
88+
type AssembleResult,
89+
type ChunkResult,
90+
type DistributedRenderConfig,
91+
type PlanResult,
92+
} from "./distributed.js";

0 commit comments

Comments
 (0)