chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# @hyperframes/producer
|
||||
|
||||
Full HTML-to-video rendering pipeline: capture frames with Chrome's BeginFrame API, encode with FFmpeg, mix audio — all in one call.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @hyperframes/producer
|
||||
```
|
||||
|
||||
**Requirements:** Node.js >= 22, Chrome/Chromium (auto-downloaded), FFmpeg
|
||||
|
||||
## Usage
|
||||
|
||||
### Render a video
|
||||
|
||||
```typescript
|
||||
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
|
||||
|
||||
const job = createRenderJob({
|
||||
inputPath: "./my-composition.html",
|
||||
outputPath: "./output.mp4",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
});
|
||||
|
||||
const result = await executeRenderJob(job, (progress) => {
|
||||
console.log(`${Math.round(progress.percent * 100)}%`);
|
||||
});
|
||||
|
||||
console.log(result.outputPath); // ./output.mp4
|
||||
```
|
||||
|
||||
### Run as an HTTP server
|
||||
|
||||
The producer can also run as a render server, accepting render requests over HTTP:
|
||||
|
||||
```typescript
|
||||
import { startServer } from "@hyperframes/producer";
|
||||
|
||||
await startServer({ port: 8080 });
|
||||
// POST /render with a RenderConfig body
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
`RenderConfig` controls the render pipeline:
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `inputPath` | — | Path to the HTML composition |
|
||||
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"`) |
|
||||
| `width` | 1920 | Frame width in pixels |
|
||||
| `height` | 1080 | Frame height in pixels |
|
||||
| `fps` | 30 | Frames per second (24, 30, or 60) |
|
||||
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
|
||||
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, or `"png-sequence"`. See [Transparent Video Output](#transparent-video-output) below. |
|
||||
| `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. |
|
||||
|
||||
## Transparent Video Output
|
||||
|
||||
The producer can render HTML compositions to formats that carry a **true alpha channel** — not chroma key. The same composition that renders an opaque MP4 renders a layerable overlay when you set `format`.
|
||||
|
||||
| `format` | Codec / pixel format | Alpha | Audio | Use case |
|
||||
| ----------------- | --------------------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `"mp4"` (default) | H.264 (yuv420p) or H.265 + HDR10 | No | AAC | Streaming, social, default deliverable |
|
||||
| `"webm"` | VP9 + yuva420p | **True alpha** | Opus | Web playback as overlay (`<video>` over background); supported in Chrome, Edge, Firefox |
|
||||
| `"mov"` | ProRes 4444 + yuva444p10le | **True alpha + 10-bit** | AAC | Editor ingest (Premiere, Final Cut Pro, DaVinci Resolve) |
|
||||
| `"png-sequence"` | Numbered RGBA PNGs in a directory | **Lossless alpha** | Sidecar `audio.aac` | After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding |
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
|
||||
|
||||
const job = createRenderJob({
|
||||
inputPath: "./my-composition.html",
|
||||
outputPath: "./output.webm", // or a directory for "png-sequence"
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
format: "webm", // "mp4" | "webm" | "mov" | "png-sequence"
|
||||
});
|
||||
|
||||
await executeRenderJob(job);
|
||||
```
|
||||
|
||||
### What "transparent background" means here
|
||||
|
||||
The producer captures Chrome screenshots with the page background forced transparent (`html, body, [data-composition-id] { background: transparent !important }`) and the CDP default background override set to RGBA 0,0,0,0. The captured PNGs carry a real alpha channel and that channel is preserved end-to-end:
|
||||
|
||||
- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, `-cpu-used 4` by default, and `alpha_mode=1` metadata. Tune the speed/quality tradeoff with `PRODUCER_VP9_CPU_USED` (`-8` to `8`) or local CLI `--vp9-cpu-used`.
|
||||
- ProRes 4444 (`mov`) is encoded with `-pix_fmt yuva444p10le`.
|
||||
- PNG sequences are written without re-encoding (zero-padded `frame_NNNNNN.png`).
|
||||
|
||||
This is not chroma keying. There is no green/blue background to remove and no "key" tolerance to tune — pixels that were transparent in the browser are transparent in the output.
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Linux + alpha forces screenshot capture.** Chrome's BeginFrame compositor (the default deterministic capture path on Linux headless-shell) does not preserve alpha; the orchestrator falls back to `Page.captureScreenshot`, which is slower per frame. macOS and Windows already use screenshot mode by default, so they are unaffected.
|
||||
- **HDR + alpha is not supported.** Setting `hdr: true` together with an alpha-capable format logs a warning and falls back to SDR. Use `format: "mp4"` for HDR10 output.
|
||||
- **`png-sequence` does not produce a single muxed file.** When the composition contains audio elements, an `audio.aac` sidecar is written alongside the PNGs in `outputPath`.
|
||||
- **Safari + WebM alpha is incomplete.** For broad browser playback of an alpha video, ship `format: "mov"` to your editor and re-encode for the codec your distribution target supports.
|
||||
|
||||
### Authoring transparent compositions
|
||||
|
||||
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.
|
||||
|
||||
## Distributed rendering
|
||||
|
||||
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).
|
||||
|
||||
```typescript
|
||||
import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed";
|
||||
|
||||
// Controller-side: produce a self-contained planDir + content-addressed planHash.
|
||||
const planResult = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 1920, height: 1080, format: "mp4" },
|
||||
"/tmp/plan",
|
||||
);
|
||||
|
||||
// Worker-side: render one chunk. Byte-identical retries on the same
|
||||
// `(planDir, chunkIndex)` — Temporal / Step Functions retry policies are safe
|
||||
// to point at this.
|
||||
const chunk = await renderChunk("/tmp/plan", 0, "/tmp/chunks/0.mp4");
|
||||
|
||||
// Controller-side: stitch chunks into the final deliverable.
|
||||
await assemble(
|
||||
"/tmp/plan",
|
||||
["/tmp/chunks/0.mp4", "/tmp/chunks/1.mp4"],
|
||||
"/tmp/plan/audio.aac",
|
||||
"/tmp/output.mp4",
|
||||
);
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Serve** — spins up a local file server for the HTML composition
|
||||
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame` (or `Page.captureScreenshot` for transparent / non-Linux renders), captures screenshots
|
||||
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for `format: "png-sequence"`.
|
||||
4. **Mix** — extracts `<audio>` elements and mixes them into the final video. For `png-sequence`, audio is written as an `audio.aac` sidecar.
|
||||
5. **Finalize** — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and `png-sequence`)
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: [hyperframes.heygen.com/packages/producer](https://hyperframes.heygen.com/packages/producer)
|
||||
|
||||
## Related packages
|
||||
|
||||
- [`@hyperframes/core`](../core) — types, parsers, frame adapters
|
||||
- [`@hyperframes/engine`](../engine) — lower-level capture and encode primitives
|
||||
- [`hyperframes`](../cli) — CLI
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build script for @hyperframes/producer (public OSS package)
|
||||
*
|
||||
* Bundles src/server.ts → dist/public-server.js (standalone server).
|
||||
*/
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, rmSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
rmSync("dist", { recursive: true, force: true });
|
||||
mkdirSync("dist", { recursive: true });
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// The banner provides a real `require` (via createRequire) plus the CJS-only
|
||||
// `__filename`/`__dirname` globals so esbuild's CJS interop works in ESM output.
|
||||
// Without `require`, bundled CJS deps (recast, yauzl, etc.) that call
|
||||
// require("fs") throw "Dynamic require of 'fs' is not supported"; without the
|
||||
// dirname shims, deps like wawoff2 throw "__dirname is not defined in ES module".
|
||||
const cjsBanner = {
|
||||
js: `import { createRequire as __cjsRequire } from 'module';
|
||||
import { fileURLToPath as __cjsFileURLToPath } from 'url';
|
||||
import { dirname as __cjsDirname } from 'path';
|
||||
const require = __cjsRequire(import.meta.url);
|
||||
const __filename = __cjsFileURLToPath(import.meta.url);
|
||||
const __dirname = __cjsDirname(__filename);`,
|
||||
};
|
||||
|
||||
const workspaceAliasPlugin = {
|
||||
name: "workspace-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@hyperframes\/engine$/ }, () => ({
|
||||
path: resolve(scriptDir, "../engine/src/index.ts"),
|
||||
}));
|
||||
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
|
||||
path: resolve(scriptDir, "../engine/src/utils/alphaBlit.ts"),
|
||||
}));
|
||||
build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
|
||||
path: resolve(scriptDir, "../engine/src/utils/shaderTransitions.ts"),
|
||||
}));
|
||||
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
|
||||
path: resolve(scriptDir, "../core/src/index.ts"),
|
||||
}));
|
||||
build.onResolve({ filter: /^@hyperframes\/core\/lint$/ }, () => ({
|
||||
path: resolve(scriptDir, "../core/src/lint/index.ts"),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const sharedOpts = {
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
format: "esm",
|
||||
external: ["puppeteer", "esbuild", "postcss"],
|
||||
plugins: [workspaceAliasPlugin],
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
banner: cjsBanner,
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }),
|
||||
build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }),
|
||||
build({
|
||||
...sharedOpts,
|
||||
entryPoints: ["src/services/shaderTransitionWorker.ts"],
|
||||
outfile: "dist/services/shaderTransitionWorker.js",
|
||||
}),
|
||||
build({
|
||||
...sharedOpts,
|
||||
entryPoints: ["src/services/healthWorkerThread.ts"],
|
||||
outfile: "dist/services/healthWorkerThread.js",
|
||||
}),
|
||||
build({ ...sharedOpts, entryPoints: ["src/distributed.ts"], outfile: "dist/distributed.js" }),
|
||||
]);
|
||||
|
||||
// Copy core runtime artifacts so the producer can find them at dist/
|
||||
import { copyFileSync, existsSync, readFileSync } from "fs";
|
||||
const coreDistDir = resolve(scriptDir, "../core/dist");
|
||||
try {
|
||||
const manifestSrc = resolve(coreDistDir, "hyperframe.manifest.json");
|
||||
if (existsSync(manifestSrc)) {
|
||||
copyFileSync(manifestSrc, "dist/hyperframe.manifest.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
|
||||
const runtimeIife = manifest?.artifacts?.iife || "hyperframe.runtime.iife.js";
|
||||
copyFileSync(resolve(coreDistDir, runtimeIife), `dist/${runtimeIife}`);
|
||||
console.log(`[Build] Copied runtime: hyperframe.manifest.json, ${runtimeIife}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Build] Warning: Could not copy runtime artifacts:", e.message);
|
||||
}
|
||||
|
||||
// Generate .d.ts declarations (esbuild doesn't emit them)
|
||||
import { execSync } from "child_process";
|
||||
execSync("tsc --emitDeclarationOnly --declaration --declarationMap", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
console.log("[Build] Complete: dist/index.js, dist/public-server.js, *.d.ts");
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# drawElement release canary suite — run before/after any DE-path change and on a
|
||||
# schedule once DE is default-on. macOS + hardware GPU only (DE never engages
|
||||
# elsewhere). Each canary renders with the full DE stack + safety net; the suite
|
||||
# asserts the render COMPLETES and the safety net's verdict matches expectation:
|
||||
# clean — DE end-to-end, no fallback; cross-checked vs a screenshot render (PSNR)
|
||||
# fallback — self-verify/blank-guard fires and the auto-fallback completes (these
|
||||
# comps have known intermittent DE damage or are defect comps; the net
|
||||
# diverting them IS the pass condition)
|
||||
# any — either outcome passes (known-flaky DE damage: fallback ~2/3 of runs)
|
||||
# Exit nonzero on any FAIL. Usage: COMP_ROOT=/tmp/cc-all bash de-canary-suite.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ROOT="${COMP_ROOT:-/tmp/cc-all}"
|
||||
OUT="${1:-/tmp/de-canary-suite}"
|
||||
MIN_DB="${MIN_DB:-32}"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# id expectation — see fast-capture-architecture.md § Runtime safety net for provenance
|
||||
CANARIES="
|
||||
d95f20b6 any # paint-race canary; intermittent background-image drop (16.4dB when it fires)
|
||||
0531c45f clean # video comp + clip-boundary frames (Lim 6 regression guard)
|
||||
3bea8c73 clean # dense motion graphics, deterministic small dark frames (blank-guard guard)
|
||||
11c1c878 clean # media-heavy; false de-fail history (harness artifacts)
|
||||
398c0655 any # marginal DE-vs-truth divergence hovering at the 32dB line (32-34dB post seek-fix, 23dB before); flips run to run
|
||||
3e051c00 fallback # broken comp (runtime error banner) — must divert, not ship
|
||||
42e7c33e fallback # error comp — must divert, not ship
|
||||
"
|
||||
|
||||
DE_ENV="PRODUCER_BROWSER_GPU_MODE=hardware PRODUCER_ENABLE_BROWSER_POOL=false PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true HF_DE_WORKER_ENCODE=true HF_DE_BATCH=4"
|
||||
SS_ENV="PRODUCER_BROWSER_GPU_MODE=hardware PRODUCER_ENABLE_BROWSER_POOL=false PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false"
|
||||
|
||||
fail=0
|
||||
echo "$CANARIES" | grep -v '^\s*$' | while read -r pre expect _; do
|
||||
dir=$(find "$ROOT/" -maxdepth 1 -type d -name "${pre}*" | head -1)
|
||||
if [ -z "$dir" ]; then echo "FAIL $pre: comp not found under $ROOT"; exit 1; fi
|
||||
log="$OUT/$pre.log"
|
||||
env $DE_ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 bun we-render.mjs "$dir" "$OUT/$pre-de.mp4" </dev/null >"$log" 2>&1 || true
|
||||
if ! grep -q "RENDER_OK" "$log"; then echo "FAIL $pre: render did not complete (see $log)"; exit 1; fi
|
||||
if grep -q "re-rendering via screenshot" "$log"; then verdict=fallback; else verdict=clean; fi
|
||||
case "$expect" in
|
||||
any) : ;;
|
||||
"$verdict") : ;;
|
||||
*) echo "FAIL $pre: expected $expect, got $verdict (see $log)"; exit 1 ;;
|
||||
esac
|
||||
# Clean DE renders get a cross-path quality check against a screenshot render.
|
||||
# "any"-expectation comps are exempt: they are in the suite BECAUSE their
|
||||
# DE-vs-screenshot agreement is marginal/intermittent — the check would fail
|
||||
# on their intrinsic divergence, not on a regression.
|
||||
if [ "$verdict" = clean ] && [ "$expect" != any ]; then
|
||||
env $SS_ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 bun we-render.mjs "$dir" "$OUT/$pre-ss.mp4" </dev/null >"$OUT/$pre-ss.log" 2>&1 || true
|
||||
if ! grep -q "RENDER_OK" "$OUT/$pre-ss.log"; then echo "FAIL $pre: screenshot arm failed"; exit 1; fi
|
||||
db=$(ffmpeg -nostdin -hide_banner -i "$OUT/$pre-de.mp4" -i "$OUT/$pre-ss.mp4" -lavfi psnr -f null - 2>&1 \
|
||||
| grep -oE "average:(inf|[0-9.]+)" | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$db" ]; then echo "FAIL $pre: PSNR compare produced no value"; exit 1; fi
|
||||
if [ "$db" != "inf" ] && awk -v d="$db" -v m="$MIN_DB" 'BEGIN{exit !(d<m)}'; then
|
||||
echo "FAIL $pre: DE-vs-screenshot ${db}dB < ${MIN_DB}dB"; exit 1
|
||||
fi
|
||||
echo "PASS $pre ($verdict, ${db:-?}dB)"
|
||||
else
|
||||
echo "PASS $pre ($verdict)"
|
||||
fi
|
||||
done || fail=1
|
||||
[ "$fail" = 0 ] && echo "CANARY_SUITE_OK" || { echo "CANARY_SUITE_FAILED"; exit 1; }
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Init-only gate classification over the full 500. The gate decision is logged at init
|
||||
# ("drawElement canvas injected" OR "falling back to ... — <reason>"), so launch the fast
|
||||
# render, wait for that line, kill before frames render. Writes /tmp/gatecheck/<pre>.txt.
|
||||
# Resumable. PAR workers. Usage: PAR=4 bash de-gatecheck.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
OUT=/tmp/gatecheck; mkdir -p "$OUT"
|
||||
PAR=${PAR:-4}
|
||||
SAMPLE=${SAMPLE:-/tmp/sweep-sample.txt}
|
||||
GPU="PRODUCER_BROWSER_GPU_MODE=hardware PRODUCER_ENABLE_BROWSER_POOL=false HF_DE_WORKER_ENCODE=false HF_STATIC_DEDUP=false PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true"
|
||||
export GPU OUT CAP
|
||||
worker(){
|
||||
local pre=$1
|
||||
[ -f "$OUT/$pre.txt" ] && return
|
||||
local dir; dir=$(find /tmp/cc-all/ -maxdepth 1 -type d -name "${pre}*" | head -1 || true)
|
||||
[ -z "$dir" ] && { echo "$pre NODIR" > "$OUT/$pre.txt"; return; }
|
||||
local log=/tmp/gc-$pre.log
|
||||
( eval "env $GPU bun we-render.mjs \"$dir\" /tmp/gc-$pre.mp4" >"$log" 2>&1 </dev/null ) & local p=$!
|
||||
local t=0
|
||||
while kill -0 "$p" 2>/dev/null; do
|
||||
sleep 1; t=$((t+1))
|
||||
if grep -qE "drawElement canvas injected|falling back to (screenshot|beginframe) capture —|Fast capture: composition uses|render-mode compatibility hint" "$log" 2>/dev/null; then break; fi
|
||||
[ "$t" -ge "${CAP:-120}" ] && break
|
||||
done
|
||||
# kill the render tree (don't need full render)
|
||||
for c in $(pgrep -P "$p" 2>/dev/null || true); do kill -9 "$c" 2>/dev/null || true; done
|
||||
kill -9 "$p" 2>/dev/null || true
|
||||
pkill -f "we-render.mjs $dir" 2>/dev/null || true
|
||||
wait "$p" 2>/dev/null || true
|
||||
if grep -q "drawElement canvas injected" "$log" 2>/dev/null; then
|
||||
echo "$pre drawelement" > "$OUT/$pre.txt"
|
||||
else
|
||||
local reason; reason=$(grep -oE "falling back to (screenshot|beginframe) capture — [^(]*" "$log" | head -1 | sed -E 's/.*— //' || true)
|
||||
# Compile-time gates (3D / mix-blend-mode) log a different shape than the
|
||||
# engine's init-time gates — "Fast capture: composition uses X — disabling".
|
||||
if [ -z "$reason" ]; then
|
||||
reason=$(grep -oE "Fast capture: composition uses [a-z0-9 -]*" "$log" | head -1 | sed -E 's/.*uses /compile:/' || true)
|
||||
fi
|
||||
# Compat-hint routing (raw rAF etc.): "fast capture: falling back to screenshot — render-mode ..."
|
||||
if [ -z "$reason" ] && grep -q "render-mode compatibility hint" "$log" 2>/dev/null; then
|
||||
reason="render-mode compat hint (raw rAF/alpha)"
|
||||
fi
|
||||
if [ -n "$reason" ]; then echo "$pre gated $reason" > "$OUT/$pre.txt"
|
||||
elif grep -q "zero duration" "$log" 2>/dev/null; then echo "$pre comp-defect zero-duration" > "$OUT/$pre.txt"
|
||||
elif grep -qiE "Failure summary|error:" "$log" 2>/dev/null; then echo "$pre comp-defect error" > "$OUT/$pre.txt"
|
||||
else echo "$pre unknown" > "$OUT/$pre.txt"; fi
|
||||
fi
|
||||
rm -f /tmp/gc-$pre.mp4 /tmp/gc-$pre.log
|
||||
}
|
||||
export -f worker
|
||||
awk '{print $1}' "$SAMPLE" | xargs -P "$PAR" -I{} bash -c 'set -uo pipefail; worker "$@"' _ {} || true
|
||||
echo "GATECHECK_DONE ($(ls "$OUT"/*.txt 2>/dev/null | wc -l | tr -d ' ') comps)"
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.7.55",
|
||||
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/producer"
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./server": {
|
||||
"import": "./dist/public-server.js"
|
||||
},
|
||||
"./distributed": {
|
||||
"import": "./dist/distributed.js",
|
||||
"types": "./dist/distributed.d.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run build:fonts && bun run build:hf-early-stub && bun run --cwd ../.. build:hyperframes-runtime:modular && node build.mjs",
|
||||
"build:fonts": "node scripts/build-fonts.mjs",
|
||||
"build:hf-early-stub": "bun run scripts/build-hf-early-stub.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"parity:check": "tsx src/parity-harness.ts",
|
||||
"parity:fixtures": "tsx src/parity-fixtures.ts",
|
||||
"parity:fixtures:ci": "tsx src/parity-fixtures.ts",
|
||||
"parity:check:ci": "tsx src/parity-harness.ts --preview-url \"http://127.0.0.1:4173/minimal-wysiwyg.html\" --producer-url \"http://127.0.0.1:4173/minimal-wysiwyg.html?mode=producer\" --checkpoints \"0,0.5,1,1.5\" --allow-mismatch-ratio 0 --emulate-producer-swap true --artifacts-dir \".debug/parity-harness-ci\"",
|
||||
"perf:gate": "tsx src/perf-gate.ts",
|
||||
"check:runtime-conformance": "tsx src/runtime-conformance.ts",
|
||||
"benchmark": "tsx src/benchmark.ts",
|
||||
"bench:hdr": "tsx src/benchmark.ts --tags hdr",
|
||||
"test": "bun run test:unit",
|
||||
"test:classification": "node --test scripts/test-classification.test.mjs && node scripts/check-test-classification.mjs",
|
||||
"test:unit": "bun run test:classification && node scripts/run-test-lane.mjs unit",
|
||||
"test:unit:bun": "node scripts/run-test-lane.mjs unit bun",
|
||||
"test:unit:vitest": "node scripts/run-test-lane.mjs unit vitest",
|
||||
"test:integration": "bun run test:classification && node scripts/run-test-lane.mjs integration",
|
||||
"test:integration:bun": "node scripts/run-test-lane.mjs integration bun",
|
||||
"test:integration:vitest": "node scripts/run-test-lane.mjs integration vitest",
|
||||
"test:regression": "tsx src/regression-harness.ts --exclude-tags transparency",
|
||||
"test:regression:update": "tsx src/regression-harness.ts --update --exclude-tags transparency",
|
||||
"test:distributed": "tsx src/regression-harness.ts --exclude-tags transparency --mode=distributed-simulated",
|
||||
"test:transparency": "tsx src/transparency-test.ts",
|
||||
"docker:build:test": "docker build -f ../../Dockerfile.test -t hyperframes-producer:test ../..",
|
||||
"docker:test": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test",
|
||||
"docker:test:update": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --update",
|
||||
"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",
|
||||
"test:lambda-local": "tsx src/regression-harness.ts --exclude-tags transparency --mode=lambda-local",
|
||||
"docker:test:lambda-local": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --mode=lambda-local",
|
||||
"prepublishOnly": "echo skip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/league-gothic": "^5.2.8",
|
||||
"@fontsource/montserrat": "^5.2.8",
|
||||
"@fontsource/nunito": "^5.2.7",
|
||||
"@fontsource/oswald": "^5.2.8",
|
||||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
"@hyperframes/engine": "workspace:^",
|
||||
"@hyperframes/lint": "workspace:^",
|
||||
"@hyperframes/parsers": "workspace:^",
|
||||
"@hyperframes/studio-server": "workspace:^",
|
||||
"hono": "^4.6.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.4.0",
|
||||
"puppeteer": "^25.2.1",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
"wawoff2": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/lato": "^5.2.7",
|
||||
"@fontsource/noto-sans-jp": "^5.2.9",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@fontsource/playfair-display": "^5.2.8",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@fontsource/source-code-pro": "^5.2.7",
|
||||
"@hyperframes/aws-lambda": "workspace:*",
|
||||
"@types/node": "^25.0.10",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"esbuild": "^0.25.12",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const target = "src/services/fontData.generated.ts";
|
||||
|
||||
if (existsSync(target)) {
|
||||
console.log("[build:fonts] skipped — fontData.generated.ts already exists");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
execSync("node --experimental-strip-types scripts/generate-font-data.ts", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Build script: compile stubs/hf-early-stub.ts → src/generated/hf-early-stub-inline.ts
|
||||
*
|
||||
* Run via: bun run scripts/build-hf-early-stub.ts
|
||||
* (also called automatically as part of `bun run build`)
|
||||
*
|
||||
* Output format mirrors packages/core/scripts/build-hyperframes-runtime-artifact.ts:
|
||||
* a TypeScript module exporting a single string-constant getter that is
|
||||
* compiled by tsc into dist/ — no esbuild, no file I/O, no dynamic paths at
|
||||
* runtime.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildSync } from "esbuild";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(thisDir, "..");
|
||||
|
||||
const stubEntry = resolve(repoRoot, "stubs/hf-early-stub.ts");
|
||||
const generatedDir = resolve(repoRoot, "src/generated");
|
||||
const outPath = resolve(generatedDir, "hf-early-stub-inline.ts");
|
||||
|
||||
// ── Compile the stub to a self-contained IIFE ─────────────────────────────────
|
||||
const result = buildSync({
|
||||
entryPoints: [stubEntry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
target: ["es2020"],
|
||||
// Minify for production — the stub is injected on every page load.
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
});
|
||||
|
||||
const iife = result.outputFiles[0]?.text ?? "";
|
||||
if (!iife) {
|
||||
throw new Error("esbuild produced no output for hf-early-stub.ts");
|
||||
}
|
||||
|
||||
// ── Write the generated module ────────────────────────────────────────────────
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
|
||||
const escaped = JSON.stringify(iife);
|
||||
writeFileSync(
|
||||
outPath,
|
||||
[
|
||||
"// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit",
|
||||
`const HF_EARLY_STUB_IIFE: string = ${escaped};`,
|
||||
"",
|
||||
"/**",
|
||||
" * Returns the pre-built HyperFrames early stub IIFE as a string constant.",
|
||||
" * Inject into <head> before any other scripts so the GSAP batching",
|
||||
" * interceptor is in place when user composition scripts run.",
|
||||
" */",
|
||||
"export function getHfEarlyStub(): string {",
|
||||
" return HF_EARLY_STUB_IIFE;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Format the generated file so `oxfmt --check` passes in CI.
|
||||
// Errors are intentionally swallowed — oxfmt unavailable in some envs.
|
||||
try {
|
||||
execSync(`bunx oxfmt ${outPath}`, { stdio: "ignore" });
|
||||
} catch {
|
||||
// not fatal
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hf_early_stub_generated",
|
||||
stubEntry,
|
||||
outPath,
|
||||
bytes: Buffer.byteLength(iife, "utf8"),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { discoverProducerTests, summarizeTests } from "./test-classification.mjs";
|
||||
|
||||
try {
|
||||
const tests = discoverProducerTests();
|
||||
console.log(JSON.stringify({ event: "producer_tests_classified", ...summarizeTests(tests) }));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Generate embedded font data for deterministic font injection.
|
||||
*
|
||||
* Reads woff2 files from @fontsource/* packages at build time and produces
|
||||
* a TypeScript module with base64 data URIs. This eliminates the runtime
|
||||
* dependency on @fontsource packages, making the CLI self-contained when
|
||||
* bundled via tsup.
|
||||
*
|
||||
* Usage: tsx scripts/generate-font-data.ts
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type FontFaceSpec = { weight: string; style?: "normal" | "italic" };
|
||||
type CanonicalFontSpec = { packageName: string; faces: FontFaceSpec[] };
|
||||
|
||||
// Mirror of CANONICAL_FONTS from deterministicFonts.ts — single source of truth
|
||||
const CANONICAL_FONTS: Record<string, CanonicalFontSpec> = {
|
||||
inter: {
|
||||
packageName: "@fontsource/inter",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
montserrat: {
|
||||
packageName: "@fontsource/montserrat",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
outfit: {
|
||||
packageName: "@fontsource/outfit",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
nunito: {
|
||||
packageName: "@fontsource/nunito",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
oswald: {
|
||||
packageName: "@fontsource/oswald",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"league-gothic": {
|
||||
packageName: "@fontsource/league-gothic",
|
||||
faces: [{ weight: "400" }],
|
||||
},
|
||||
"archivo-black": {
|
||||
packageName: "@fontsource/archivo-black",
|
||||
faces: [{ weight: "400" }],
|
||||
},
|
||||
"space-mono": {
|
||||
packageName: "@fontsource/space-mono",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"ibm-plex-mono": {
|
||||
packageName: "@fontsource/ibm-plex-mono",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"jetbrains-mono": {
|
||||
packageName: "@fontsource/jetbrains-mono",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"eb-garamond": {
|
||||
packageName: "@fontsource/eb-garamond",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"playfair-display": {
|
||||
packageName: "@fontsource/playfair-display",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
"source-code-pro": {
|
||||
packageName: "@fontsource/source-code-pro",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"noto-sans-jp": {
|
||||
packageName: "@fontsource/noto-sans-jp",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
roboto: {
|
||||
packageName: "@fontsource/roboto",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
"open-sans": {
|
||||
packageName: "@fontsource/open-sans",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
lato: {
|
||||
packageName: "@fontsource/lato",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
poppins: {
|
||||
packageName: "@fontsource/poppins",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
};
|
||||
|
||||
function packageRoot(packageName: string): string {
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`);
|
||||
return dirname(packageJsonPath);
|
||||
}
|
||||
|
||||
function resolveFontFile(
|
||||
packageName: string,
|
||||
weight: string,
|
||||
style: "normal" | "italic" = "normal",
|
||||
): string {
|
||||
const root = packageRoot(packageName);
|
||||
const filesDir = join(root, "files");
|
||||
const slug = packageName.replace("@fontsource/", "");
|
||||
const files = readdirSync(filesDir);
|
||||
|
||||
const exact = `${slug}-latin-${weight}-${style}.woff2`;
|
||||
if (files.includes(exact)) {
|
||||
return join(filesDir, exact);
|
||||
}
|
||||
|
||||
const relaxed = files.find((file) => {
|
||||
return file.endsWith(`-${weight}-${style}.woff2`) && file.includes("-latin-");
|
||||
});
|
||||
if (relaxed) {
|
||||
return join(filesDir, relaxed);
|
||||
}
|
||||
|
||||
throw new Error(`No font asset found for ${packageName} weight=${weight} style=${style}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const entries: Array<{ key: string; dataUri: string }> = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const [, spec] of Object.entries(CANONICAL_FONTS)) {
|
||||
for (const face of spec.faces) {
|
||||
const style = face.style || "normal";
|
||||
const key = `${spec.packageName}:${face.weight}:${style}`;
|
||||
const fontPath = resolveFontFile(spec.packageName, face.weight, style);
|
||||
const content = readFileSync(fontPath);
|
||||
totalBytes += content.length;
|
||||
const dataUri = `data:font/woff2;base64,${content.toString("base64")}`;
|
||||
entries.push({ key, dataUri });
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"/**",
|
||||
" * AUTO-GENERATED — do not edit manually.",
|
||||
` * Generated by: scripts/generate-font-data.ts`,
|
||||
` * ${entries.length} font faces, ${Math.round(totalBytes / 1024)}KB raw woff2`,
|
||||
" */",
|
||||
"",
|
||||
"export const EMBEDDED_FONT_DATA: ReadonlyMap<string, string> = new Map([",
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
lines.push(` ["${entry.key}", "${entry.dataUri}"],`);
|
||||
}
|
||||
|
||||
lines.push("]);");
|
||||
lines.push("");
|
||||
|
||||
const outputPath = resolve(__dirname, "../src/services/fontData.generated.ts");
|
||||
writeFileSync(outputPath, lines.join("\n"), "utf8");
|
||||
console.log(
|
||||
`[generate-font-data] Wrote ${entries.length} font faces (${Math.round(totalBytes / 1024)}KB) → ${outputPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { discoverProducerTests, PRODUCER_ROOT } from "./test-classification.mjs";
|
||||
|
||||
const lane = process.argv[2];
|
||||
const requestedRunner = process.argv[3];
|
||||
if (lane !== "unit" && lane !== "integration") {
|
||||
throw new Error("Usage: node scripts/run-test-lane.mjs <unit|integration> [bun|vitest]");
|
||||
}
|
||||
if (requestedRunner && requestedRunner !== "bun" && requestedRunner !== "vitest") {
|
||||
throw new Error(`Unknown test runner: ${requestedRunner}`);
|
||||
}
|
||||
|
||||
const tests = discoverProducerTests().filter(
|
||||
(test) => test.lane === lane && (!requestedRunner || test.runner === requestedRunner),
|
||||
);
|
||||
|
||||
function run(args) {
|
||||
const result = spawnSync("bun", args, {
|
||||
cwd: PRODUCER_ROOT,
|
||||
env: { ...process.env, HYPERFRAMES_TEST_LANE: lane },
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const vitestFiles = tests.filter((test) => test.runner === "vitest").map((test) => test.file);
|
||||
if (vitestFiles.length > 0) run(["x", "vitest", "run", ...vitestFiles]);
|
||||
|
||||
// Bun's mock.module registry is process-global. Run each file in a fresh
|
||||
// process so mocks from one source test cannot mutate another test's imports.
|
||||
for (const test of tests.filter((entry) => entry.runner === "bun")) {
|
||||
run(["test", test.file]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const PRODUCER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
// These tests need host capabilities such as Chrome, ffmpeg, worker threads,
|
||||
// or local sockets. Keep the list explicit so a filename-only rename does not
|
||||
// make Git/fallow re-audit thousands of unchanged test lines as new code.
|
||||
const INTEGRATION_TEST_FILES = new Set([
|
||||
"src/services/coreRuntimeBrowser.test.ts",
|
||||
"src/services/deterministicFonts-systemCapture.test.ts",
|
||||
"src/services/distributed/assemble.test.ts",
|
||||
"src/services/distributed/chunkBoundary.test.ts",
|
||||
"src/services/distributed/crossWorkerIdempotency.test.ts",
|
||||
"src/services/distributed/plan.test.ts",
|
||||
"src/services/distributed/planSizeCap.test.ts",
|
||||
"src/services/distributed/renderChunk.test.ts",
|
||||
"src/services/fileServer.test.ts",
|
||||
"src/services/healthWorker.test.ts",
|
||||
"src/utils/audioRegression.test.ts",
|
||||
"src/utils/streamDurationParity.test.ts",
|
||||
]);
|
||||
|
||||
function collectTestFiles(directory, files = []) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) collectTestFiles(entryPath, files);
|
||||
else if (entry.isFile() && entry.name.endsWith(".test.ts")) files.push(entryPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function classifyTestSource(filePath, source, integrationFiles = INTEGRATION_TEST_FILES) {
|
||||
const importsBun = /\bfrom\s+["']bun:test["']/.test(source);
|
||||
const importsVitest = /\bfrom\s+["']vitest["']/.test(source);
|
||||
if (importsBun === importsVitest) {
|
||||
const detail = importsBun ? "imports both bun:test and vitest" : "imports neither runner";
|
||||
throw new Error(`${filePath}: ${detail}`);
|
||||
}
|
||||
return {
|
||||
file: filePath,
|
||||
runner: importsBun ? "bun" : "vitest",
|
||||
lane: integrationFiles.has(filePath) ? "integration" : "unit",
|
||||
};
|
||||
}
|
||||
|
||||
export function discoverProducerTests(producerRoot = PRODUCER_ROOT) {
|
||||
const srcDir = resolve(producerRoot, "src");
|
||||
const files = collectTestFiles(srcDir).map((absolutePath) => ({
|
||||
absolutePath,
|
||||
filePath: relative(producerRoot, absolutePath).replaceAll("\\", "/"),
|
||||
}));
|
||||
const discoveredFiles = new Set(files.map((test) => test.filePath));
|
||||
const staleEntries = [...INTEGRATION_TEST_FILES].filter((file) => !discoveredFiles.has(file));
|
||||
if (staleEntries.length > 0) {
|
||||
throw new Error(`Integration test manifest contains missing files: ${staleEntries.join(", ")}`);
|
||||
}
|
||||
return files
|
||||
.map(({ absolutePath, filePath }) => {
|
||||
return classifyTestSource(filePath, readFileSync(absolutePath, "utf8"));
|
||||
})
|
||||
.sort((left, right) => left.file.localeCompare(right.file));
|
||||
}
|
||||
|
||||
export function summarizeTests(tests) {
|
||||
const summary = {
|
||||
total: tests.length,
|
||||
unit: { bun: 0, vitest: 0 },
|
||||
integration: { bun: 0, vitest: 0 },
|
||||
};
|
||||
for (const test of tests) summary[test.lane][test.runner] += 1;
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
classifyTestSource,
|
||||
discoverProducerTests,
|
||||
summarizeTests,
|
||||
} from "./test-classification.mjs";
|
||||
|
||||
describe("producer test classification", () => {
|
||||
it("classifies each supported runner into one lane", () => {
|
||||
assert.deepEqual(classifyTestSource("src/a.test.ts", 'import { it } from "bun:test";'), {
|
||||
file: "src/a.test.ts",
|
||||
runner: "bun",
|
||||
lane: "unit",
|
||||
});
|
||||
assert.deepEqual(
|
||||
classifyTestSource(
|
||||
"src/a.test.ts",
|
||||
'import { it } from "vitest";',
|
||||
new Set(["src/a.test.ts"]),
|
||||
),
|
||||
{ file: "src/a.test.ts", runner: "vitest", lane: "integration" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing and ambiguous runner imports", () => {
|
||||
assert.throws(() => classifyTestSource("src/a.test.ts", "export {};"), /neither runner/);
|
||||
assert.throws(
|
||||
() =>
|
||||
classifyTestSource(
|
||||
"src/a.test.ts",
|
||||
'import { it } from "bun:test"; import { expect } from "vitest";',
|
||||
),
|
||||
/both bun:test and vitest/,
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies every current source test exactly once", () => {
|
||||
const tests = discoverProducerTests();
|
||||
assert.equal(new Set(tests.map((test) => test.file)).size, tests.length);
|
||||
const summary = summarizeTests(tests);
|
||||
assert.ok(summary.total > 0);
|
||||
assert.equal(
|
||||
summary.unit.bun + summary.unit.vitest + summary.integration.bun + summary.integration.vitest,
|
||||
summary.total,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Validate the fast-capture (drawElementImage) VIDEO path on real Linux.
|
||||
*
|
||||
* drawElementImage draws a snapshot taken at the paint event; capturing video
|
||||
* needs a fresh per-frame paint. On Linux headless-shell that paint comes from
|
||||
* the per-frame HeadlessExperimental.beginFrame — so video should capture
|
||||
* correctly there (see docs/fast-capture-limitations.md, Limitation 2). This
|
||||
* could not be validated under Docker-on-rosetta (renders hung); this script is
|
||||
* meant to run on a native amd64 Linux runner inside Dockerfile.test.
|
||||
*
|
||||
* Renders a video composition twice — baseline (screenshot) and fast
|
||||
* (drawElement) — and asserts the fast output matches the baseline (PSNR above
|
||||
* threshold), proving the video was captured and not dropped to black.
|
||||
*
|
||||
* PRODUCER_VALIDATE_COMP=sub-composition-video \
|
||||
* bunx tsx scripts/validate-fast-video.ts
|
||||
*
|
||||
* Exit 0 = fast video matches baseline; exit 1 = regression (black/stale video).
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createRenderJob, executeRenderJob } from "../src/index.js";
|
||||
|
||||
// `||` not `??` — the workflow passes empty strings on a push trigger (inputs
|
||||
// are only populated for workflow_dispatch), and "" must fall through to the default.
|
||||
const COMP = process.env.PRODUCER_VALIDATE_COMP || "sub-composition-video";
|
||||
const MIN_PSNR = Number.parseFloat(process.env.PRODUCER_VALIDATE_MIN_PSNR || "25");
|
||||
const work = mkdtempSync(join(tmpdir(), "fastvideo-"));
|
||||
|
||||
process.env.PRODUCER_ENABLE_BROWSER_POOL = "false";
|
||||
|
||||
async function render(mode: "baseline" | "fast", out: string): Promise<void> {
|
||||
process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = mode === "fast" ? "true" : "false";
|
||||
const job = createRenderJob({
|
||||
fps: 30,
|
||||
quality: "high",
|
||||
format: "mp4",
|
||||
workers: 1,
|
||||
useGpu: false,
|
||||
hdrMode: "force-sdr",
|
||||
});
|
||||
await executeRenderJob(job, resolve("tests", COMP, "src"), out);
|
||||
}
|
||||
|
||||
function psnr(a: string, b: string): number {
|
||||
const out = execFileSync(
|
||||
"bash",
|
||||
["-c", `ffmpeg -y -i "${a}" -i "${b}" -lavfi psnr -f null - 2>&1`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const m = out.match(/average:(\S+)/);
|
||||
if (!m) throw new Error(`ffmpeg psnr produced no average:\n${out}`);
|
||||
return m[1] === "inf" ? Number.POSITIVE_INFINITY : Number.parseFloat(m[1]);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const baseline = join(work, "baseline.mp4");
|
||||
const fast = join(work, "fast.mp4");
|
||||
console.log(`[validate-fast-video] comp=${COMP} minPsnr=${MIN_PSNR}`);
|
||||
await render("baseline", baseline);
|
||||
await render("fast", fast);
|
||||
const db = psnr(baseline, fast);
|
||||
console.log(`[validate-fast-video] fast-vs-baseline PSNR = ${db} dB`);
|
||||
if (db < MIN_PSNR) {
|
||||
console.error(
|
||||
`[validate-fast-video] FAIL — ${db} dB < ${MIN_PSNR} dB. Fast capture dropped video ` +
|
||||
`(stale/black snapshot). The Linux BeginFrame paint path is not capturing video.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("[validate-fast-video] PASS — fast video matches baseline.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Render Benchmark
|
||||
*
|
||||
* Runs each test fixture multiple times and records per-stage timing
|
||||
* plus peak heap/RSS memory. Results are saved to
|
||||
* producer/tests/perf/benchmark-results.json.
|
||||
*
|
||||
* Usage:
|
||||
* bun run benchmark # 3 runs per fixture (default)
|
||||
* bun run benchmark -- --runs 5 # 5 runs per fixture
|
||||
* bun run benchmark -- --only chat # single fixture
|
||||
* bun run benchmark -- --exclude-tags slow
|
||||
* bun run benchmark -- --tags hdr # only fixtures tagged "hdr"
|
||||
* bun run bench:hdr # convenience: --tags hdr
|
||||
*
|
||||
* `--tags` and `--exclude-tags` may be passed together; a fixture must match
|
||||
* at least one positive tag (when `--tags` is provided) AND must not match
|
||||
* any excluded tag.
|
||||
*/
|
||||
|
||||
import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createRenderJob,
|
||||
executeRenderJob,
|
||||
type RenderPerfSummary,
|
||||
} from "./services/renderOrchestrator.js";
|
||||
import { parseFps } from "@hyperframes/core";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const testsDir = resolve(scriptDir, "../tests");
|
||||
const perfDir = resolve(testsDir, "perf");
|
||||
|
||||
interface TestMeta {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
// Same on-disk shape as the regression harness — JSON `number` (integer
|
||||
// fps) or JSON `string` ("30000/1001"). Normalized to Fps when loaded.
|
||||
renderConfig: { fps: import("@hyperframes/core").Fps };
|
||||
}
|
||||
|
||||
interface BenchmarkRun {
|
||||
run: number;
|
||||
perfSummary: RenderPerfSummary;
|
||||
}
|
||||
|
||||
interface FixtureResult {
|
||||
fixture: string;
|
||||
name: string;
|
||||
runs: BenchmarkRun[];
|
||||
averages: {
|
||||
totalElapsedMs: number;
|
||||
captureAvgMs: number | null;
|
||||
/** Average of per-run peak RSS in MiB. `null` if no run reported memory. */
|
||||
peakRssMb: number | null;
|
||||
/** Average of per-run peak heapUsed in MiB. `null` if no run reported memory. */
|
||||
peakHeapUsedMb: number | null;
|
||||
stages: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
interface BenchmarkResults {
|
||||
timestamp: string;
|
||||
platform: string;
|
||||
nodeVersion: string;
|
||||
runsPerFixture: number;
|
||||
fixtures: FixtureResult[];
|
||||
}
|
||||
|
||||
interface BenchmarkArgs {
|
||||
runs: number;
|
||||
only: string | null;
|
||||
/** Positive tag filter — fixture must include at least one. Empty = no positive filter. */
|
||||
tags: string[];
|
||||
/** Negative tag filter — fixture must not include any. Applied after `tags`. */
|
||||
excludeTags: string[];
|
||||
}
|
||||
|
||||
function parseArgs(): BenchmarkArgs {
|
||||
let runs = 3;
|
||||
let only: string | null = null;
|
||||
const tags: string[] = [];
|
||||
const excludeTags: string[] = [];
|
||||
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
if (process.argv[i] === "--runs" && process.argv[i + 1]) {
|
||||
i++;
|
||||
runs = parseInt(process.argv[i] ?? "", 10);
|
||||
} else if (process.argv[i] === "--only" && process.argv[i + 1]) {
|
||||
i++;
|
||||
only = process.argv[i] ?? null;
|
||||
} else if (process.argv[i] === "--tags" && process.argv[i + 1]) {
|
||||
i++;
|
||||
tags.push(...(process.argv[i] ?? "").split(",").filter(Boolean));
|
||||
} else if (process.argv[i] === "--exclude-tags" && process.argv[i + 1]) {
|
||||
i++;
|
||||
excludeTags.push(...(process.argv[i] ?? "").split(",").filter(Boolean));
|
||||
}
|
||||
}
|
||||
|
||||
return { runs, only, tags, excludeTags };
|
||||
}
|
||||
|
||||
function discoverFixtures(
|
||||
only: string | null,
|
||||
tags: string[],
|
||||
excludeTags: string[],
|
||||
): Array<{ id: string; dir: string; meta: TestMeta }> {
|
||||
const fixtures: Array<{ id: string; dir: string; meta: TestMeta }> = [];
|
||||
|
||||
for (const entry of readdirSync(testsDir)) {
|
||||
if (entry === "perf" || entry === "parity") continue;
|
||||
const dir = join(testsDir, entry);
|
||||
const metaPath = join(dir, "meta.json");
|
||||
const srcDir = join(dir, "src");
|
||||
if (!existsSync(metaPath) || !existsSync(join(srcDir, "index.html"))) continue;
|
||||
|
||||
if (only && entry !== only) continue;
|
||||
|
||||
const rawMeta = JSON.parse(readFileSync(metaPath, "utf-8")) as {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
renderConfig: { fps: number | string };
|
||||
};
|
||||
// meta.json on disk uses a JSON `number` for legacy integer fps values
|
||||
// and a JSON `string` for new ffmpeg-style rationals (e.g. "30000/1001").
|
||||
// Normalize to the Fps rational shape so downstream code only sees the
|
||||
// structured form — same convention as the regression harness.
|
||||
const fpsParse = parseFps(rawMeta.renderConfig.fps);
|
||||
if (!fpsParse.ok) {
|
||||
throw new Error(
|
||||
`Benchmark fixture ${entry}: invalid renderConfig.fps ${JSON.stringify(rawMeta.renderConfig.fps)}`,
|
||||
);
|
||||
}
|
||||
const meta: TestMeta = {
|
||||
...rawMeta,
|
||||
renderConfig: { ...rawMeta.renderConfig, fps: fpsParse.value },
|
||||
};
|
||||
const fixtureTags = meta.tags ?? [];
|
||||
// Positive filter (--tags): if provided, fixture must match at least one.
|
||||
if (tags.length > 0 && !fixtureTags.some((t) => tags.includes(t))) continue;
|
||||
// Negative filter (--exclude-tags): always wins.
|
||||
if (excludeTags.length > 0 && fixtureTags.some((t) => excludeTags.includes(t))) continue;
|
||||
|
||||
fixtures.push({ id: entry, dir, meta });
|
||||
}
|
||||
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
function avg(nums: number[]): number {
|
||||
if (nums.length === 0) return 0;
|
||||
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Average a possibly-empty list of optional numbers. Returns `null` when no
|
||||
* defined samples exist so the JSON output stays consistent with the
|
||||
* `peakRssMb: number | null` shape the consumer (perf README, regression
|
||||
* checks) expects — silently coercing missing memory data to `0` would mask
|
||||
* older results regenerated against this harness.
|
||||
*/
|
||||
function avgOrNull(nums: Array<number | null | undefined>): number | null {
|
||||
const filtered = nums.filter((n): n is number => typeof n === "number");
|
||||
if (filtered.length === 0) return null;
|
||||
return avg(filtered);
|
||||
}
|
||||
|
||||
async function runBenchmark(): Promise<void> {
|
||||
const { runs, only, tags, excludeTags } = parseArgs();
|
||||
const fixtures = discoverFixtures(only, tags, excludeTags);
|
||||
|
||||
if (fixtures.length === 0) {
|
||||
console.error(
|
||||
`No fixtures found${tags.length ? ` matching tags=[${tags.join(",")}]` : ""}` +
|
||||
`${excludeTags.length ? ` excluding=[${excludeTags.join(",")}]` : ""}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filterDesc =
|
||||
(tags.length ? ` tags=[${tags.join(",")}]` : "") +
|
||||
(excludeTags.length ? ` exclude=[${excludeTags.join(",")}]` : "");
|
||||
console.log(`\n🏁 Benchmark: ${fixtures.length} fixture(s) × ${runs} run(s)${filterDesc}\n`);
|
||||
|
||||
const results: FixtureResult[] = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
console.log(`\n━━━ ${fixture.meta.name} (${fixture.id}) ━━━`);
|
||||
const fixtureRuns: BenchmarkRun[] = [];
|
||||
|
||||
for (let r = 0; r < runs; r++) {
|
||||
console.log(` Run ${r + 1}/${runs}...`);
|
||||
|
||||
// Copy src to temp dir for isolation
|
||||
const tmpRoot = join(tmpdir(), `benchmark-${fixture.id}-${Date.now()}`);
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
cpSync(join(fixture.dir, "src"), join(tmpRoot, "src"), { recursive: true });
|
||||
|
||||
const projectDir = join(tmpRoot, "src");
|
||||
const outputPath = join(tmpRoot, "output.mp4");
|
||||
|
||||
const job = createRenderJob({
|
||||
fps: fixture.meta.renderConfig.fps,
|
||||
quality: "high",
|
||||
debug: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await executeRenderJob(job, projectDir, outputPath);
|
||||
} catch (err) {
|
||||
console.error(` ❌ Run ${r + 1} failed: ${err instanceof Error ? err.message : err}`);
|
||||
continue;
|
||||
} finally {
|
||||
try {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (job.perfSummary) {
|
||||
fixtureRuns.push({ run: r + 1, perfSummary: job.perfSummary });
|
||||
const ps = job.perfSummary;
|
||||
const memDesc =
|
||||
ps.peakRssMb != null || ps.peakHeapUsedMb != null
|
||||
? ` | peak RSS ${ps.peakRssMb ?? "?"}MiB heap ${ps.peakHeapUsedMb ?? "?"}MiB`
|
||||
: "";
|
||||
console.log(
|
||||
` ✓ ${ps.totalElapsedMs}ms total | capture avg ${ps.captureAvgMs ?? "?"}ms/frame | ${ps.totalFrames} frames${memDesc}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fixtureRuns.length === 0) {
|
||||
console.log(` ⚠ No successful runs`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute averages
|
||||
const allStageKeys = new Set<string>();
|
||||
for (const run of fixtureRuns) {
|
||||
for (const key of Object.keys(run.perfSummary.stages)) {
|
||||
allStageKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const avgStages: Record<string, number> = {};
|
||||
for (const key of allStageKeys) {
|
||||
avgStages[key] = avg(fixtureRuns.map((r) => r.perfSummary.stages[key] ?? 0));
|
||||
}
|
||||
|
||||
const fixtureResult: FixtureResult = {
|
||||
fixture: fixture.id,
|
||||
name: fixture.meta.name,
|
||||
runs: fixtureRuns,
|
||||
averages: {
|
||||
totalElapsedMs: avg(fixtureRuns.map((r) => r.perfSummary.totalElapsedMs)),
|
||||
captureAvgMs: avgOrNull(fixtureRuns.map((r) => r.perfSummary.captureAvgMs)),
|
||||
peakRssMb: avgOrNull(fixtureRuns.map((r) => r.perfSummary.peakRssMb)),
|
||||
peakHeapUsedMb: avgOrNull(fixtureRuns.map((r) => r.perfSummary.peakHeapUsedMb)),
|
||||
stages: avgStages,
|
||||
},
|
||||
};
|
||||
|
||||
results.push(fixtureResult);
|
||||
|
||||
const memLine =
|
||||
fixtureResult.averages.peakRssMb != null || fixtureResult.averages.peakHeapUsedMb != null
|
||||
? ` | peak RSS ${fixtureResult.averages.peakRssMb ?? "?"}MiB heap ${fixtureResult.averages.peakHeapUsedMb ?? "?"}MiB`
|
||||
: "";
|
||||
console.log(`\n Average: ${fixtureResult.averages.totalElapsedMs}ms total${memLine}`);
|
||||
for (const [stage, ms] of Object.entries(fixtureResult.averages.stages)) {
|
||||
const pct = Math.round((ms / fixtureResult.averages.totalElapsedMs) * 100);
|
||||
console.log(` ${stage}: ${ms}ms (${pct}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save results
|
||||
const benchmarkResults: BenchmarkResults = {
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
nodeVersion: process.version,
|
||||
runsPerFixture: runs,
|
||||
fixtures: results,
|
||||
};
|
||||
|
||||
if (!existsSync(perfDir)) mkdirSync(perfDir, { recursive: true });
|
||||
const outputPath = join(perfDir, "benchmark-results.json");
|
||||
writeFileSync(outputPath, JSON.stringify(benchmarkResults, null, 2), "utf-8");
|
||||
|
||||
// Print summary table
|
||||
console.log("\n\n📊 BENCHMARK SUMMARY");
|
||||
console.log("═".repeat(95));
|
||||
console.log(
|
||||
"Fixture".padEnd(25) +
|
||||
"Total".padStart(10) +
|
||||
"Compile".padStart(10) +
|
||||
"Extract".padStart(10) +
|
||||
"Audio".padStart(10) +
|
||||
"Capture".padStart(10) +
|
||||
"Encode".padStart(10) +
|
||||
"PeakRSS".padStart(10) +
|
||||
"PeakHeap".padStart(10),
|
||||
);
|
||||
console.log("─".repeat(95));
|
||||
|
||||
for (const f of results) {
|
||||
const s = f.averages.stages;
|
||||
console.log(
|
||||
f.fixture.padEnd(25) +
|
||||
`${f.averages.totalElapsedMs}ms`.padStart(10) +
|
||||
`${s.compileMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.videoExtractMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.audioProcessMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.captureMs ?? "-"}ms`.padStart(10) +
|
||||
`${s.encodeMs ?? "-"}ms`.padStart(10) +
|
||||
`${f.averages.peakRssMb ?? "-"}MiB`.padStart(10) +
|
||||
`${f.averages.peakHeapUsedMb ?? "-"}MiB`.padStart(10),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("═".repeat(95));
|
||||
console.log(`\nResults saved to: ${outputPath}`);
|
||||
}
|
||||
|
||||
runBenchmark().catch((err) => {
|
||||
console.error("Benchmark failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Re-exported from @hyperframes/engine with ProducerConfig alias.
|
||||
* @see engine/src/config.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
type EngineConfig,
|
||||
type EngineConfig as ProducerConfig,
|
||||
DEFAULT_CONFIG,
|
||||
resolveConfig,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* `@hyperframes/producer/distributed` — the distributed render primitives.
|
||||
*
|
||||
* The three activities (`plan` → `renderChunk` × N → `assemble`) are pure
|
||||
* functions over local file paths; networking + orchestration live in
|
||||
* adapters.
|
||||
*
|
||||
* Adopters (AWS Lambda, Cloud Run Jobs, Temporal, K8s Jobs, plain SSH):
|
||||
*
|
||||
* ```ts
|
||||
* import {
|
||||
* plan,
|
||||
* renderChunk,
|
||||
* assemble,
|
||||
* } from "@hyperframes/producer/distributed";
|
||||
*
|
||||
* // Controller-side: produce a self-contained planDir + content-addressed planHash.
|
||||
* const planResult = await plan(projectDir, config, planDir);
|
||||
*
|
||||
* // Worker-side: render one chunk. Byte-identical retries on the same
|
||||
* // (planDir, chunkIndex) — Temporal / Step Functions retry policies are
|
||||
* // safe to point at this.
|
||||
* const chunk = await renderChunk(planDir, chunkIndex, outputChunkPath);
|
||||
*
|
||||
* // Controller-side: stitch chunks into the final deliverable.
|
||||
* await assemble(planDir, chunkPaths, audioPath, outputPath);
|
||||
* ```
|
||||
*
|
||||
* No networking, no AWS SDK, no Temporal SDK — those live in adapter
|
||||
* packages. This module is library code only.
|
||||
*/
|
||||
|
||||
// ── Plan (Activity A) ───────────────────────────────────────────────────────
|
||||
export {
|
||||
// Functions
|
||||
buildChunkSlices,
|
||||
measurePlanDirBytes,
|
||||
plan,
|
||||
rejectUnsupportedDistributedFormat,
|
||||
resolveChunkPlan,
|
||||
// Types
|
||||
type DistributedRenderConfig,
|
||||
type PlanResult,
|
||||
// Constants
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_MAX_PARALLEL_CHUNKS,
|
||||
MIN_CHUNK_SIZE,
|
||||
PLAN_DIR_SIZE_LIMIT_BYTES,
|
||||
PLAN_PROJECT_DIR_SKIP_SEGMENTS,
|
||||
// Error codes + classes
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
FormatNotSupportedInDistributedError,
|
||||
PLAN_TOO_LARGE,
|
||||
PlanTooLargeError,
|
||||
} from "./services/distributed/plan.js";
|
||||
|
||||
// ── RenderChunk (Activity B) ────────────────────────────────────────────────
|
||||
export {
|
||||
applyRuntimeEnvSnapshot,
|
||||
readWebGlVendorInfoFromCanvas,
|
||||
renderChunk,
|
||||
// Types
|
||||
type ChunkResult,
|
||||
// Error codes + classes
|
||||
FFMPEG_VERSION_MISMATCH,
|
||||
PLAN_HASH_MISMATCH,
|
||||
RenderChunkValidationError,
|
||||
} from "./services/distributed/renderChunk.js";
|
||||
|
||||
// ── Assemble (Activity C) ───────────────────────────────────────────────────
|
||||
export { assemble, type AssembleResult } from "./services/distributed/assemble.js";
|
||||
|
||||
// ── Cloud-agnostic adapter helpers ──────────────────────────────────────────
|
||||
// Shared by the distributed-render adapters (aws-lambda, gcp-cloud-run, …) so
|
||||
// the config-shape validator lives in one place; each adapter layers only its
|
||||
// own wire-format size cap on top.
|
||||
export {
|
||||
InvalidConfigError,
|
||||
type SerializableDistributedRenderConfig,
|
||||
validateDistributedRenderConfig,
|
||||
validateVariablesPayload,
|
||||
} from "./services/distributed/renderConfigValidation.js";
|
||||
export { hashProjectDir } from "./services/distributed/projectHash.js";
|
||||
|
||||
// ── Format union ────────────────────────────────────────────────────────────
|
||||
// Canonical output-format type. The aws-lambda package re-exports it so
|
||||
// CLI / adopter SDKs can derive runtime allowlists from one source.
|
||||
export type { DistributedFormat } from "./services/distributed/shared.js";
|
||||
|
||||
// ── Plan-time shared types from `freezePlan` ───────────────────────────────
|
||||
// Re-exported so adopters that deserialize a planDir's `meta/encoder.json`
|
||||
// or `meta/chunks.json` see the same shapes the producer wrote them as.
|
||||
export type {
|
||||
ChunkSliceJson,
|
||||
CompositionMetadataJson,
|
||||
LockedRenderConfig,
|
||||
} from "./services/render/stages/freezePlan.js";
|
||||
|
||||
// ── Plan-time validation errors ────────────────────────────────────────────
|
||||
// Export typed deterministic validation codes so orchestration adapters can
|
||||
// mark authoring/configuration failures as terminal while still retrying real
|
||||
// infrastructure faults.
|
||||
export {
|
||||
DISTRIBUTED_DURATION_OUT_OF_RANGE,
|
||||
MAX_DISTRIBUTED_DURATION_SECONDS,
|
||||
PlanValidationError,
|
||||
} from "./services/render/planValidation.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit
|
||||
const HF_EARLY_STUB_IIFE: string =
|
||||
'"use strict";(()=>{var P=100,x=[],s=[],w=!1,f=!1,T=new Set;function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function b(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function O(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n,e){n!=="add"&&v(e)}function h(n){if(n===null||typeof n!="object"||Array.isArray(n))return!1;let e=n;return"rotationX"in e||"rotationY"in e||"transformPerspective"in e}var d=new Set;function v(n){let e=n[0];if(e==null)return;let o=window;d.has(e)||(d.add(e),o.__hfAllTweenTargets=Array.from(d)),(h(n[1])||h(n[2]))&&(T.add(e),o.__hf3dTweenTargets=Array.from(T))}function _(n){let e=n.proxy.__hfReal,o=e[n.method];if(typeof o=="function"){let i=n.method==="add"?n.args.map(O):n.args;o.call(e,...i)}}function l(n,e,o){m(e,o);let i={proxy:n,method:e,args:o};return n.__hfQueue.push(i),s.push(i),R(),n}function y(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function t(){for(;s.length>0;){let n=s.shift();n&&(y(n),_(n))}A()}function k(){f=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function A(){f||(f=!0,b(()=>{s.length===0?k():f=!1}))}function g(){w=!1;let n=s.splice(0,P);for(let e of n)y(e),_(e);s.length>0?(w=!0,p(g)):k()}function R(){w||(w=!0,window.__hfTimelinesBuilding=!0,p(g))}var S=new Set(["to","from","fromTo","set","add"]);function I(n,e){let o=e;for(;o!==null&&o!==Object.prototype;){for(let i of Object.getOwnPropertyNames(o)){if(i==="constructor"||i==="then"||i in n||S.has(i)||i.charAt(0)==="_")continue;let u=Object.getOwnPropertyDescriptor(o,i);if(!u||typeof u.value!="function")continue;let r=u.value;n[i]=function(...c){t();let a=r.call(e,...c);return a===e?n:a}}o=Object.getPrototypeOf(o)}}function G(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...o){return l(e,"to",o)},from(...o){return l(e,"from",o)},fromTo(...o){return l(e,"fromTo",o)},set(...o){return l(e,"set",o)},add(...o){return l(e,"add",o)},pause(...o){return t(),n.pause(...o),e},play(...o){return t(),n.play(...o),e},seek(...o){return t(),n.seek(...o),e},totalTime(...o){return t(),o.length>0?(n.totalTime(...o),e):n.totalTime()},time(...o){return t(),o.length>0?(n.time(...o),e):n.time()},duration(...o){return t(),o.length>0?(n.duration(...o),e):n.duration()},getChildren(...o){t();let i=n.getChildren(...o);return Array.isArray(i)?i:[]},paused(...o){return t(),o.length>0?(n.paused(...o),e):n.paused()},timeScale(...o){return t(),o.length>0?(n.timeScale(...o),e):n.timeScale()},kill(){t(),n.kill()}};return I(e,n),x.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1,window.__hfFlushSync=()=>{t(),s.length===0&&window.__hfTimelinesBuilding&&k()};let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let o=e.timeline.bind(e);e.timeline=u=>G(o(u));for(let u of["to","from","set"]){let r=e[u];if(typeof r!="function")continue;let c=r.bind(e);e[u]=(...a)=>(m(u,a),c(...a))}let i=e.fromTo;if(typeof i=="function"){let u=i.bind(e);e.fromTo=(...r)=>(m("fromTo",r),u(...r))}}})}catch{}}})();\n';
|
||||
|
||||
/**
|
||||
* Returns the pre-built HyperFrames early stub IIFE as a string constant.
|
||||
* Inject into <head> before any other scripts so the GSAP batching
|
||||
* interceptor is in place when user composition scripts run.
|
||||
*/
|
||||
export function getHfEarlyStub(): string {
|
||||
return HF_EARLY_STUB_IIFE;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @hyperframes/producer
|
||||
*
|
||||
* Generic HTML-to-video rendering engine using Chrome's BeginFrame API.
|
||||
* Framework-agnostic: works with GSAP, Lottie, Three.js, CSS animations,
|
||||
* or any web content via configurable page contracts and hooks.
|
||||
*/
|
||||
|
||||
// ── Main rendering pipeline ─────────────────────────────────────────────────
|
||||
export {
|
||||
createRenderJob,
|
||||
executeRenderJob,
|
||||
RenderCancelledError,
|
||||
type RenderConfig,
|
||||
type RenderConfigInput,
|
||||
type RenderJob,
|
||||
type RenderStatus,
|
||||
type RenderPerfSummary,
|
||||
type ProgressCallback,
|
||||
} from "./services/renderOrchestrator.js";
|
||||
export {
|
||||
type BrowserDiagnosticSummary,
|
||||
type RenderCaptureObservability,
|
||||
type RenderObservabilitySummary,
|
||||
type RenderObservationData,
|
||||
type RenderObservationEvent,
|
||||
type RenderObservationStatus,
|
||||
} from "./services/render/observability.js";
|
||||
|
||||
// ── HTML asset localization ─────────────────────────────────────────────────
|
||||
// Rewrite remote <img>/<video>/<audio>/@font-face to same-origin local paths
|
||||
// before capture. Shared by render and `validate` so both resolve assets alike.
|
||||
export {
|
||||
localizeRemoteMediaSources,
|
||||
localizeRemoteImageSources,
|
||||
localizeRemoteFontFaces,
|
||||
} from "./services/htmlCompiler.js";
|
||||
|
||||
// ── Frame capture (lower-level) ─────────────────────────────────────────────
|
||||
export {
|
||||
createCaptureSession,
|
||||
initializeSession,
|
||||
closeCaptureSession,
|
||||
captureFrame,
|
||||
captureFrameToBuffer,
|
||||
getCompositionDuration,
|
||||
getCapturePerfSummary,
|
||||
prepareCaptureSessionForReuse,
|
||||
type CaptureOptions,
|
||||
type CaptureSession,
|
||||
type CaptureResult,
|
||||
type CapturePerfSummary,
|
||||
type BeforeCaptureHook,
|
||||
} from "./services/frameCapture.js";
|
||||
|
||||
// ── File server ─────────────────────────────────────────────────────────────
|
||||
export {
|
||||
createFileServer,
|
||||
type FileServerOptions,
|
||||
type FileServerHandle,
|
||||
} from "./services/fileServer.js";
|
||||
|
||||
// ── Video frame injection (Hyperframes-specific hook) ───────────────────────
|
||||
export { createVideoFrameInjector } from "@hyperframes/engine";
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────────
|
||||
export { resolveConfig, DEFAULT_CONFIG, type ProducerConfig } from "./config.js";
|
||||
|
||||
// ── Logger ──────────────────────────────────────────────────────────────────
|
||||
export {
|
||||
type ProducerLogger,
|
||||
type LogLevel,
|
||||
createConsoleLogger,
|
||||
defaultLogger,
|
||||
} from "./logger.js";
|
||||
|
||||
// ── Server ──────────────────────────────────────────────────────────────────
|
||||
export {
|
||||
createRenderHandlers,
|
||||
createProducerApp,
|
||||
startServer,
|
||||
type HandlerOptions,
|
||||
type ServerOptions,
|
||||
type RenderHandlers,
|
||||
} from "./server.js";
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────────────────
|
||||
export { normalizeErrorMessage } from "./utils/errorMessage.js";
|
||||
// Font localization: fetch + embed @font-face rules for requested families
|
||||
// (including those declared only via a remote <link>) so a bundled composition
|
||||
// renders with the real font instead of a fallback, regardless of network
|
||||
// timing. The render pipeline runs this in its compile stage; the CLI audit
|
||||
// paths (snapshot/check) reuse it so their captures match the render.
|
||||
export {
|
||||
injectDeterministicFontFaces,
|
||||
type InjectDeterministicFontFacesOptions,
|
||||
} from "./services/deterministicFonts.js";
|
||||
export { quantizeTimeToFrame } from "./utils/parityContract.js";
|
||||
export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js";
|
||||
|
||||
export {
|
||||
prepareHyperframeLintBody,
|
||||
runHyperframeLint,
|
||||
type PreparedHyperframeLintInput,
|
||||
} from "./services/hyperframeLint.js";
|
||||
|
||||
// ── Distributed render primitives ───────────────────────────────────────────
|
||||
// The full surface lives at `@hyperframes/producer/distributed`; we
|
||||
// additionally re-export the three activity functions + their result
|
||||
// types here so callers that pin `@hyperframes/producer` don't need a
|
||||
// separate subpath import.
|
||||
export {
|
||||
assemble,
|
||||
plan,
|
||||
renderChunk,
|
||||
type AssembleResult,
|
||||
type ChunkResult,
|
||||
type DistributedRenderConfig,
|
||||
type PlanResult,
|
||||
} from "./distributed.js";
|
||||
@@ -0,0 +1,319 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createConsoleLogger, defaultLogger } from "./logger.js";
|
||||
import type { LogLevel, ProducerLogger } from "./logger.js";
|
||||
|
||||
// `isLevelEnabled` is optional on ProducerLogger, so every call site guards
|
||||
// it with `?.`; pulled out once so the loops below stay single-branch.
|
||||
function isLevelEnabledSafe(
|
||||
log: Pick<ProducerLogger, "isLevelEnabled">,
|
||||
level: LogLevel,
|
||||
): boolean | undefined {
|
||||
return log.isLevelEnabled?.(level);
|
||||
}
|
||||
|
||||
// Shared by the isLevelEnabled matrix cases below: assert a threshold's
|
||||
// enabled levels report true and its disabled levels report false.
|
||||
function assertLevelEnabledMatrix(
|
||||
log: Pick<ProducerLogger, "isLevelEnabled">,
|
||||
enabled: ReadonlyArray<LogLevel>,
|
||||
disabled: ReadonlyArray<LogLevel>,
|
||||
): void {
|
||||
for (const lvl of enabled) {
|
||||
expect(isLevelEnabledSafe(log, lvl)).toBe(true);
|
||||
}
|
||||
for (const lvl of disabled) {
|
||||
expect(isLevelEnabledSafe(log, lvl)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The `isLevelEnabled?.("debug") ?? true` call-site gate pattern itself,
|
||||
// isolated so runGatedDebugLoop's own branch count stays at "loop + if".
|
||||
function isDebugGated(log: Pick<ProducerLogger, "isLevelEnabled">): boolean {
|
||||
return log.isLevelEnabled?.("debug") ?? true;
|
||||
}
|
||||
|
||||
// Shared by the call-site gate cases below: run the `isLevelEnabled?.("debug")
|
||||
// ?? true` pattern callers use to skip expensive meta construction, the
|
||||
// exact number of times the test needs, so each test asserts only the
|
||||
// pattern's outcome (buildCount / logged calls) and not the loop mechanics.
|
||||
function runGatedDebugLoop(
|
||||
log: Pick<ProducerLogger, "debug" | "isLevelEnabled">,
|
||||
iterations: number,
|
||||
buildMeta: () => Record<string, unknown>,
|
||||
): void {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (isDebugGated(log)) {
|
||||
log.debug("evt", buildMeta());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("createConsoleLogger", () => {
|
||||
// We capture calls to console.{log,warn,error} via `vi.fn` so we can
|
||||
// assert what would have been printed without polluting test output.
|
||||
let logSpy: ReturnType<typeof vi.fn>;
|
||||
let warnSpy: ReturnType<typeof vi.fn>;
|
||||
let errorSpy: ReturnType<typeof vi.fn>;
|
||||
let origLog: typeof console.log;
|
||||
let origWarn: typeof console.warn;
|
||||
let origError: typeof console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
origLog = console.log;
|
||||
origWarn = console.warn;
|
||||
origError = console.error;
|
||||
logSpy = vi.fn();
|
||||
warnSpy = vi.fn();
|
||||
errorSpy = vi.fn();
|
||||
console.log = logSpy as unknown as typeof console.log;
|
||||
console.warn = warnSpy as unknown as typeof console.warn;
|
||||
console.error = errorSpy as unknown as typeof console.error;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
});
|
||||
|
||||
// All four levels are stderr-bound (console.error/console.warn); console.log
|
||||
// (stdout) must never be touched, since producer runs inside CLI commands
|
||||
// whose stdout is a machine-readable contract (e.g. `validate --json`).
|
||||
describe("stdout/stderr routing", () => {
|
||||
it("info and debug write to console.error (stderr), never console.log (stdout)", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.info("info-msg");
|
||||
log.debug("debug-msg");
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy.mock.calls.map((c) => c[0])).toEqual([
|
||||
"[INFO] info-msg",
|
||||
"[DEBUG] debug-msg",
|
||||
]);
|
||||
});
|
||||
|
||||
it("warn and error keep their pre-existing channels (console.warn / console.error)", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.warn("warn-msg");
|
||||
log.error("error-msg");
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg");
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg");
|
||||
});
|
||||
|
||||
it("console.log is never called at any level", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("level filtering", () => {
|
||||
it("level=info drops debug, keeps info/warn/error", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
log.debug("debug-msg");
|
||||
log.info("info-msg");
|
||||
log.warn("warn-msg");
|
||||
log.error("error-msg");
|
||||
|
||||
// info + error both route to console.error now (info: stderr routing, error: always stderr).
|
||||
expect(errorSpy.mock.calls.length).toBe(2);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg");
|
||||
expect(errorSpy.mock.calls[1]?.[0]).toBe("[ERROR] error-msg");
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg");
|
||||
});
|
||||
|
||||
it("level=debug keeps all four levels", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
// debug + info + error all go to console.error
|
||||
expect(errorSpy.mock.calls.length).toBe(3);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d");
|
||||
expect(errorSpy.mock.calls[1]?.[0]).toBe("[INFO] i");
|
||||
expect(errorSpy.mock.calls[2]?.[0]).toBe("[ERROR] e");
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("level=warn drops info and debug, keeps warn/error", () => {
|
||||
const log = createConsoleLogger("warn");
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] e");
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("level=error drops everything except error", () => {
|
||||
const log = createConsoleLogger("error");
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(warnSpy.mock.calls.length).toBe(0);
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("default level is info", () => {
|
||||
const log = createConsoleLogger();
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] i");
|
||||
});
|
||||
});
|
||||
|
||||
describe("meta formatting", () => {
|
||||
it("appends JSON-stringified meta when provided", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
log.info("hello", { a: 1, b: "two" });
|
||||
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}');
|
||||
});
|
||||
|
||||
it("emits message only when meta is omitted", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
log.info("plain");
|
||||
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] plain");
|
||||
});
|
||||
|
||||
it("does not invoke JSON.stringify when level is filtered out", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
// A getter that throws would be invoked by JSON.stringify if the
|
||||
// logger built the meta string before the level check. We rely on
|
||||
// the call-site `isLevelEnabled` gate plus the internal `shouldLog`
|
||||
// short-circuit to prevent that.
|
||||
const trap = {
|
||||
get problem() {
|
||||
throw new Error("meta should not be stringified when level is filtered");
|
||||
},
|
||||
};
|
||||
// Should not throw — debug is below the info threshold.
|
||||
log.debug("trap", trap as unknown as Record<string, unknown>);
|
||||
expect(errorSpy.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLevelEnabled", () => {
|
||||
const cases: ReadonlyArray<{
|
||||
threshold: LogLevel;
|
||||
enabled: ReadonlyArray<LogLevel>;
|
||||
disabled: ReadonlyArray<LogLevel>;
|
||||
}> = [
|
||||
{
|
||||
threshold: "error",
|
||||
enabled: ["error"],
|
||||
disabled: ["warn", "info", "debug"],
|
||||
},
|
||||
{
|
||||
threshold: "warn",
|
||||
enabled: ["error", "warn"],
|
||||
disabled: ["info", "debug"],
|
||||
},
|
||||
{
|
||||
threshold: "info",
|
||||
enabled: ["error", "warn", "info"],
|
||||
disabled: ["debug"],
|
||||
},
|
||||
{
|
||||
threshold: "debug",
|
||||
enabled: ["error", "warn", "info", "debug"],
|
||||
disabled: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { threshold, enabled, disabled } of cases) {
|
||||
it(`level=${threshold} reports enabled levels correctly`, () => {
|
||||
const log = createConsoleLogger(threshold);
|
||||
assertLevelEnabledMatrix(log, enabled, disabled);
|
||||
});
|
||||
}
|
||||
|
||||
it("call-site gate using `?? true` short-circuits expensive meta build at info level", () => {
|
||||
// Mirrors the hot-path pattern used in renderOrchestrator: callers
|
||||
// wrap meta construction in `if (log.isLevelEnabled?.('debug') ?? true)`
|
||||
// so production (level=info) skips the work entirely.
|
||||
const log = createConsoleLogger("info");
|
||||
let buildCount = 0;
|
||||
const buildMeta = (): Record<string, unknown> => {
|
||||
buildCount += 1;
|
||||
return { expensive: true };
|
||||
};
|
||||
|
||||
runGatedDebugLoop(log, 100, buildMeta);
|
||||
|
||||
expect(buildCount).toBe(0);
|
||||
expect(errorSpy.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("call-site gate runs the meta builder when debug is enabled", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
let buildCount = 0;
|
||||
const buildMeta = (): Record<string, unknown> => {
|
||||
buildCount += 1;
|
||||
return { iter: buildCount };
|
||||
};
|
||||
|
||||
runGatedDebugLoop(log, 5, buildMeta);
|
||||
|
||||
expect(buildCount).toBe(5);
|
||||
expect(errorSpy.mock.calls.length).toBe(5);
|
||||
});
|
||||
|
||||
it("custom logger without isLevelEnabled falls back to running the meta builder (`?? true`)", () => {
|
||||
// A user-provided logger that doesn't implement isLevelEnabled — the
|
||||
// call-site fallback must preserve the prior behavior of always
|
||||
// building meta (so we don't silently drop diagnostics for them).
|
||||
const calls: Array<{ msg: string; meta?: Record<string, unknown> }> = [];
|
||||
const customLog: ProducerLogger = {
|
||||
error: (msg, meta) => calls.push({ msg, meta }),
|
||||
warn: (msg, meta) => calls.push({ msg, meta }),
|
||||
info: (msg, meta) => calls.push({ msg, meta }),
|
||||
debug: (msg, meta) => calls.push({ msg, meta }),
|
||||
};
|
||||
|
||||
let buildCount = 0;
|
||||
const buildMeta = (): Record<string, unknown> => {
|
||||
buildCount += 1;
|
||||
return { i: buildCount };
|
||||
};
|
||||
|
||||
runGatedDebugLoop(customLog, 3, buildMeta);
|
||||
|
||||
expect(buildCount).toBe(3);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]?.msg).toBe("evt");
|
||||
expect(calls[0]?.meta).toEqual({ i: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultLogger", () => {
|
||||
it("is a singleton at level=info", () => {
|
||||
defaultLogger.info("default-info");
|
||||
defaultLogger.debug("default-debug");
|
||||
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info");
|
||||
});
|
||||
|
||||
it("exposes isLevelEnabled gating debug at info threshold", () => {
|
||||
expect(defaultLogger.isLevelEnabled?.("info")).toBe(true);
|
||||
expect(defaultLogger.isLevelEnabled?.("debug")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Pluggable Producer Logger
|
||||
*
|
||||
* Lightweight pluggable logger with zero dependencies.
|
||||
* Default implementation writes to console with level filtering.
|
||||
*
|
||||
* All levels write to stderr (console.error/console.warn), never stdout —
|
||||
* producer runs inside CLI commands whose stdout is a machine-readable
|
||||
* contract (e.g. `validate --json`, `check --json`); an info/debug line on
|
||||
* stdout would corrupt that output. There is no diagnostic use case that
|
||||
* needs these lines on stdout specifically, so the whole logger is stderr-only.
|
||||
*
|
||||
* Users can provide their own logger (e.g. Winston, Pino) by
|
||||
* implementing the ProducerLogger interface.
|
||||
*/
|
||||
|
||||
export type LogLevel = "error" | "warn" | "info" | "debug";
|
||||
|
||||
export interface ProducerLogger {
|
||||
error(message: string, meta?: Record<string, unknown>): void;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
info(message: string, meta?: Record<string, unknown>): void;
|
||||
debug(message: string, meta?: Record<string, unknown>): void;
|
||||
|
||||
/**
|
||||
* Optional fast level check used to skip expensive metadata construction
|
||||
* at the call site. When the call site needs to build a non-trivial meta
|
||||
* object (e.g. snapshot a struct, format numbers, run `Array.find` over
|
||||
* scene state) just to attach to a debug log, gate it with this method:
|
||||
*
|
||||
* ```ts
|
||||
* if (log.isLevelEnabled?.("debug") ?? true) {
|
||||
* const meta = buildExpensiveMeta();
|
||||
* log.debug("hot-path event", meta);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The default coalescence (`?? true`) preserves today's behavior for
|
||||
* loggers that omit this method — they keep building the meta object as
|
||||
* before. Custom integrations (Pino, Winston, structured loggers) should
|
||||
* implement this to enable the optimization.
|
||||
*/
|
||||
isLevelEnabled?(level: LogLevel): boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a console-based logger with level filtering.
|
||||
*
|
||||
* Messages at or below the configured level are printed;
|
||||
* everything else is silently dropped.
|
||||
*/
|
||||
export function createConsoleLogger(level: LogLevel = "info"): ProducerLogger {
|
||||
const threshold = LOG_LEVEL_PRIORITY[level];
|
||||
|
||||
const shouldLog = (msgLevel: LogLevel): boolean => LOG_LEVEL_PRIORITY[msgLevel] <= threshold;
|
||||
|
||||
const formatMeta = (meta?: Record<string, unknown>): string =>
|
||||
meta ? ` ${JSON.stringify(meta)}` : "";
|
||||
|
||||
return {
|
||||
error(message, meta) {
|
||||
if (shouldLog("error")) {
|
||||
console.error(`[ERROR] ${message}${formatMeta(meta)}`);
|
||||
}
|
||||
},
|
||||
warn(message, meta) {
|
||||
if (shouldLog("warn")) {
|
||||
console.warn(`[WARN] ${message}${formatMeta(meta)}`);
|
||||
}
|
||||
},
|
||||
info(message, meta) {
|
||||
if (shouldLog("info")) {
|
||||
console.error(`[INFO] ${message}${formatMeta(meta)}`);
|
||||
}
|
||||
},
|
||||
debug(message, meta) {
|
||||
if (shouldLog("debug")) {
|
||||
console.error(`[DEBUG] ${message}${formatMeta(meta)}`);
|
||||
}
|
||||
},
|
||||
isLevelEnabled(msgLevel) {
|
||||
return shouldLog(msgLevel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default logger singleton (level: "info"). */
|
||||
export const defaultLogger: ProducerLogger = createConsoleLogger("info");
|
||||
@@ -0,0 +1,21 @@
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
||||
const producerRoot = resolve(sourceDir, "..");
|
||||
const repoRoot = resolve(producerRoot, "../..");
|
||||
const runtimePath = resolve(repoRoot, "packages/core/dist/hyperframe.runtime.iife.js");
|
||||
const fixturesDir = resolve(producerRoot, "tests/parity/fixtures");
|
||||
const fixtureRuntimePath = resolve(fixturesDir, "hyperframe.runtime.iife.js");
|
||||
|
||||
if (!existsSync(runtimePath)) {
|
||||
throw new Error(
|
||||
`Missing preview runtime at ${runtimePath}. Run "bun run --cwd packages/core build:hyperframes-runtime" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(fixturesDir, { recursive: true });
|
||||
copyFileSync(runtimePath, fixtureRuntimePath);
|
||||
|
||||
console.log(`[ParityFixtures] copied ${runtimePath} -> ${fixtureRuntimePath}`);
|
||||
@@ -0,0 +1,429 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import puppeteer, { type Browser, type Page } from "puppeteer-core";
|
||||
import { MEDIA_VISUAL_STYLE_PROPERTIES, quantizeTimeToFrame } from "./utils/parityContract.js";
|
||||
|
||||
type ParityHarnessOptions = {
|
||||
previewUrl: string;
|
||||
producerUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
checkpoints: number[];
|
||||
allowMismatchRatio: number;
|
||||
artifactsDir: string;
|
||||
emulateProducerSwap: boolean;
|
||||
};
|
||||
|
||||
function parseNumberArg(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParityHarnessOptions {
|
||||
const args = new Map<string, string>();
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token || !token.startsWith("--")) continue;
|
||||
const key = token.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args.set(key, "true");
|
||||
continue;
|
||||
}
|
||||
args.set(key, next);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const previewUrl = args.get("preview-url") || "";
|
||||
const producerUrl = args.get("producer-url") || "";
|
||||
if (!previewUrl || !producerUrl) {
|
||||
throw new Error(
|
||||
'Missing required args. Usage: --preview-url "<url>" --producer-url "<url>" [--checkpoints "0,1,2"] [--fps 30] [--width 1920] [--height 1080] [--allow-mismatch-ratio 0]',
|
||||
);
|
||||
}
|
||||
|
||||
const checkpointsRaw = args.get("checkpoints") || "0,1,2,3,5";
|
||||
const checkpoints = checkpointsRaw
|
||||
.split(",")
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isFinite(value) && value >= 0);
|
||||
if (checkpoints.length === 0) {
|
||||
throw new Error("No valid checkpoints provided.");
|
||||
}
|
||||
|
||||
return {
|
||||
previewUrl,
|
||||
producerUrl,
|
||||
width: parseNumberArg(args.get("width"), 1920),
|
||||
height: parseNumberArg(args.get("height"), 1080),
|
||||
fps: parseNumberArg(args.get("fps"), 30),
|
||||
checkpoints,
|
||||
allowMismatchRatio: Math.max(
|
||||
0,
|
||||
Math.min(1, parseNumberArg(args.get("allow-mismatch-ratio"), 0)),
|
||||
),
|
||||
artifactsDir: resolve(args.get("artifacts-dir") || ".debug/parity-harness"),
|
||||
emulateProducerSwap: (args.get("emulate-producer-swap") || "false").toLowerCase() === "true",
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForParityReady(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const win = window as unknown as { __playerReady?: boolean; __renderReady?: boolean };
|
||||
return Boolean(win.__playerReady && win.__renderReady);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
function ensureDir(path: string): void {
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeBinary(path: string, data: Buffer): void {
|
||||
ensureDir(dirname(path));
|
||||
writeFileSync(path, data);
|
||||
}
|
||||
|
||||
function writeJson(path: string, data: unknown): void {
|
||||
ensureDir(dirname(path));
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function writeImageDiff(basePath: string, comparePath: string, outputPath: string): void {
|
||||
const ffmpeg = spawnSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
basePath,
|
||||
"-i",
|
||||
comparePath,
|
||||
"-filter_complex",
|
||||
"[0:v][1:v]blend=all_mode=difference",
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
if (ffmpeg.status !== 0) {
|
||||
const stderr = (ffmpeg.stderr || Buffer.from("")).toString("utf-8");
|
||||
throw new Error(`ffmpeg diff failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureStyleSnapshot(page: Page): Promise<Record<string, unknown>> {
|
||||
return page.evaluate(
|
||||
(properties: string[]) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll(
|
||||
"video[data-start], img.__render_frame__, img.__preview_render_frame__, img.__parity_render_frame__",
|
||||
),
|
||||
) as HTMLElement[];
|
||||
return {
|
||||
location: window.location.href,
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
},
|
||||
media: targets.map((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const values: Record<string, string> = {};
|
||||
for (const property of properties) {
|
||||
values[property] = style.getPropertyValue(property);
|
||||
}
|
||||
return {
|
||||
id: el.id || null,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
className: el.className || null,
|
||||
values,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
||||
);
|
||||
}
|
||||
|
||||
async function emulateProducerVideoSwap(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
(properties: string[]) => {
|
||||
const videos = Array.from(
|
||||
document.querySelectorAll("video[data-start]"),
|
||||
) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
let img = video.nextElementSibling as HTMLImageElement | null;
|
||||
if (!img || !img.classList.contains("__parity_render_frame__")) {
|
||||
img = document.createElement("img");
|
||||
img.className = "__parity_render_frame__";
|
||||
video.parentNode?.insertBefore(img, video.nextSibling);
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(video);
|
||||
const sourceIsStatic = !style.position || style.position === "static";
|
||||
if (!sourceIsStatic) {
|
||||
img.style.position = style.position;
|
||||
img.style.top = style.top;
|
||||
img.style.left = style.left;
|
||||
img.style.right = style.right;
|
||||
img.style.bottom = style.bottom;
|
||||
} else {
|
||||
img.style.position = "absolute";
|
||||
img.style.top = "0px";
|
||||
img.style.left = "0px";
|
||||
img.style.right = "0px";
|
||||
img.style.bottom = "0px";
|
||||
}
|
||||
for (const property of properties) {
|
||||
if (
|
||||
sourceIsStatic &&
|
||||
(property === "top" ||
|
||||
property === "left" ||
|
||||
property === "right" ||
|
||||
property === "bottom" ||
|
||||
property === "inset")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = style.getPropertyValue(property);
|
||||
if (value) {
|
||||
img.style.setProperty(property, value);
|
||||
}
|
||||
}
|
||||
img.style.pointerEvents = "none";
|
||||
img.style.visibility = "visible";
|
||||
|
||||
try {
|
||||
const width = Math.max(2, video.videoWidth || video.clientWidth || 2);
|
||||
const height = Math.max(2, video.videoHeight || video.clientHeight || 2);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) {
|
||||
continue;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
img.src = canvas.toDataURL("image/png");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
} catch {
|
||||
video.style.removeProperty("visibility");
|
||||
video.style.removeProperty("opacity");
|
||||
}
|
||||
}
|
||||
},
|
||||
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
||||
);
|
||||
}
|
||||
|
||||
async function captureCheckpoint(
|
||||
page: Page,
|
||||
checkpointSec: number,
|
||||
fps: number,
|
||||
emulateProducerSwap: boolean,
|
||||
): Promise<Buffer> {
|
||||
const quantized = quantizeTimeToFrame(checkpointSec, fps);
|
||||
await page.evaluate(
|
||||
({ time, targetFps }) => {
|
||||
const win = window as unknown as {
|
||||
__player?: { renderSeek?(t: number): void; seek?(t: number): void };
|
||||
gsap?: { ticker?: { tick(): void } };
|
||||
};
|
||||
const player = win.__player;
|
||||
if (!player) return;
|
||||
const safe = Math.max(0, Number(time) || 0);
|
||||
const frame = Math.floor(safe * targetFps + 1e-9);
|
||||
const quantized = frame / targetFps;
|
||||
if (typeof player.renderSeek === "function") {
|
||||
player.renderSeek(quantized);
|
||||
} else if (typeof player.seek === "function") {
|
||||
player.seek(quantized);
|
||||
}
|
||||
if (win.gsap?.ticker?.tick) {
|
||||
win.gsap.ticker.tick();
|
||||
}
|
||||
},
|
||||
{ time: quantized, targetFps: fps },
|
||||
);
|
||||
if (emulateProducerSwap) {
|
||||
await emulateProducerVideoSwap(page);
|
||||
}
|
||||
await page.evaluate(`new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
window.setTimeout(finish, 100);
|
||||
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||||
})`);
|
||||
return (await page.screenshot({ type: "png" })) as Buffer;
|
||||
}
|
||||
|
||||
async function captureParitySide(
|
||||
browser: Browser,
|
||||
url: string,
|
||||
checkpointSec: number,
|
||||
fps: number,
|
||||
emulateProducerSwap: boolean,
|
||||
): Promise<{ buffer: Buffer; styles: Record<string, unknown> }> {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
// Use domcontentloaded to avoid blocking on video media preloading, which
|
||||
// can exceed the navigation timeout for compositions with many video sources.
|
||||
await page.goto(url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForParityReady(page);
|
||||
const buffer = await captureCheckpoint(page, checkpointSec, fps, emulateProducerSwap);
|
||||
const styles = await captureStyleSnapshot(page);
|
||||
return { buffer, styles };
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const options = parseArgs(process.argv);
|
||||
console.log(`[ParityHarness] options=${JSON.stringify(options)}`);
|
||||
ensureDir(options.artifactsDir);
|
||||
|
||||
const browserTarget = process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
|
||||
: { channel: "chrome" as const };
|
||||
const browser = await puppeteer.launch({
|
||||
...browserTarget,
|
||||
headless: true,
|
||||
defaultViewport: {
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
deviceScaleFactor: 1,
|
||||
},
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-accelerated-2d-canvas",
|
||||
"--enable-webgl",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--font-render-hinting=none",
|
||||
"--force-color-profile=srgb",
|
||||
`--window-size=${options.width},${options.height}`,
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
let mismatches = 0;
|
||||
const results: Array<{
|
||||
checkpointSec: number;
|
||||
previewHash: string;
|
||||
producerHash: string;
|
||||
match: boolean;
|
||||
previewImagePath: string;
|
||||
producerImagePath: string;
|
||||
diffImagePath: string | null;
|
||||
previewStylesPath: string;
|
||||
producerStylesPath: string;
|
||||
}> = [];
|
||||
|
||||
for (const checkpointSec of options.checkpoints) {
|
||||
const checkpointKey = checkpointSec.toFixed(3).replace(/\./g, "_");
|
||||
const artifactDir = join(options.artifactsDir, `checkpoint_${checkpointKey}s`);
|
||||
ensureDir(artifactDir);
|
||||
const previewCapture = await captureParitySide(
|
||||
browser,
|
||||
options.previewUrl,
|
||||
checkpointSec,
|
||||
options.fps,
|
||||
false,
|
||||
);
|
||||
const producerCapture = await captureParitySide(
|
||||
browser,
|
||||
options.producerUrl,
|
||||
checkpointSec,
|
||||
options.fps,
|
||||
options.emulateProducerSwap,
|
||||
);
|
||||
const previewBuffer = previewCapture.buffer;
|
||||
const producerBuffer = producerCapture.buffer;
|
||||
const previewHash = sha256(previewBuffer);
|
||||
const producerHash = sha256(producerBuffer);
|
||||
const match = previewHash === producerHash;
|
||||
if (!match) mismatches += 1;
|
||||
const previewImagePath = join(artifactDir, "preview.png");
|
||||
const producerImagePath = join(artifactDir, "producer.png");
|
||||
const diffImagePath = match ? null : join(artifactDir, "diff.png");
|
||||
writeBinary(previewImagePath, previewBuffer);
|
||||
writeBinary(producerImagePath, producerBuffer);
|
||||
if (diffImagePath) {
|
||||
writeImageDiff(previewImagePath, producerImagePath, diffImagePath);
|
||||
}
|
||||
const previewStyles = previewCapture.styles;
|
||||
const producerStyles = producerCapture.styles;
|
||||
const previewStylesPath = join(artifactDir, "preview-styles.json");
|
||||
const producerStylesPath = join(artifactDir, "producer-styles.json");
|
||||
writeJson(previewStylesPath, previewStyles);
|
||||
writeJson(producerStylesPath, producerStyles);
|
||||
const row = {
|
||||
checkpointSec,
|
||||
previewHash,
|
||||
producerHash,
|
||||
match,
|
||||
previewImagePath,
|
||||
producerImagePath,
|
||||
diffImagePath,
|
||||
previewStylesPath,
|
||||
producerStylesPath,
|
||||
};
|
||||
results.push(row);
|
||||
console.log(`[ParityHarness] checkpoint=${JSON.stringify(row)}`);
|
||||
}
|
||||
|
||||
const total = results.length;
|
||||
const mismatchRatio = total > 0 ? mismatches / total : 0;
|
||||
const summary = {
|
||||
totalCheckpoints: total,
|
||||
mismatches,
|
||||
mismatchRatio,
|
||||
allowMismatchRatio: options.allowMismatchRatio,
|
||||
pass: mismatchRatio <= options.allowMismatchRatio,
|
||||
artifactsDir: options.artifactsDir,
|
||||
};
|
||||
writeJson(join(options.artifactsDir, "summary.json"), { summary, results });
|
||||
console.log(`[ParityHarness] summary=${JSON.stringify(summary)}`);
|
||||
|
||||
if (!summary.pass) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
void run().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ParityHarness] fatal=${JSON.stringify({ message })}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
type PerfBaseline = {
|
||||
parityFixtureMaxMs: number;
|
||||
allowedRegressionRatio: number;
|
||||
};
|
||||
|
||||
function main(): void {
|
||||
const baselinePath = resolve(
|
||||
process.env.PRODUCER_PERF_BASELINE_PATH || "producer/tests/perf/baseline.json",
|
||||
);
|
||||
const measuredMs = Number(process.env.PRODUCER_PARITY_ELAPSED_MS || "");
|
||||
if (!Number.isFinite(measuredMs) || measuredMs <= 0) {
|
||||
throw new Error("Missing PRODUCER_PARITY_ELAPSED_MS for perf gate");
|
||||
}
|
||||
const baselineRaw = readFileSync(baselinePath, "utf-8");
|
||||
const baseline = JSON.parse(baselineRaw) as PerfBaseline;
|
||||
const maxMs = Math.round(baseline.parityFixtureMaxMs * (1 + baseline.allowedRegressionRatio));
|
||||
const payload = {
|
||||
baselinePath,
|
||||
measuredMs,
|
||||
parityFixtureMaxMs: baseline.parityFixtureMaxMs,
|
||||
maxMs,
|
||||
};
|
||||
console.log(`[PerfGate] ${JSON.stringify(payload)}`);
|
||||
if (measuredMs > maxMs) {
|
||||
throw new Error(`[PerfGate] Regression detected measured=${measuredMs}ms max=${maxMs}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,157 @@
|
||||
// Pure-function tests for the harness mode dispatch logic. End-to-end
|
||||
// PSNR contract lives in `Dockerfile.test` runs of the regression harness.
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
checkDistributedSupport,
|
||||
DISTRIBUTED_SIMULATED_MIN_PSNR_DB,
|
||||
parseHarnessModeFlag,
|
||||
resolveMinPsnrForMode,
|
||||
} from "./regression-harness-distributed.js";
|
||||
|
||||
describe("parseHarnessModeFlag()", () => {
|
||||
it("parses --mode=in-process", () => {
|
||||
expect(parseHarnessModeFlag("--mode=in-process")).toBe("in-process");
|
||||
});
|
||||
|
||||
it("parses --mode=distributed-simulated", () => {
|
||||
expect(parseHarnessModeFlag("--mode=distributed-simulated")).toBe("distributed-simulated");
|
||||
});
|
||||
|
||||
it("parses --mode=lambda-local", () => {
|
||||
expect(parseHarnessModeFlag("--mode=lambda-local")).toBe("lambda-local");
|
||||
});
|
||||
|
||||
it("returns null for tokens that aren't --mode", () => {
|
||||
expect(parseHarnessModeFlag("--update")).toBeNull();
|
||||
expect(parseHarnessModeFlag("font-variant-numeric")).toBeNull();
|
||||
expect(parseHarnessModeFlag("--exclude-tags")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on a known prefix with a bad value", () => {
|
||||
expect(() => parseHarnessModeFlag("--mode=foo")).toThrow(/--mode must be/);
|
||||
expect(() => parseHarnessModeFlag("--mode=")).toThrow(/--mode must be/);
|
||||
});
|
||||
|
||||
it("error message lists all three accepted modes", () => {
|
||||
expect(() => parseHarnessModeFlag("--mode=foo")).toThrow(/lambda-local/);
|
||||
expect(() => parseHarnessModeFlag("--mode=foo")).toThrow(/distributed-simulated/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDistributedSupport()", () => {
|
||||
it("accepts mp4 SDR at 24 / 30 / 60 fps", () => {
|
||||
for (const fpsNum of [24, 30, 60]) {
|
||||
const result = checkDistributedSupport({ fps: { num: fpsNum, den: 1 } });
|
||||
expect(result.supported).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts explicit format=mp4", () => {
|
||||
const result = checkDistributedSupport({ fps: { num: 30, den: 1 }, format: "mp4" });
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects fps with non-1 denominator (NTSC)", () => {
|
||||
const result = checkDistributedSupport({ fps: { num: 30000, den: 1001 } });
|
||||
expect(result.supported).toBe(false);
|
||||
if (!result.supported) {
|
||||
expect(result.reason).toMatch(/non-integer fps/);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects fps outside the {24,30,60} set", () => {
|
||||
for (const fpsNum of [12, 25, 48, 50, 120]) {
|
||||
const result = checkDistributedSupport({ fps: { num: fpsNum, den: 1 } });
|
||||
expect(result.supported).toBe(false);
|
||||
if (!result.supported) {
|
||||
expect(result.reason).toMatch(/not in \{24, 30, 60\}/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts format=webm (distributed-supported via closed-GOP concat-copy)", () => {
|
||||
const result = checkDistributedSupport({ fps: { num: 30, den: 1 }, format: "webm" });
|
||||
expect(result.supported).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects hdr=true", () => {
|
||||
const result = checkDistributedSupport({ fps: { num: 30, den: 1 }, hdr: true });
|
||||
expect(result.supported).toBe(false);
|
||||
if (!result.supported) {
|
||||
expect(result.reason).toMatch(/hdr/);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts hdr=false (or unset)", () => {
|
||||
expect(checkDistributedSupport({ fps: { num: 30, den: 1 }, hdr: false }).supported).toBe(true);
|
||||
expect(checkDistributedSupport({ fps: { num: 30, den: 1 } }).supported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMinPsnrForMode()", () => {
|
||||
it("in-process mode uses the fixture's own threshold verbatim", () => {
|
||||
expect(resolveMinPsnrForMode("in-process", 30)).toBe(30);
|
||||
expect(resolveMinPsnrForMode("in-process", 50)).toBe(50);
|
||||
expect(resolveMinPsnrForMode("in-process", 60)).toBe(60);
|
||||
});
|
||||
|
||||
it("distributed-simulated uses the fixture's own minPsnr when above the absolute floor", () => {
|
||||
// Fixtures with minPsnr >= the absolute floor (catastrophic-failure
|
||||
// guard) use their authored threshold unchanged. Distributed must pass
|
||||
// the same quality bar the in-process renderer passes against the same
|
||||
// baseline — no extra tightening, since baseline drift is shared across
|
||||
// modes.
|
||||
expect(resolveMinPsnrForMode("distributed-simulated", 30)).toBe(30);
|
||||
expect(resolveMinPsnrForMode("distributed-simulated", 50)).toBe(50);
|
||||
expect(resolveMinPsnrForMode("distributed-simulated", 80)).toBe(80);
|
||||
});
|
||||
|
||||
it("distributed-simulated raises pathologically-low thresholds to the absolute floor", () => {
|
||||
// A fixture authored with minPsnr=0 (or very low) wouldn't catch a
|
||||
// distributed-mode renderer producing fully-black output. The absolute
|
||||
// floor exists to catch that pathology.
|
||||
expect(resolveMinPsnrForMode("distributed-simulated", 0)).toBe(
|
||||
DISTRIBUTED_SIMULATED_MIN_PSNR_DB,
|
||||
);
|
||||
expect(resolveMinPsnrForMode("distributed-simulated", 5)).toBe(
|
||||
DISTRIBUTED_SIMULATED_MIN_PSNR_DB,
|
||||
);
|
||||
});
|
||||
|
||||
it("lambda-local mirrors distributed-simulated's pathology floor", () => {
|
||||
// Both non-in-process modes run through the same producer primitives,
|
||||
// so they share the same pathology threshold.
|
||||
expect(resolveMinPsnrForMode("lambda-local", 30)).toBe(30);
|
||||
expect(resolveMinPsnrForMode("lambda-local", 0)).toBe(DISTRIBUTED_SIMULATED_MIN_PSNR_DB);
|
||||
});
|
||||
|
||||
it("every committed fixture authors a minPsnr above the absolute floor", async () => {
|
||||
// The pathology floor only fires for a fixture whose authored minPsnr
|
||||
// is below it — by design that should be no committed fixture. If
|
||||
// someone lands a permissive fixture (minPsnr: 5), distributed mode
|
||||
// will silently use 10 dB instead, which is the right behavior but
|
||||
// worth flagging so reviewers ask "is this fixture really meant to
|
||||
// accept near-black output?". This test prevents accidental misuse
|
||||
// by failing loudly when a fixture drops below the floor.
|
||||
const { readdirSync, readFileSync, statSync } = await import("node:fs");
|
||||
const { join: pathJoin } = await import("node:path");
|
||||
const testsDir = pathJoin(import.meta.dir, "..", "tests");
|
||||
const offenders: Array<{ fixture: string; minPsnr: number }> = [];
|
||||
for (const entry of readdirSync(testsDir)) {
|
||||
const metaPath = pathJoin(testsDir, entry, "meta.json");
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(metaPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
const meta = JSON.parse(readFileSync(metaPath, "utf-8")) as { minPsnr?: unknown };
|
||||
if (typeof meta.minPsnr === "number" && meta.minPsnr < DISTRIBUTED_SIMULATED_MIN_PSNR_DB) {
|
||||
offenders.push({ fixture: entry, minPsnr: meta.minPsnr });
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Distributed-render path for the regression harness.
|
||||
*
|
||||
* The regression harness has two modes:
|
||||
*
|
||||
* - `in-process` (default) — calls `executeRenderJob`, the same path the
|
||||
* `hyperframes render` CLI takes. This is what produced every existing
|
||||
* `tests/<name>/output/output.mp4` golden baseline.
|
||||
*
|
||||
* - `distributed-simulated` — calls `plan()` → `renderChunk()` per chunk
|
||||
* → `assemble()` from `@hyperframes/producer/distributed`. No Temporal
|
||||
* or Lambda involvement: the controller and chunk worker are both this
|
||||
* process, but they go through the same artifact (planDir + frozen
|
||||
* `meta/encoder.json` + per-chunk concat-copy) that a real fan-out
|
||||
* would.
|
||||
*
|
||||
* Both modes share the per-fixture `minPsnr` threshold — distributed must
|
||||
* pass the same quality bar the in-process renderer passes against the
|
||||
* same frozen baseline. A separate {@link DISTRIBUTED_SIMULATED_MIN_PSNR_DB}
|
||||
* pathology floor catches the case where a fixture authored a permissive
|
||||
* threshold and distributed regresses to fully-black output. The 50 dB
|
||||
* "distributed vs in-process" contract is a per-render comparison
|
||||
* (fresh in-process vs fresh distributed); against the frozen baseline
|
||||
* file it's unreachable for either mode due to shared encoder/JPEG-
|
||||
* capture jitter, so the harness can't use it as a per-test gate.
|
||||
*
|
||||
* Not every fixture can run in distributed-simulated mode. Distributed mode
|
||||
* refuses HDR mp4, NTSC framerates, and non-{24,30,60} fps at plan time.
|
||||
* Fixtures that don't meet the constraints are skipped — the harness logs
|
||||
* the reason and the fixture is treated as "passed (skipped)" in
|
||||
* distributed-simulated mode.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Fps } from "@hyperframes/core";
|
||||
import { assemble, plan, renderChunk } from "./distributed.js";
|
||||
import type { DistributedFormat } from "./services/distributed/shared.js";
|
||||
|
||||
/**
|
||||
* Three-mode contract that backs `--mode=<value>` on the regression
|
||||
* harness CLI:
|
||||
*
|
||||
* - `in-process` — `executeRenderJob`, the same path the CLI takes.
|
||||
* - `distributed-simulated` — `plan` → `renderChunk` × N → `assemble`
|
||||
* in-process. No adapter (no Temporal, no Lambda).
|
||||
* - `lambda-local` — drives the OSS `@hyperframes/aws-lambda` handler
|
||||
* dispatch through a filesystem-backed fake S3, so every event
|
||||
* shape SFN sends in production also lands here. Catches regressions
|
||||
* in event JSON / S3 path conventions without paying for a real AWS
|
||||
* round-trip.
|
||||
*/
|
||||
export type HarnessMode = "in-process" | "distributed-simulated" | "lambda-local";
|
||||
|
||||
/**
|
||||
* Absolute pathology floor for `--mode=distributed-simulated` — catches
|
||||
* a chunk that renders fully-black against a fixture authored with a
|
||||
* permissive `minPsnr`. Non-pathological drift is caught by the fixture's
|
||||
* own threshold; both modes share the same encoder/JPEG-capture jitter
|
||||
* floor against the frozen baseline file, so the 50 dB distributed-vs-
|
||||
* in-process contract value is unreachable for either mode and isn't a
|
||||
* useful per-test gate.
|
||||
*/
|
||||
export const DISTRIBUTED_SIMULATED_MIN_PSNR_DB = 10;
|
||||
|
||||
/** Result of {@link checkDistributedSupport}. */
|
||||
export type DistributedSupportResult = { supported: true } | { supported: false; reason: string };
|
||||
|
||||
/**
|
||||
* Decide whether a fixture's `renderConfig` is one the distributed pipeline
|
||||
* can actually run. Two hard gates:
|
||||
*
|
||||
* - fps must be `{ num: 24|30|60, den: 1 }`. `DistributedRenderConfig.fps`
|
||||
* accepts only the three integer values, and rationals like
|
||||
* `{ num: 30000, den: 1001 }` (NTSC) trip the type system at the call
|
||||
* site. We surface this gate in code rather than only in TS so the
|
||||
* harness can skip the fixture cleanly instead of throwing.
|
||||
* - hdr must not be `true`. Distributed mode is SDR-only at v1.
|
||||
*
|
||||
* Callers that want the structured reason can read it off the returned
|
||||
* `reason` field; the message is intended to be log-friendly.
|
||||
*/
|
||||
export function checkDistributedSupport(renderConfig: {
|
||||
fps: Fps;
|
||||
format?: DistributedFormat;
|
||||
hdr?: boolean;
|
||||
}): DistributedSupportResult {
|
||||
if (renderConfig.fps.den !== 1) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `non-integer fps ${renderConfig.fps.num}/${renderConfig.fps.den} (distributed mode requires fps.den=1)`,
|
||||
};
|
||||
}
|
||||
const fpsNum = renderConfig.fps.num;
|
||||
if (fpsNum !== 24 && fpsNum !== 30 && fpsNum !== 60) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `fps ${fpsNum} not in {24, 30, 60} (DistributedRenderConfig.fps is a closed set)`,
|
||||
};
|
||||
}
|
||||
if (renderConfig.hdr === true) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: "hdr=true refused in distributed mode (HDR signaling re-apply not implemented)",
|
||||
};
|
||||
}
|
||||
return { supported: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs for {@link runDistributedSimulatedRender}. The harness has already
|
||||
* prepared `projectDir` (a working copy of the fixture's `src/` directory)
|
||||
* and `tempRoot` (where the harness writes its scratch artifacts).
|
||||
*/
|
||||
export interface RunDistributedSimulatedInput {
|
||||
/** Working copy of the fixture's `src/` — contains `index.html`. */
|
||||
projectDir: string;
|
||||
/** Scratch root for plan + chunks; must be a directory the harness owns. */
|
||||
tempRoot: string;
|
||||
/** Where to write the assembled final mp4 / mov / png-sequence directory. */
|
||||
renderedOutputPath: string;
|
||||
/** From the fixture's renderConfig — must pass `checkDistributedSupport`. */
|
||||
fps: 24 | 30 | 60;
|
||||
format: DistributedFormat;
|
||||
/**
|
||||
* Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to
|
||||
* exercise the libx265 closed-GOP path. Ignored for non-mp4 formats —
|
||||
* `plan()` throws if codec is passed with a non-mp4 format.
|
||||
*/
|
||||
codec?: "h264" | "h265";
|
||||
/** Optional chunkSize override; defaults to the plan's 240. */
|
||||
chunkSize?: number;
|
||||
/** Optional maxParallelChunks override; defaults to the plan's 16. */
|
||||
maxParallelChunks?: number;
|
||||
/** Forwarded to `plan()` and re-applied by `renderChunk()` at boot. */
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the distributed pipeline against a single fixture as if a fan-out
|
||||
* adapter were driving it. The three activities run serially in this
|
||||
* process — there is no Temporal, no Lambda, no S3 — so the planDir,
|
||||
* chunk outputs, and assembled output all live under `tempRoot`.
|
||||
*
|
||||
* Width and height are required by `DistributedRenderConfig` for cross-call
|
||||
* sanity but are not consulted at render time — `plan()` reads the
|
||||
* composition's `data-width` / `data-height` attributes and overrides
|
||||
* whatever the config carried. The harness passes a dummy 1920×1080 here
|
||||
* for that reason; if the contract ever changes, the fixture's authored
|
||||
* dimensions will flow through `PlanResult` and we can switch to using
|
||||
* those instead.
|
||||
*/
|
||||
export async function runDistributedSimulatedRender(
|
||||
input: RunDistributedSimulatedInput,
|
||||
): Promise<void> {
|
||||
const planDir = join(input.tempRoot, "plan");
|
||||
const chunksDir = join(input.tempRoot, "chunks");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
mkdirSync(chunksDir, { recursive: true });
|
||||
|
||||
// Step A: plan. `plan()` throws when `codec` is set with a non-mp4 format,
|
||||
// but `codec: undefined` is a no-op — so we forward it directly for mp4
|
||||
// and elide it for the others rather than branching the entire config.
|
||||
// hdrMode is pinned to force-sdr so the harness's behavior is independent
|
||||
// of any future auto-detect changes.
|
||||
const planResult = await plan(
|
||||
input.projectDir,
|
||||
{
|
||||
fps: input.fps,
|
||||
// Required-by-type but overridden by the composition's `data-width` /
|
||||
// `data-height` attrs; any positive integer works.
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: input.format,
|
||||
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
|
||||
chunkSize: input.chunkSize,
|
||||
maxParallelChunks: input.maxParallelChunks,
|
||||
hdrMode: "force-sdr",
|
||||
// Forward `variables` to plan() so distributed-simulated fixtures
|
||||
// that declare `renderConfig.variables` produce the same pixels in
|
||||
// distributed mode as in-process. Without this, the harness silently
|
||||
// drops the variables for distributed/lambda-local modes and any
|
||||
// composition that reads `window.__hfVariables` diverges.
|
||||
variables: input.variables,
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
|
||||
// Step B: render every chunk. Sequential to keep the harness predictable —
|
||||
// adapters in production are free to fan out; this code path's job is to
|
||||
// exercise the per-chunk activity itself.
|
||||
const chunkPaths: string[] = [];
|
||||
for (let i = 0; i < planResult.chunkCount; i++) {
|
||||
const chunkPath =
|
||||
input.format === "png-sequence"
|
||||
? join(chunksDir, `chunk-${String(i).padStart(4, "0")}`)
|
||||
: join(chunksDir, `chunk-${String(i).padStart(4, "0")}.${input.format}`);
|
||||
await renderChunk(planDir, i, chunkPath);
|
||||
chunkPaths.push(chunkPath);
|
||||
}
|
||||
|
||||
// Step C: assemble. `audio.aac` only exists when the composition has
|
||||
// audio — pass null otherwise so `assemble()` doesn't try to mux silence.
|
||||
const audioPath = join(planDir, "audio.aac");
|
||||
const audioForAssemble = existsSync(audioPath) ? audioPath : null;
|
||||
await assemble(planDir, chunkPaths, audioForAssemble, input.renderedOutputPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the PSNR threshold for a fixture given the harness mode. Both modes
|
||||
* share the fixture's authored `minPsnr` — distributed must clear the same
|
||||
* quality bar in-process clears against the same frozen baseline.
|
||||
* Distributed-simulated additionally lifts the threshold to
|
||||
* {@link DISTRIBUTED_SIMULATED_MIN_PSNR_DB} for fixtures with a permissive
|
||||
* authored threshold; that absolute floor catches fully-black-output
|
||||
* regressions independent of fixture tolerance.
|
||||
*/
|
||||
export function resolveMinPsnrForMode(mode: HarnessMode, fixtureMinPsnr: number): number {
|
||||
if (mode === "in-process") return fixtureMinPsnr;
|
||||
// `lambda-local` shares the distributed-simulated pathology floor —
|
||||
// both modes go through the same plan/renderChunk/assemble primitives.
|
||||
return Math.max(fixtureMinPsnr, DISTRIBUTED_SIMULATED_MIN_PSNR_DB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `--mode=<value>` from a single CLI token. Returns the parsed mode
|
||||
* when the token matches the expected shape, `null` otherwise so the
|
||||
* caller can pass the token through to the next handler. Throws on a
|
||||
* known prefix with a bad value (`--mode=foo`) — surfacing a typo at
|
||||
* parse time is cheaper than discovering at render time.
|
||||
*/
|
||||
export function parseHarnessModeFlag(token: string): HarnessMode | null {
|
||||
if (token === "--mode=in-process") return "in-process";
|
||||
if (token === "--mode=distributed-simulated") return "distributed-simulated";
|
||||
if (token === "--mode=lambda-local") return "lambda-local";
|
||||
if (token.startsWith("--mode=")) {
|
||||
const value = token.slice("--mode=".length);
|
||||
throw new Error(
|
||||
`regression-harness: --mode must be 'in-process', 'distributed-simulated', or 'lambda-local' (got ${JSON.stringify(value)})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Public-facing types for `./regression-harness-lambda-local.ts`.
|
||||
*
|
||||
* Kept in its own file because the implementation imports
|
||||
* `@hyperframes/aws-lambda`, which can't be resolved by producer's
|
||||
* tsc emit pass until aws-lambda's own dist/ is built. Splitting the
|
||||
* types out lets producer's regression harness reference the lambda
|
||||
* adapter's shape without pulling the aws-lambda graph into producer's
|
||||
* type-check pass.
|
||||
*/
|
||||
|
||||
import type { DistributedFormat } from "./services/distributed/shared.js";
|
||||
|
||||
/** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
|
||||
export interface RunLambdaLocalInput {
|
||||
projectDir: string;
|
||||
tempRoot: string;
|
||||
renderedOutputPath: string;
|
||||
fps: 24 | 30 | 60;
|
||||
/**
|
||||
* Width/height from the fixture's renderConfig. Forwarded directly to
|
||||
* the Lambda event so this mode catches drift if the handler ever
|
||||
* starts honouring `Config.width/height` for canvas sizing rather
|
||||
* than reading the composition's `data-width`/`data-height`. The
|
||||
* `distributed-simulated` mode hardcodes 1920×1080 because it
|
||||
* bypasses the event-serialization boundary; lambda-local goes
|
||||
* through it, which is the whole point.
|
||||
*/
|
||||
width: number;
|
||||
height: number;
|
||||
format: DistributedFormat;
|
||||
codec?: "h264" | "h265";
|
||||
chunkSize?: number;
|
||||
maxParallelChunks?: number;
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Public signature of the dynamically-loaded `runLambdaLocalRender`. */
|
||||
export type RunLambdaLocalRender = (input: RunLambdaLocalInput) => Promise<void>;
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Lambda-local render path for the regression harness.
|
||||
*
|
||||
* Drives the OSS `@hyperframes/aws-lambda` handler through the exact
|
||||
* sequence Step Functions invokes in production:
|
||||
*
|
||||
* handler({ Action: "plan" }) → planDir tarball on S3
|
||||
* handler({ Action: "renderChunk" }) × N → chunk artifacts on S3
|
||||
* handler({ Action: "assemble" }) → final mp4 / mov / png-seq
|
||||
*
|
||||
* The S3 client is a filesystem-backed fake: every `s3://test-bucket/<key>`
|
||||
* URI maps to `<tempRoot>/s3/<key>`. This means the harness exercises
|
||||
* the handler's event-parsing + tar / S3 layout + dispatch logic in
|
||||
* addition to the underlying producer primitives, catching regressions
|
||||
* (event JSON drift, S3 key conventions, plan-hash boundary checks)
|
||||
* that `distributed-simulated` mode wouldn't.
|
||||
*
|
||||
* `lambda-local` is **deliberately** not a Docker / RIE invocation —
|
||||
* that would gate the producer test suite on Docker-in-Docker support
|
||||
* which most CI runners lack. Real-ZIP-via-RIE tests live in
|
||||
* `packages/aws-lambda/scripts/` (`probe:beginframe`) and the
|
||||
* maintainer-run `smoke.sh`.
|
||||
*/
|
||||
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { downloadS3ObjectToFile, tarDirectory, untarDirectory } from "@hyperframes/aws-lambda";
|
||||
import { handler } from "@hyperframes/aws-lambda/handler";
|
||||
import type {
|
||||
AssembleEvent,
|
||||
AssembleLambdaResult,
|
||||
HandlerDeps,
|
||||
PlanEvent,
|
||||
PlanLambdaResult,
|
||||
RenderChunkEvent,
|
||||
RenderChunkLambdaResult,
|
||||
SerializableDistributedRenderConfig,
|
||||
} from "@hyperframes/aws-lambda";
|
||||
|
||||
export type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
|
||||
import type { RunLambdaLocalInput } from "./regression-harness-lambda-local-types.js";
|
||||
|
||||
const FAKE_BUCKET = "harness-lambda-local";
|
||||
|
||||
/** S3 URI helpers — keep the URI shape identical to what SFN uses in production. */
|
||||
function uri(key: string): string {
|
||||
return `s3://${FAKE_BUCKET}/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run plan → renderChunk × N → assemble through the OSS handler with a
|
||||
* filesystem-backed fake S3. Output lands at `input.renderedOutputPath`.
|
||||
*/
|
||||
export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<void> {
|
||||
const s3Root = join(input.tempRoot, "s3");
|
||||
mkdirSync(s3Root, { recursive: true });
|
||||
|
||||
// STEP 0: stage the project as a tar.gz at the fake-S3 path the Plan
|
||||
// event will reference, mirroring what `deploySite` does in prod.
|
||||
const projectKey = `sites/harness/${Date.now()}/project.tar.gz`;
|
||||
const projectS3Path = join(s3Root, projectKey);
|
||||
mkdirSync(dirname(projectS3Path), { recursive: true });
|
||||
await tarDirectory(input.projectDir, projectS3Path);
|
||||
|
||||
const fakeS3 = new FilesystemBackedFakeS3(s3Root);
|
||||
const deps: HandlerDeps = {
|
||||
s3: fakeS3 as unknown as HandlerDeps["s3"],
|
||||
// The handler resolves a Chrome path via `@sparticuz/chromium` by
|
||||
// default; that's the Lambda-specific binary. In Dockerfile.test
|
||||
// we want the producer's already-configured Chrome instead. The
|
||||
// skip flag tells the handler not to override PRODUCER_HEADLESS_SHELL_PATH.
|
||||
skipChromeResolution: true,
|
||||
tmpRoot: join(input.tempRoot, "lambda-tmp"),
|
||||
};
|
||||
mkdirSync(deps.tmpRoot as string, { recursive: true });
|
||||
|
||||
const config: SerializableDistributedRenderConfig = {
|
||||
fps: input.fps,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
format: input.format,
|
||||
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
|
||||
chunkSize: input.chunkSize,
|
||||
maxParallelChunks: input.maxParallelChunks,
|
||||
hdrMode: "force-sdr",
|
||||
// Forward `variables` through the event boundary so lambda-local mode
|
||||
// exercises the same variables-in-encoder.json path that real Lambda
|
||||
// executions take. Without this, a fixture's `renderConfig.variables`
|
||||
// would be silently dropped at the harness's serializer.
|
||||
variables: input.variables,
|
||||
};
|
||||
|
||||
// STEP A: plan
|
||||
const planPrefix = `renders/harness/${Date.now()}/`;
|
||||
const planEvent: PlanEvent = {
|
||||
Action: "plan",
|
||||
ProjectS3Uri: uri(projectKey),
|
||||
PlanOutputS3Prefix: uri(planPrefix),
|
||||
Config: config,
|
||||
};
|
||||
const planResult = (await handler(planEvent, deps)) as PlanLambdaResult;
|
||||
|
||||
// STEP B: render every chunk through the handler.
|
||||
const chunkUris: string[] = [];
|
||||
for (let i = 0; i < planResult.ChunkCount; i++) {
|
||||
const chunkEvent: RenderChunkEvent = {
|
||||
Action: "renderChunk",
|
||||
PlanS3Uri: planResult.PlanS3Uri,
|
||||
PlanHash: planResult.PlanHash,
|
||||
ChunkIndex: i,
|
||||
ChunkOutputS3Prefix: uri(planPrefix),
|
||||
Format: input.format,
|
||||
};
|
||||
const chunkResult = (await handler(chunkEvent, deps)) as RenderChunkLambdaResult;
|
||||
chunkUris.push(chunkResult.ChunkS3Uri);
|
||||
}
|
||||
|
||||
// STEP C: assemble
|
||||
const finalUri = uri(
|
||||
`${planPrefix}output${input.format === "png-sequence" ? ".tar.gz" : `.${input.format}`}`,
|
||||
);
|
||||
const assembleEvent: AssembleEvent = {
|
||||
Action: "assemble",
|
||||
PlanS3Uri: planResult.PlanS3Uri,
|
||||
ChunkS3Uris: chunkUris,
|
||||
AudioS3Uri: planResult.AudioS3Uri,
|
||||
OutputS3Uri: finalUri,
|
||||
Format: input.format,
|
||||
};
|
||||
(await handler(assembleEvent, deps)) as AssembleLambdaResult;
|
||||
|
||||
// Copy the final output from fake-S3 land back out to the path the
|
||||
// harness expects. For png-sequence, untar into the dir.
|
||||
const finalKey = finalUri.slice(`s3://${FAKE_BUCKET}/`.length);
|
||||
if (input.format === "png-sequence") {
|
||||
const tarPath = join(s3Root, finalKey);
|
||||
mkdirSync(input.renderedOutputPath, { recursive: true });
|
||||
await untarDirectory(tarPath, input.renderedOutputPath);
|
||||
} else {
|
||||
await downloadS3ObjectToFile(
|
||||
fakeS3 as unknown as Parameters<typeof downloadS3ObjectToFile>[0],
|
||||
finalUri,
|
||||
input.renderedOutputPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum AWS-SDK-shaped fake S3 the handler's `send(GetObject)` and
|
||||
* `send(PutObject)` calls land in. Stores blobs on the local filesystem
|
||||
* under `root/<key>` so the harness can pre-stage inputs (tarball'd
|
||||
* project) and post-inspect outputs (per-chunk artifacts, final video)
|
||||
* without going through a real S3 endpoint.
|
||||
*/
|
||||
class FilesystemBackedFakeS3 {
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
async send(command: unknown): Promise<unknown> {
|
||||
const cmdName = (command as { constructor: { name: string } }).constructor.name;
|
||||
const input = (command as { input: { Bucket: string; Key: string; Body?: unknown } }).input;
|
||||
const fsPath = join(this.root, input.Key);
|
||||
|
||||
if (cmdName === "GetObjectCommand") {
|
||||
if (!existsSync(fsPath)) {
|
||||
const err = new Error(
|
||||
`FakeS3: GetObject for missing key ${input.Bucket}/${input.Key}`,
|
||||
) as Error & {
|
||||
$metadata: { httpStatusCode: number };
|
||||
};
|
||||
err.$metadata = { httpStatusCode: 404 };
|
||||
throw err;
|
||||
}
|
||||
const bytes = readFileSync(fsPath);
|
||||
return { Body: Readable.from([bytes]) };
|
||||
}
|
||||
if (cmdName === "PutObjectCommand") {
|
||||
mkdirSync(dirname(fsPath), { recursive: true });
|
||||
const body = input.Body;
|
||||
if (body instanceof Buffer || typeof body === "string") {
|
||||
writeFileSync(fsPath, body);
|
||||
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
|
||||
await pipeline(body as NodeJS.ReadableStream, createWriteStream(fsPath));
|
||||
} else {
|
||||
throw new Error(`FakeS3: PutObject body shape not supported (${typeof body})`);
|
||||
}
|
||||
return { ETag: `"fake-${statSync(fsPath).size}"` };
|
||||
}
|
||||
if (cmdName === "HeadObjectCommand") {
|
||||
if (!existsSync(fsPath)) {
|
||||
const err = new Error(
|
||||
`FakeS3: HeadObject for missing key ${input.Bucket}/${input.Key}`,
|
||||
) as Error & {
|
||||
$metadata: { httpStatusCode: number };
|
||||
};
|
||||
err.$metadata = { httpStatusCode: 404 };
|
||||
throw err;
|
||||
}
|
||||
return { ContentLength: statSync(fsPath).size, LastModified: new Date() };
|
||||
}
|
||||
throw new Error(`FakeS3: unexpected command ${cmdName}`);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
getVerifiedHyperframeRuntimeSource,
|
||||
resolveHyperframeManifestPath,
|
||||
} from "./services/hyperframeRuntimeLoader.js";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPath = resolveHyperframeManifestPath();
|
||||
const manifestRaw = readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(manifestRaw) as {
|
||||
sha256?: string;
|
||||
artifacts?: { iife?: string };
|
||||
};
|
||||
|
||||
const verifiedSource = getVerifiedHyperframeRuntimeSource();
|
||||
const sourceSha = createHash("sha256").update(verifiedSource, "utf8").digest("hex");
|
||||
assert(sourceSha === manifest.sha256, "Verified runtime hash does not match manifest sha256");
|
||||
|
||||
const servicesDir = resolve(fileURLToPath(new URL("./services", import.meta.url)));
|
||||
const fileServerSource = readFileSync(resolve(servicesDir, "fileServer.ts"), "utf8");
|
||||
assert(
|
||||
fileServerSource.includes("getVerifiedHyperframeRuntimeSource"),
|
||||
"Producer file server must inject runtime via getVerifiedHyperframeRuntimeSource",
|
||||
);
|
||||
assert(
|
||||
!fileServerSource.includes("loadHyperframeRuntimeSource"),
|
||||
"Producer file server must not inject runtime via loadHyperframeRuntimeSource",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "producer_runtime_conformance_ok",
|
||||
manifestPath,
|
||||
runtimeSha256: sourceSha,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { parseRenderOptions, prepareRenderBody } from "./server.js";
|
||||
|
||||
describe("parseRenderOptions — variables", () => {
|
||||
it("forwards a plain JSON object", () => {
|
||||
const opts = parseRenderOptions({ variables: { title: "Hello", n: 3 } });
|
||||
expect(opts.variables).toEqual({ title: "Hello", n: 3 });
|
||||
});
|
||||
|
||||
it("drops non-object variables to undefined", () => {
|
||||
expect(parseRenderOptions({ variables: [1, 2] }).variables).toBeUndefined();
|
||||
expect(parseRenderOptions({ variables: "nope" }).variables).toBeUndefined();
|
||||
expect(parseRenderOptions({ variables: null }).variables).toBeUndefined();
|
||||
expect(parseRenderOptions({}).variables).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRenderOptions — outputResolution", () => {
|
||||
it("normalizes canonical presets and aliases", () => {
|
||||
expect(parseRenderOptions({ outputResolution: "landscape-4k" }).outputResolution).toBe(
|
||||
"landscape-4k",
|
||||
);
|
||||
expect(parseRenderOptions({ outputResolution: "4k" }).outputResolution).toBe("landscape-4k");
|
||||
expect(parseRenderOptions({ outputResolution: "portrait-4k" }).outputResolution).toBe(
|
||||
"portrait-4k",
|
||||
);
|
||||
expect(parseRenderOptions({ outputResolution: "square" }).outputResolution).toBe("square");
|
||||
});
|
||||
|
||||
it("drops unknown / non-string resolutions to undefined", () => {
|
||||
expect(parseRenderOptions({ outputResolution: "8k" }).outputResolution).toBeUndefined();
|
||||
expect(parseRenderOptions({ outputResolution: 123 }).outputResolution).toBeUndefined();
|
||||
expect(parseRenderOptions({}).outputResolution).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareRenderBody — validation", () => {
|
||||
it("rejects an explicitly-supplied non-object variables", async () => {
|
||||
const result = await prepareRenderBody({ variables: [1, 2], html: "<html></html>" });
|
||||
expect(result).toHaveProperty("error");
|
||||
expect((result as { error: string }).error).toContain("variables must be a JSON object");
|
||||
});
|
||||
|
||||
it("rejects an explicitly-supplied invalid outputResolution", async () => {
|
||||
const result = await prepareRenderBody({ outputResolution: "8k", html: "<html></html>" });
|
||||
expect(result).toHaveProperty("error");
|
||||
expect((result as { error: string }).error).toContain("Invalid outputResolution");
|
||||
});
|
||||
|
||||
it("rejects a non-string outputResolution instead of silently dropping it", async () => {
|
||||
const result = await prepareRenderBody({ outputResolution: 123, html: "<html></html>" });
|
||||
expect(result).toHaveProperty("error");
|
||||
expect((result as { error: string }).error).toContain("must be a string preset");
|
||||
});
|
||||
|
||||
it("rejects outputResolution combined with an alpha format (webm/mov)", async () => {
|
||||
for (const format of ["webm", "mov"]) {
|
||||
const result = await prepareRenderBody({
|
||||
outputResolution: "4k",
|
||||
format,
|
||||
html: "<html></html>",
|
||||
});
|
||||
expect(result).toHaveProperty("error");
|
||||
expect((result as { error: string }).error).toContain("can't supersample");
|
||||
}
|
||||
});
|
||||
|
||||
it("threads variables + outputResolution into the prepared render input", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-server-test-"));
|
||||
writeFileSync(join(dir, "index.html"), "<html><body></body></html>", "utf-8");
|
||||
|
||||
const result = await prepareRenderBody({
|
||||
projectDir: dir,
|
||||
variables: { title: "Q4" },
|
||||
outputResolution: "4k",
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("prepared");
|
||||
const { input } = (result as { prepared: { input: Record<string, unknown> } }).prepared;
|
||||
expect(input.variables).toEqual({ title: "Q4" });
|
||||
expect(input.outputResolution).toBe("landscape-4k");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,816 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @hyperframes/producer — Public Server
|
||||
*
|
||||
* Clean HTTP API for rendering HTML compositions to video.
|
||||
*
|
||||
* Routes:
|
||||
* POST /render — blocking render, returns JSON
|
||||
* POST /render/stream — SSE streaming render with progress
|
||||
* GET /render/queue — current render queue status
|
||||
* POST /lint — blocking Hyperframe lint
|
||||
* GET /health — health check
|
||||
* GET /outputs/:token — download rendered MP4
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
createReadStream,
|
||||
} from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { parseArgs } from "node:util";
|
||||
import crypto from "node:crypto";
|
||||
import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { serve } from "@hono/node-server";
|
||||
import {
|
||||
RenderCancelledError,
|
||||
createRenderJob,
|
||||
executeRenderJob,
|
||||
type RenderConfig,
|
||||
} from "./services/renderOrchestrator.js";
|
||||
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
|
||||
import { startHealthWorker, type HealthWorkerHandle } from "./services/healthWorker.js";
|
||||
import { isVideoFrameFormat } from "@hyperframes/engine";
|
||||
import { resolveRenderPaths } from "./utils/paths.js";
|
||||
import { defaultLogger, type ProducerLogger } from "./logger.js";
|
||||
import { Semaphore } from "./utils/semaphore.js";
|
||||
import { parseFps, normalizeResolutionFlag, type CanvasResolution } from "@hyperframes/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface HandlerOptions {
|
||||
/** Custom logger. Defaults to console-based defaultLogger. */
|
||||
logger?: ProducerLogger;
|
||||
/** Extract or generate a request ID. Defaults to x-request-id header or random UUID. */
|
||||
getRequestId?: (c: Context) => string;
|
||||
/** Directory for rendered output files. Defaults to PRODUCER_RENDERS_DIR or /tmp. */
|
||||
rendersDir?: string;
|
||||
/** Prefix for output URLs in responses. Default: "/outputs". */
|
||||
outputUrlPrefix?: string;
|
||||
/** TTL for output artifact download tokens (ms). Default: 15 minutes. */
|
||||
artifactTtlMs?: number;
|
||||
/** Max renders that execute simultaneously. Queued requests wait FIFO. Default: 2. */
|
||||
maxConcurrentRenders?: number;
|
||||
}
|
||||
|
||||
export interface ServerOptions extends HandlerOptions {
|
||||
/** Port to listen on. Default: 9847. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
interface RenderInput {
|
||||
projectDir: string;
|
||||
outputPath?: string | null;
|
||||
fps: import("@hyperframes/core").Fps;
|
||||
quality: "draft" | "standard" | "high";
|
||||
format?: "mp4" | "webm" | "mov";
|
||||
videoFrameFormat?: RenderConfig["videoFrameFormat"];
|
||||
workers?: number;
|
||||
useGpu: boolean;
|
||||
debug: boolean;
|
||||
entryFile?: string;
|
||||
/**
|
||||
* data-composition-variables overrides forwarded into the render config.
|
||||
* Without this the HTTP/server render path silently rendered the
|
||||
* composition's declared defaults, ignoring per-request overrides.
|
||||
*/
|
||||
variables?: Record<string, unknown>;
|
||||
/**
|
||||
* Output resolution preset (e.g. `landscape-4k`). Drives the same
|
||||
* `resolveDeviceScaleFactor` supersampling path the local CLI uses — Chrome
|
||||
* renders at a higher devicePixelRatio so the captured screenshot lands at
|
||||
* the requested dimensions. Aspect ratio must match the composition.
|
||||
*/
|
||||
outputResolution?: CanvasResolution;
|
||||
}
|
||||
|
||||
interface PreparedRenderInput {
|
||||
input: RenderInput;
|
||||
cleanupProjectDir?: string;
|
||||
}
|
||||
|
||||
export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
|
||||
// Accept either a JSON `number` (integer fps) or a JSON `string` (rational
|
||||
// like "30000/1001"). Falls back to 30 fps on parse failure to preserve the
|
||||
// forgiving behaviour the original whitelist had — the producer surfaces a
|
||||
// clearer downstream error if the value is genuinely unusable.
|
||||
const fpsRaw = body.fps;
|
||||
const fpsParse =
|
||||
typeof fpsRaw === "number" || typeof fpsRaw === "string" ? parseFps(fpsRaw) : null;
|
||||
const fps = fpsParse && fpsParse.ok ? fpsParse.value : ({ num: 30, den: 1 } as const);
|
||||
const quality = (
|
||||
["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high"
|
||||
) as "draft" | "standard" | "high";
|
||||
const workers = typeof body.workers === "number" ? body.workers : undefined;
|
||||
const useGpu = body.gpu === true;
|
||||
const debug = body.debug === true;
|
||||
const outputPath =
|
||||
typeof body.outputPath === "string" && body.outputPath.trim().length > 0
|
||||
? body.outputPath
|
||||
: typeof body.output === "string" && body.output.trim().length > 0
|
||||
? body.output
|
||||
: null;
|
||||
|
||||
const entryFile =
|
||||
typeof body.entryFile === "string" && body.entryFile.trim().length > 0
|
||||
? body.entryFile.trim()
|
||||
: undefined;
|
||||
|
||||
const format = (
|
||||
["mp4", "webm", "mov"].includes(body.format as string) ? body.format : undefined
|
||||
) as RenderInput["format"];
|
||||
const videoFrameFormat = isVideoFrameFormat(body.videoFrameFormat)
|
||||
? body.videoFrameFormat
|
||||
: undefined;
|
||||
|
||||
const { variables, outputResolution } = parseRenderOverrides(body);
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
fps,
|
||||
quality,
|
||||
workers,
|
||||
useGpu,
|
||||
debug,
|
||||
entryFile,
|
||||
format,
|
||||
variables,
|
||||
outputResolution,
|
||||
videoFrameFormat,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the lenient form of the variable + resolution overrides used by
|
||||
* `parseRenderOptions`. Invalid shapes coerce to `undefined` here;
|
||||
* `validateRenderOverrides` separately rejects explicitly-supplied bad values
|
||||
* with a 400 so they aren't silently ignored.
|
||||
*/
|
||||
function parseRenderOverrides(body: Record<string, unknown>): {
|
||||
variables?: Record<string, unknown>;
|
||||
outputResolution?: CanvasResolution;
|
||||
} {
|
||||
// Only forward a plain JSON object. Arrays / primitives / null → undefined.
|
||||
const variables = isPlainObject(body.variables) ? body.variables : undefined;
|
||||
// Accept canonical presets and aliases ("4k", "landscape-4k", …).
|
||||
const outputResolution =
|
||||
typeof body.outputResolution === "string"
|
||||
? normalizeResolutionFlag(body.outputResolution)
|
||||
: undefined;
|
||||
return { variables, outputResolution };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `createRenderJob` config from a prepared render input. Shared by
|
||||
* the sync (`render`) and streaming (`render-stream`) handlers so the field
|
||||
* set — including `variables` and `outputResolution` — stays in one place.
|
||||
*/
|
||||
function buildRenderJobConfig(input: RenderInput, log: ProducerLogger) {
|
||||
return {
|
||||
fps: input.fps,
|
||||
quality: input.quality,
|
||||
format: input.format,
|
||||
workers: input.workers,
|
||||
useGpu: input.useGpu,
|
||||
debug: input.debug,
|
||||
entryFile: input.entryFile,
|
||||
variables: input.variables,
|
||||
outputResolution: input.outputResolution,
|
||||
videoFrameFormat: input.videoFrameFormat,
|
||||
logger: log,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the destination path for a prepared render and ensure its parent
|
||||
* directory exists. Shared by the sync + streaming handlers (their only
|
||||
* difference is how a `prepareRenderBody` error is surfaced — JSON vs SSE —
|
||||
* which stays in each handler).
|
||||
*/
|
||||
function resolvePreparedRenderOutput(
|
||||
prepared: PreparedRenderInput,
|
||||
rendersDir: string,
|
||||
log: ProducerLogger,
|
||||
): { input: RenderInput; cleanupProjectDir?: string; absoluteOutputPath: string } {
|
||||
const { input, cleanupProjectDir } = prepared;
|
||||
const absoluteOutputPath = resolveOutputPath(input.projectDir, input.outputPath, rendersDir, log);
|
||||
const outputDir = dirname(absoluteOutputPath);
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
return { input, cleanupProjectDir, absoluteOutputPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate explicitly-supplied render overrides that can't be sanely coerced.
|
||||
* Returns an error string for a clean 400, or `undefined` when the body is
|
||||
* acceptable (including when the fields are simply absent).
|
||||
*/
|
||||
function validateRenderOverrides(body: Record<string, unknown>): string | undefined {
|
||||
if (body.variables !== undefined && !isPlainObject(body.variables)) {
|
||||
return 'variables must be a JSON object keyed by variable id (e.g. {"title":"Hello"})';
|
||||
}
|
||||
return validateOutputResolutionOverride(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-supplied `outputResolution`. Rejects (a) non-string
|
||||
* values, which parseRenderOverrides would otherwise silently coerce to
|
||||
* `undefined`; (b) unknown presets; and (c) the alpha-format combination —
|
||||
* outputResolution drives deviceScaleFactor supersampling, which the webm/mov
|
||||
* capture path can't apply (resolveDeviceScaleFactor throws mid-render), so we
|
||||
* reject it here for a clean 400 regardless of which caller sent it.
|
||||
*/
|
||||
function validateOutputResolutionOverride(body: Record<string, unknown>): string | undefined {
|
||||
if (body.outputResolution === undefined) return undefined;
|
||||
if (typeof body.outputResolution !== "string") {
|
||||
return 'outputResolution must be a string preset (e.g. "4k", "landscape-4k")';
|
||||
}
|
||||
const normalized = normalizeResolutionFlag(body.outputResolution);
|
||||
if (body.outputResolution.trim().length > 0 && normalized === undefined) {
|
||||
return `Invalid outputResolution "${body.outputResolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: 1080p, 4k, …).`;
|
||||
}
|
||||
if (normalized !== undefined && (body.format === "webm" || body.format === "mov")) {
|
||||
return `outputResolution is not supported with format "${body.format}" — the alpha (webm/mov) capture path can't supersample. Use format "mp4", or omit outputResolution to render at the composition's native dimensions.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function prepareRenderBody(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ prepared: PreparedRenderInput } | { error: string }> {
|
||||
// Reject explicitly-supplied-but-malformed overrides up front so the caller
|
||||
// gets a clear 400 instead of a silently-ignored value.
|
||||
const overrideError = validateRenderOverrides(body);
|
||||
if (overrideError) return { error: overrideError };
|
||||
|
||||
const options = parseRenderOptions(body);
|
||||
const projectDir = typeof body.projectDir === "string" ? body.projectDir : undefined;
|
||||
if (projectDir) {
|
||||
const absProjectDir = resolve(projectDir);
|
||||
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
|
||||
return { error: `Project directory not found: ${absProjectDir}` };
|
||||
}
|
||||
const entry = options.entryFile || "index.html";
|
||||
if (!existsSync(resolve(absProjectDir, entry))) {
|
||||
return { error: `Entry file "${entry}" not found in project directory: ${absProjectDir}` };
|
||||
}
|
||||
return { prepared: { input: { projectDir: absProjectDir, ...options } } };
|
||||
}
|
||||
|
||||
const previewUrl = typeof body.previewUrl === "string" ? body.previewUrl.trim() : "";
|
||||
const inlineHtml = typeof body.html === "string" ? body.html : "";
|
||||
if (!previewUrl && !inlineHtml) {
|
||||
return { error: "Missing render source: provide projectDir, previewUrl, or html" };
|
||||
}
|
||||
|
||||
let htmlContent = inlineHtml;
|
||||
if (!htmlContent) {
|
||||
try {
|
||||
const response = await fetch(previewUrl, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
return { error: `Failed to fetch previewUrl: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
htmlContent = await response.text();
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to fetch previewUrl: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir();
|
||||
const tempProjectDir = mkdtempSync(join(tempRoot, "producer-project-"));
|
||||
writeFileSync(join(tempProjectDir, "index.html"), htmlContent, "utf-8");
|
||||
return {
|
||||
prepared: {
|
||||
input: {
|
||||
projectDir: tempProjectDir,
|
||||
...options,
|
||||
},
|
||||
cleanupProjectDir: tempProjectDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOutputPath(
|
||||
projectDir: string,
|
||||
outputCandidate: string | null | undefined,
|
||||
rendersDir: string,
|
||||
log: ProducerLogger,
|
||||
): string {
|
||||
try {
|
||||
return resolveRenderPaths(projectDir, outputCandidate, rendersDir).absoluteOutputPath;
|
||||
} catch (error) {
|
||||
const fallbackPath = resolve(rendersDir, `producer-fallback-${Date.now()}.mp4`);
|
||||
log.warn("Failed to resolve output path, using fallback", {
|
||||
fallback: fallbackPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return fallbackPath;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output artifact management
|
||||
// ---------------------------------------------------------------------------
|
||||
interface OutputArtifact {
|
||||
path: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
function createArtifactStore(ttlMs: number) {
|
||||
const artifacts = new Map<string, OutputArtifact>();
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, artifact] of artifacts.entries()) {
|
||||
if (artifact.expiresAtMs <= now) {
|
||||
artifacts.delete(token);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
cleanup.unref();
|
||||
|
||||
return {
|
||||
register(path: string): string {
|
||||
const token = crypto.randomUUID();
|
||||
artifacts.set(token, { path, expiresAtMs: Date.now() + ttlMs });
|
||||
return token;
|
||||
},
|
||||
get(token: string): OutputArtifact | undefined {
|
||||
return artifacts.get(token);
|
||||
},
|
||||
delete(token: string) {
|
||||
artifacts.delete(token);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupTempDir(dir: string | undefined, log: ProducerLogger): void {
|
||||
if (!dir) return;
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
log.warn("Failed to cleanup temp project dir", {
|
||||
cleanupProjectDir: dir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler factory
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface RenderHandlers {
|
||||
render: (c: Context) => Promise<Response>;
|
||||
renderStream: (c: Context) => Response | Promise<Response>;
|
||||
lint: (c: Context) => Promise<Response>;
|
||||
health: (c: Context) => Response;
|
||||
outputs: (c: Context) => Response;
|
||||
queue: (c: Context) => Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create route handler functions for the producer server.
|
||||
*
|
||||
* These can be mounted on any Hono app at any path prefix.
|
||||
*/
|
||||
export function createRenderHandlers(options: HandlerOptions = {}): RenderHandlers {
|
||||
const log = options.logger ?? defaultLogger;
|
||||
const getRequestId =
|
||||
options.getRequestId ?? ((c: Context) => c.req.header("x-request-id") || crypto.randomUUID());
|
||||
const outputUrlPrefix = options.outputUrlPrefix ?? "/outputs";
|
||||
const rendersDir = options.rendersDir ?? process.env.PRODUCER_RENDERS_DIR ?? "/tmp";
|
||||
const artifactTtlMs =
|
||||
options.artifactTtlMs ?? Number(process.env.PRODUCER_OUTPUT_ARTIFACT_TTL_MS || 15 * 60 * 1000);
|
||||
const store = createArtifactStore(artifactTtlMs);
|
||||
const maxConcurrentRenders =
|
||||
options.maxConcurrentRenders ?? Number(process.env.PRODUCER_MAX_CONCURRENT_RENDERS || 2);
|
||||
const renderSemaphore = new Semaphore(maxConcurrentRenders);
|
||||
const startTime = Date.now();
|
||||
|
||||
const health = (c: Context): Response =>
|
||||
c.json({
|
||||
status: "ok",
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const lint = async (c: Context): Promise<Response> => {
|
||||
const requestId = getRequestId(c);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const preparedResult = prepareHyperframeLintBody(body);
|
||||
if ("error" in preparedResult) {
|
||||
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
|
||||
}
|
||||
|
||||
const result = await runHyperframeLint(preparedResult.prepared);
|
||||
log.info("lint completed", {
|
||||
requestId,
|
||||
entryFile: preparedResult.prepared.entryFile,
|
||||
source: preparedResult.prepared.source,
|
||||
errorCount: result.errorCount,
|
||||
warningCount: result.warningCount,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
requestId,
|
||||
entryFile: preparedResult.prepared.entryFile,
|
||||
source: preparedResult.prepared.source,
|
||||
result,
|
||||
});
|
||||
};
|
||||
|
||||
const render = async (c: Context): Promise<Response> => {
|
||||
const requestId = getRequestId(c);
|
||||
const t0 = Date.now();
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, requestId, error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const preparedResult = await prepareRenderBody(body);
|
||||
if ("error" in preparedResult) {
|
||||
return c.json({ success: false, requestId, error: preparedResult.error }, 400);
|
||||
}
|
||||
|
||||
const { input, cleanupProjectDir, absoluteOutputPath } = resolvePreparedRenderOutput(
|
||||
preparedResult.prepared,
|
||||
rendersDir,
|
||||
log,
|
||||
);
|
||||
|
||||
const release = await renderSemaphore.acquire();
|
||||
|
||||
log.info("render started", {
|
||||
requestId,
|
||||
projectDir: input.projectDir,
|
||||
fps: input.fps,
|
||||
quality: input.quality,
|
||||
});
|
||||
|
||||
const job = createRenderJob(buildRenderJobConfig(input, log));
|
||||
|
||||
let lastLoggedPct = -10;
|
||||
try {
|
||||
await executeRenderJob(job, input.projectDir, absoluteOutputPath, async (j, message) => {
|
||||
const pct = Math.floor(j.progress * 100);
|
||||
if (pct >= lastLoggedPct + 10) {
|
||||
lastLoggedPct = pct;
|
||||
log.info(`render progress ${pct}%`, { requestId, stage: j.currentStage, message });
|
||||
}
|
||||
});
|
||||
|
||||
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
|
||||
const durationMs = Date.now() - t0;
|
||||
const outputToken = store.register(absoluteOutputPath);
|
||||
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
|
||||
log.info("render completed", {
|
||||
requestId,
|
||||
durationMs,
|
||||
fileSize,
|
||||
perf: job.perfSummary ?? null,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
requestId,
|
||||
outputPath: absoluteOutputPath,
|
||||
outputToken,
|
||||
outputUrl,
|
||||
fileSize,
|
||||
durationMs,
|
||||
videoDurationSeconds: job.duration ?? null,
|
||||
perf: job.perfSummary ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - t0;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
log.error("render failed", {
|
||||
requestId,
|
||||
durationMs,
|
||||
error: errorMsg,
|
||||
stage: job.currentStage,
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
stage: job.currentStage,
|
||||
durationMs,
|
||||
errorDetails: job.errorDetails ?? null,
|
||||
},
|
||||
500,
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
cleanupTempDir(cleanupProjectDir, log);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStream = (c: Context) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const requestId = getRequestId(c);
|
||||
const t0 = Date.now();
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "error",
|
||||
requestId,
|
||||
error: "Invalid JSON body",
|
||||
stage: "validation",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedResult = await prepareRenderBody(body);
|
||||
if ("error" in preparedResult) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "error",
|
||||
requestId,
|
||||
error: preparedResult.error,
|
||||
stage: "validation",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { input, cleanupProjectDir, absoluteOutputPath } = resolvePreparedRenderOutput(
|
||||
preparedResult.prepared,
|
||||
rendersDir,
|
||||
log,
|
||||
);
|
||||
|
||||
log.info("render-stream started", { requestId, projectDir: input.projectDir });
|
||||
|
||||
const job = createRenderJob(buildRenderJobConfig(input, log));
|
||||
const abortController = new AbortController();
|
||||
const onRequestAbort = () =>
|
||||
abortController.abort(new RenderCancelledError("request_aborted"));
|
||||
c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
|
||||
|
||||
if (renderSemaphore.activeCount >= maxConcurrentRenders) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "queued",
|
||||
requestId,
|
||||
position: renderSemaphore.waitingCount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const release = await renderSemaphore.acquire();
|
||||
|
||||
try {
|
||||
await executeRenderJob(
|
||||
job,
|
||||
input.projectDir,
|
||||
absoluteOutputPath,
|
||||
async (j, message) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "progress",
|
||||
requestId,
|
||||
stage: j.currentStage,
|
||||
progress: j.progress,
|
||||
framesRendered: j.framesRendered ?? 0,
|
||||
totalFrames: j.totalFrames ?? 0,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const fileSize = existsSync(absoluteOutputPath) ? statSync(absoluteOutputPath).size : 0;
|
||||
const outputToken = store.register(absoluteOutputPath);
|
||||
const outputUrl = `${outputUrlPrefix}/${outputToken}`;
|
||||
log.info("render-stream completed", { requestId, fileSize, perf: job.perfSummary ?? null });
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "complete",
|
||||
requestId,
|
||||
outputPath: absoluteOutputPath,
|
||||
outputToken,
|
||||
outputUrl,
|
||||
fileSize,
|
||||
videoDurationSeconds: job.duration ?? null,
|
||||
perf: job.perfSummary ?? null,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RenderCancelledError) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "cancelled",
|
||||
requestId,
|
||||
stage: job.currentStage,
|
||||
message: error.message,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const elapsedMs = Date.now() - t0;
|
||||
log.error("render-stream failed", {
|
||||
requestId,
|
||||
elapsedMs,
|
||||
error: errorMsg,
|
||||
stage: job.currentStage,
|
||||
});
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "error",
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
stage: job.currentStage,
|
||||
elapsedMs,
|
||||
errorDetails: job.errorDetails ?? null,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
|
||||
cleanupTempDir(cleanupProjectDir, log);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const outputs = (c: Context): Response => {
|
||||
const token = c.req.param("token") ?? "";
|
||||
const artifact = store.get(token);
|
||||
if (!artifact) {
|
||||
return c.json({ success: false, error: "Output artifact not found or expired" }, 404);
|
||||
}
|
||||
if (!existsSync(artifact.path)) {
|
||||
store.delete(token);
|
||||
return c.json({ success: false, error: "Output artifact file missing" }, 404);
|
||||
}
|
||||
const stats = statSync(artifact.path);
|
||||
return new Response(createReadStream(artifact.path) as unknown as ReadableStream, {
|
||||
headers: {
|
||||
"content-type": "video/mp4",
|
||||
"content-length": String(stats.size),
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const queue = (c: Context): Response =>
|
||||
c.json({
|
||||
maxConcurrentRenders,
|
||||
activeRenders: renderSemaphore.activeCount,
|
||||
queuedRenders: renderSemaphore.waitingCount,
|
||||
});
|
||||
|
||||
return { render, renderStream, lint, health, outputs, queue };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public app factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Hono app with clean public routes for OSS use.
|
||||
*/
|
||||
export function createProducerApp(options: HandlerOptions = {}): Hono {
|
||||
const app = new Hono();
|
||||
const handlers = createRenderHandlers(options);
|
||||
|
||||
app.get("/health", handlers.health);
|
||||
app.post("/render", handlers.render);
|
||||
app.post("/render/stream", handlers.renderStream);
|
||||
app.get("/render/queue", handlers.queue);
|
||||
app.post("/lint", handlers.lint);
|
||||
app.get("/outputs/:token", handlers.outputs);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the producer HTTP server with graceful shutdown.
|
||||
*/
|
||||
export function startServer(options: ServerOptions = {}) {
|
||||
const port = options.port ?? parseInt(process.env.PRODUCER_PORT ?? "9847", 10);
|
||||
const log = options.logger ?? defaultLogger;
|
||||
const app = createProducerApp(options);
|
||||
|
||||
const server = serve({ fetch: app.fetch, port }, () => {
|
||||
log.info(`Listening on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Disable timeouts for long renders
|
||||
server.setTimeout(0);
|
||||
(server as unknown as import("node:http").Server).requestTimeout = 0;
|
||||
(server as unknown as import("node:http").Server).keepAliveTimeout = 0;
|
||||
|
||||
// Start the worker-thread health endpoint alongside the main listener.
|
||||
// The main thread keeps serving /health on `port` for backwards
|
||||
// compatibility; the worker thread additionally serves /health on
|
||||
// PRODUCER_HEALTH_PORT (default 9848) so k8s liveness/readiness probes can
|
||||
// migrate to a listener that doesn't share an event loop with renders.
|
||||
//
|
||||
// Opt-out: set PRODUCER_DISABLE_HEALTH_WORKER=1 (e.g. for tests that don't
|
||||
// want a worker spawned, or for environments where the extra port isn't
|
||||
// wanted).
|
||||
//
|
||||
// We store the *promise* (not the resolved handle) so a SIGTERM that
|
||||
// arrives before the worker has finished booting still has something to
|
||||
// await. Awaiting a `let healthWorker = null` mutated from inside `.then`
|
||||
// would race: if SIGTERM lands before the `.then` callback fires,
|
||||
// `shutdown()` sees `null` and skips worker cleanup. The promise pattern
|
||||
// closes that window without making startup blocking.
|
||||
const healthWorkerPromise: Promise<HealthWorkerHandle | null> =
|
||||
process.env.PRODUCER_DISABLE_HEALTH_WORKER === "1"
|
||||
? Promise.resolve(null)
|
||||
: startHealthWorker({ logger: log }).catch((err: Error) => {
|
||||
// Don't crash the producer if the worker fails to start — the main
|
||||
// /health is still up. Log loudly so the operator notices.
|
||||
log.error(`[server] health worker failed to start: ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
log.info(`Received ${signal}, shutting down`);
|
||||
const { drainBrowserPool } = await import("@hyperframes/engine");
|
||||
await drainBrowserPool().catch(() => {});
|
||||
// Bounded await: if the worker hasn't come online within 1.5s of
|
||||
// shutdown there's no useful cleanup left to do — `worker.terminate()`
|
||||
// from process exit will kill the thread regardless, and we'd rather
|
||||
// not let a hung-startup worker keep the SIGTERM path waiting.
|
||||
const handle = await Promise.race<HealthWorkerHandle | null>([
|
||||
healthWorkerPromise,
|
||||
new Promise<null>((res) => setTimeout(() => res(null), 1_500).unref()),
|
||||
]);
|
||||
if (handle) {
|
||||
await handle.shutdown().catch(() => {});
|
||||
}
|
||||
server.close(() => {
|
||||
log.info("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
log.warn("Forced exit after 30s timeout");
|
||||
process.exit(1);
|
||||
}, 30_000).unref();
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-executable: node dist/public-server.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Only auto-start when this file is the explicit entry point.
|
||||
// In esbuild bundles, import.meta.url is shared across inlined modules,
|
||||
// so we check argv[1] against known public server filenames.
|
||||
const entryScript = process.argv[1] ? resolve(process.argv[1]) : "";
|
||||
const isPublicServerEntry =
|
||||
entryScript.endsWith("/public-server.js") || entryScript.endsWith("/src/server.ts");
|
||||
|
||||
if (isPublicServerEntry) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
port: { type: "string", short: "p", default: process.env.PRODUCER_PORT ?? "9847" },
|
||||
},
|
||||
});
|
||||
startServer({ port: parseInt(values.port as string, 10) });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Test fixture worker (not part of the production build).
|
||||
*
|
||||
* Simulates a worker that dies mid-task — e.g. an OOM kill on a heavy shader
|
||||
* frame — by throwing an uncaught exception on the first message it receives.
|
||||
* The throw surfaces as the Worker `error` event (followed by `exit`), which
|
||||
* is exactly the crash path the shader / png-decode pools must recover from:
|
||||
* the crashing slot has to be marked dead so a later task is never routed to
|
||||
* its terminated worker (where `postMessage` is a silent no-op and the task
|
||||
* would hang forever).
|
||||
*
|
||||
* Referenced only by path via the pools' `workerEntryPath` option in
|
||||
* shaderTransitionWorkerPool.test.ts / pngDecodeBlitWorkerPool.test.ts.
|
||||
*/
|
||||
import { parentPort } from "node:worker_threads";
|
||||
|
||||
parentPort?.on("message", () => {
|
||||
throw new Error("simulated worker crash");
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
buildAnimatedGifTranscodeArgs,
|
||||
prepareAnimatedGifInputs,
|
||||
type AnimatedGifTranscodeRequest,
|
||||
} from "./animatedGifPrep.js";
|
||||
|
||||
function u16(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function ascii(value: string): number[] {
|
||||
return Array.from(value).map((char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function frame(delayCentiseconds: number): number[] {
|
||||
return [
|
||||
0x21,
|
||||
0xf9,
|
||||
0x04,
|
||||
0x00,
|
||||
...u16(delayCentiseconds),
|
||||
0x00,
|
||||
0x00,
|
||||
0x2c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x02,
|
||||
0x4c,
|
||||
0x01,
|
||||
0x00,
|
||||
];
|
||||
}
|
||||
|
||||
function gif(frames: number[], loopCount?: number): Uint8Array {
|
||||
const loop =
|
||||
loopCount === undefined
|
||||
? []
|
||||
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
|
||||
return Uint8Array.from([
|
||||
...ascii("GIF89a"),
|
||||
...u16(1),
|
||||
...u16(1),
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
...loop,
|
||||
...frames,
|
||||
0x3b,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeProject(): string {
|
||||
return mkdtempSync(join(tmpdir(), "hf-gif-prep-"));
|
||||
}
|
||||
|
||||
describe("buildAnimatedGifTranscodeArgs", () => {
|
||||
it("builds VP9 WebM args with alpha and finite-loop expansion", () => {
|
||||
const args = buildAnimatedGifTranscodeArgs({
|
||||
inputPath: "/in/reaction.gif",
|
||||
outputPath: "/out/reaction.webm",
|
||||
loopIterations: 3,
|
||||
});
|
||||
|
||||
expect(args).toContain("-stream_loop");
|
||||
expect(args).toContain("2");
|
||||
expect(args).toContain("libvpx-vp9");
|
||||
expect(args).toContain("yuva420p");
|
||||
expect(args[args.indexOf("-cpu-used") + 1]).toBe("4");
|
||||
expect(args).toContain("-ignore_loop");
|
||||
// Output goes to an extension-less tmp path; the muxer must be explicit.
|
||||
expect(args.join(" ")).toContain("-f webm");
|
||||
});
|
||||
|
||||
it("clone-pads the tail when the clip window outlives the looped source", () => {
|
||||
const args = buildAnimatedGifTranscodeArgs({
|
||||
inputPath: "/in/reaction.gif",
|
||||
outputPath: "/out/reaction.webm",
|
||||
loopIterations: 2,
|
||||
padSeconds: 1.5,
|
||||
});
|
||||
|
||||
expect(args.join(" ")).toContain("tpad=stop_mode=clone:stop_duration=1.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareAnimatedGifInputs", () => {
|
||||
it("rewrites animated GIF images to muted looped videos", async () => {
|
||||
const projectDir = makeProject();
|
||||
writeFileSync(join(projectDir, "sticker.gif"), gif([...frame(5), ...frame(15)], 0));
|
||||
const calls: AnimatedGifTranscodeRequest[] = [];
|
||||
|
||||
const result = await prepareAnimatedGifInputs(
|
||||
`<img class="clip badge" data-start="1" data-duration="4" src="sticker.gif" alt="sticker" />`,
|
||||
{
|
||||
projectDir,
|
||||
downloadDir: projectDir,
|
||||
transcode: async (request) => {
|
||||
calls.push(request);
|
||||
writeFileSync(request.outputPath, "webm");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { document } = parseHTML(result.html);
|
||||
const video = document.querySelector("video");
|
||||
expect(video?.getAttribute("src")).toMatch(/^_animated_gif\/hfgif-v1-/);
|
||||
expect(video?.getAttribute("class")).toBe("clip badge");
|
||||
expect(video?.hasAttribute("loop")).toBe(true);
|
||||
expect(video?.hasAttribute("muted")).toBe(true);
|
||||
expect(video?.getAttribute("data-has-audio")).toBe("false");
|
||||
expect(video?.getAttribute("data-end")).toBe("5");
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
expect(result.preparedAssets.size).toBe(1);
|
||||
expect(result.preparedGifs[0]?.metadata.delaysCentiseconds).toEqual([5, 15]);
|
||||
expect(calls).toHaveLength(1);
|
||||
// 0.2s source looped to cover the 4s clip window: 20 iterations baked in,
|
||||
// because the render pipeline seek-syncs videos and ignores native loop.
|
||||
expect(result.preparedGifs[0]?.loopIterations).toBe(20);
|
||||
expect(calls[0]?.args.join(" ")).toContain("-stream_loop 19");
|
||||
});
|
||||
|
||||
it("leaves single-frame GIF images unchanged", async () => {
|
||||
const projectDir = makeProject();
|
||||
writeFileSync(join(projectDir, "still.gif"), gif(frame(10)));
|
||||
const result = await prepareAnimatedGifInputs(`<img src="still.gif" />`, {
|
||||
projectDir,
|
||||
downloadDir: projectDir,
|
||||
transcode: async () => {
|
||||
throw new Error("should not transcode");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.html).toContain("<img");
|
||||
expect(result.preparedAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("lets data-loop override infinite GIF metadata", async () => {
|
||||
const projectDir = makeProject();
|
||||
writeFileSync(join(projectDir, "reaction.gif"), gif([...frame(10), ...frame(10)], 0));
|
||||
const result = await prepareAnimatedGifInputs(
|
||||
`<img data-start="0" data-duration="2" data-loop="false" src="reaction.gif" />`,
|
||||
{
|
||||
projectDir,
|
||||
downloadDir: projectDir,
|
||||
transcode: async (request) => {
|
||||
writeFileSync(request.outputPath, "webm");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { document } = parseHTML(result.html);
|
||||
expect(document.querySelector("video")?.hasAttribute("loop")).toBe(false);
|
||||
});
|
||||
|
||||
it("expands finite loop metadata into the transcoded source", async () => {
|
||||
const projectDir = makeProject();
|
||||
writeFileSync(join(projectDir, "finite.gif"), gif([...frame(10), ...frame(10)], 3));
|
||||
const calls: AnimatedGifTranscodeRequest[] = [];
|
||||
|
||||
const result = await prepareAnimatedGifInputs(`<img src="finite.gif" />`, {
|
||||
projectDir,
|
||||
downloadDir: projectDir,
|
||||
transcode: async (request) => {
|
||||
calls.push(request);
|
||||
writeFileSync(request.outputPath, "webm");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.preparedGifs[0]?.loop).toBe(false);
|
||||
expect(result.preparedGifs[0]?.loopIterations).toBe(3);
|
||||
expect(calls[0]?.args).toContain("-stream_loop");
|
||||
expect(calls[0]?.args).toContain("2");
|
||||
});
|
||||
|
||||
it("uses source asset mappings for remote GIF URLs", async () => {
|
||||
const projectDir = makeProject();
|
||||
const sourceUrl = "https://cdn.example.com/reaction.gif";
|
||||
const sourcePath = join(projectDir, "downloaded.gif");
|
||||
writeFileSync(sourcePath, gif([...frame(10), ...frame(10)], 0));
|
||||
|
||||
const result = await prepareAnimatedGifInputs(
|
||||
`<img data-start="0" data-duration="2" src="${sourceUrl}" />`,
|
||||
{
|
||||
projectDir,
|
||||
downloadDir: projectDir,
|
||||
sourceAssets: new Map([[sourceUrl, sourcePath]]),
|
||||
transcode: async (request) => {
|
||||
writeFileSync(request.outputPath, "webm");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { document } = parseHTML(result.html);
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toMatch(
|
||||
/^_animated_gif\/hfgif-v1-/,
|
||||
);
|
||||
expect(result.preparedGifs[0]?.sourceSrc).toBe(sourceUrl);
|
||||
});
|
||||
|
||||
it("propagates actionable transcode failure messages", async () => {
|
||||
const projectDir = makeProject();
|
||||
const sourcePath = join(projectDir, "broken.gif");
|
||||
writeFileSync(sourcePath, gif([...frame(10), ...frame(10)], 0));
|
||||
|
||||
await expect(
|
||||
prepareAnimatedGifInputs(`<img src="broken.gif" />`, {
|
||||
projectDir,
|
||||
downloadDir: projectDir,
|
||||
transcode: async (request) => {
|
||||
throw new Error(
|
||||
`ffmpeg failed for ${request.inputPath}: Invalid data found when processing input`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(`ffmpeg failed for ${sourcePath}: Invalid data found`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core";
|
||||
import { DEFAULT_VP9_CPU_USED, getFfmpegBinary } from "@hyperframes/engine";
|
||||
import { isHttpUrl } from "../utils/urlDownloader.js";
|
||||
|
||||
const PREPARED_GIF_SUBDIR = "_animated_gif";
|
||||
const CACHE_SCHEMA = "hfgif-v1";
|
||||
|
||||
export interface PreparedAnimatedGif {
|
||||
id: string;
|
||||
sourceSrc: string;
|
||||
outputSrc: string;
|
||||
outputPath: string;
|
||||
metadata: AnimatedGifMetadata;
|
||||
loop: boolean;
|
||||
loopIterations: number;
|
||||
padSeconds: number;
|
||||
}
|
||||
|
||||
export interface PrepareAnimatedGifInputsOptions {
|
||||
projectDir: string;
|
||||
downloadDir: string;
|
||||
outputDir?: string;
|
||||
outputSrcPrefix?: string;
|
||||
cacheDir?: string;
|
||||
sourceAssets?: Map<string, string>;
|
||||
timeoutMs?: number;
|
||||
transcode?: (input: AnimatedGifTranscodeRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AnimatedGifTranscodeRequest {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
args: string[];
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PrepareAnimatedGifInputsResult {
|
||||
html: string;
|
||||
preparedAssets: Map<string, string>;
|
||||
preparedGifs: PreparedAnimatedGif[];
|
||||
}
|
||||
|
||||
function splitUrlSuffix(src: string): { basePath: string; suffix: string } {
|
||||
const queryIdx = src.indexOf("?");
|
||||
const hashIdx = src.indexOf("#");
|
||||
if (queryIdx < 0 && hashIdx < 0) return { basePath: src, suffix: "" };
|
||||
const cutIdx = queryIdx < 0 ? hashIdx : hashIdx < 0 ? queryIdx : Math.min(queryIdx, hashIdx);
|
||||
return { basePath: src.slice(0, cutIdx), suffix: src.slice(cutIdx) };
|
||||
}
|
||||
|
||||
function normalizeRelPath(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function hasGifExtension(src: string): boolean {
|
||||
const { basePath } = splitUrlSuffix(src.trim().toLowerCase());
|
||||
return basePath.endsWith(".gif");
|
||||
}
|
||||
|
||||
function readLoopOverride(el: Element): boolean | null {
|
||||
const raw = el.getAttribute("data-loop");
|
||||
if (raw == null) return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "" || normalized === "true" || normalized === "1" || normalized === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGifSourcePath(
|
||||
src: string,
|
||||
options: Pick<PrepareAnimatedGifInputsOptions, "projectDir" | "downloadDir" | "sourceAssets">,
|
||||
): string | null {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed || trimmed.startsWith("data:")) return null;
|
||||
const { basePath } = splitUrlSuffix(trimmed);
|
||||
const normalizedBase = normalizeRelPath(basePath);
|
||||
|
||||
const mapped =
|
||||
options.sourceAssets?.get(trimmed) ??
|
||||
options.sourceAssets?.get(basePath) ??
|
||||
options.sourceAssets?.get(normalizedBase);
|
||||
if (mapped && existsSync(mapped)) return mapped;
|
||||
if (isHttpUrl(trimmed)) return null;
|
||||
|
||||
const projectRelative = basePath.startsWith("/") ? basePath.slice(1) : basePath;
|
||||
const candidates = [
|
||||
isAbsolute(basePath) ? basePath : resolve(options.projectDir, projectRelative),
|
||||
resolve(options.downloadDir, normalizedBase),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function isUsableFile(path: string): boolean {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function computePreparedGifHash(
|
||||
bytes: Uint8Array,
|
||||
loopIterations: number,
|
||||
padSeconds: number,
|
||||
): string {
|
||||
return createHash("sha256")
|
||||
.update(CACHE_SCHEMA)
|
||||
.update("\0")
|
||||
.update(String(loopIterations))
|
||||
.update("\0")
|
||||
.update(String(padSeconds))
|
||||
.update("\0")
|
||||
.update(bytes)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function resolveLoop(metadata: AnimatedGifMetadata, override: boolean | null): boolean {
|
||||
if (override != null) return override;
|
||||
return metadata.loopCount === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The render pipeline seek-syncs <video> elements and does not honor the
|
||||
* native `loop` attribute, so a 2s WebM inside a 10s clip would vanish after
|
||||
* 2s. Prep-time knows the clip window, so looping is baked into the encoded
|
||||
* file: enough `-stream_loop` iterations to cover the window, plus a
|
||||
* clone-frame pad for finite-loop GIFs that hold their last frame.
|
||||
*/
|
||||
const MAX_LOOP_ITERATIONS = 1_000;
|
||||
const MAX_PAD_SECONDS = 3_600;
|
||||
|
||||
function resolveCompositionDurationSeconds(document: Document): number | null {
|
||||
let max: number | null = null;
|
||||
for (const el of Array.from(document.querySelectorAll("[data-composition-id][data-duration]"))) {
|
||||
const value = Number.parseFloat(el.getAttribute("data-duration") || "");
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
max = max == null ? value : Math.max(max, value);
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function resolveClipWindowSeconds(img: Element, compositionDuration: number | null): number | null {
|
||||
const durationRaw = img.getAttribute("data-duration");
|
||||
if (durationRaw != null) {
|
||||
const duration = Number.parseFloat(durationRaw);
|
||||
if (Number.isFinite(duration) && duration > 0) return duration;
|
||||
}
|
||||
if (compositionDuration != null) {
|
||||
const startRaw = Number.parseFloat(img.getAttribute("data-start") || "0");
|
||||
const start = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0;
|
||||
const window = compositionDuration - start;
|
||||
return window > 0 ? window : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePreparedPlayback(
|
||||
metadata: AnimatedGifMetadata,
|
||||
loop: boolean,
|
||||
windowSeconds: number | null,
|
||||
): { loopIterations: number; padSeconds: number } {
|
||||
const gifDuration = metadata.durationSeconds > 0 ? metadata.durationSeconds : null;
|
||||
|
||||
let loopIterations = 1;
|
||||
if (loop) {
|
||||
loopIterations =
|
||||
windowSeconds != null && gifDuration != null
|
||||
? Math.min(MAX_LOOP_ITERATIONS, Math.max(1, Math.ceil(windowSeconds / gifDuration)))
|
||||
: 1;
|
||||
} else if (metadata.loopCount != null && metadata.loopCount > 1) {
|
||||
loopIterations = Math.min(MAX_LOOP_ITERATIONS, metadata.loopCount);
|
||||
}
|
||||
|
||||
let padSeconds = 0;
|
||||
if (windowSeconds != null && gifDuration != null) {
|
||||
const covered = gifDuration * loopIterations;
|
||||
if (windowSeconds > covered) {
|
||||
padSeconds = Math.min(windowSeconds - covered, MAX_PAD_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
return { loopIterations, padSeconds: Math.round(padSeconds * 1000) / 1000 };
|
||||
}
|
||||
|
||||
export function buildAnimatedGifTranscodeArgs(input: {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
loopIterations: number;
|
||||
padSeconds?: number;
|
||||
}): string[] {
|
||||
const args = ["-hide_banner", "-loglevel", "error"];
|
||||
if (input.loopIterations > 1) {
|
||||
args.push("-stream_loop", String(input.loopIterations - 1));
|
||||
}
|
||||
args.push(
|
||||
"-ignore_loop",
|
||||
"1",
|
||||
"-i",
|
||||
input.inputPath,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-pix_fmt",
|
||||
"yuva420p",
|
||||
"-auto-alt-ref",
|
||||
"0",
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
String(DEFAULT_VP9_CPU_USED),
|
||||
"-crf",
|
||||
"18",
|
||||
"-b:v",
|
||||
"0",
|
||||
...(input.padSeconds && input.padSeconds > 0
|
||||
? ["-vf", `tpad=stop_mode=clone:stop_duration=${input.padSeconds}`]
|
||||
: []),
|
||||
// Explicit muxer: the transcode writes to a `.tmp-<pid>-<ts>` path whose
|
||||
// extension ffmpeg cannot infer the container from.
|
||||
"-f",
|
||||
"webm",
|
||||
"-y",
|
||||
input.outputPath,
|
||||
);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function runAnimatedGifTranscode(request: AnimatedGifTranscodeRequest): Promise<void> {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const proc = spawn(getFfmpegBinary(), request.args);
|
||||
let stderr = "";
|
||||
const timeout = request.timeoutMs ?? 300_000;
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill("SIGTERM");
|
||||
reject(new Error(`Animated GIF transcode timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Animated GIF transcode failed (${code}): ${stderr.slice(-500)}`));
|
||||
});
|
||||
proc.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePreparedWebm(input: {
|
||||
sourcePath: string;
|
||||
cachePath: string;
|
||||
outputPath: string;
|
||||
loopIterations: number;
|
||||
padSeconds: number;
|
||||
timeoutMs?: number;
|
||||
transcode?: (request: AnimatedGifTranscodeRequest) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (!existsSync(dirname(input.cachePath))) {
|
||||
mkdirSync(dirname(input.cachePath), { recursive: true });
|
||||
}
|
||||
if (!existsSync(dirname(input.outputPath))) {
|
||||
mkdirSync(dirname(input.outputPath), { recursive: true });
|
||||
}
|
||||
|
||||
if (!isUsableFile(input.cachePath)) {
|
||||
const tmpPath = `${input.cachePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
const args = buildAnimatedGifTranscodeArgs({
|
||||
inputPath: input.sourcePath,
|
||||
outputPath: tmpPath,
|
||||
loopIterations: input.loopIterations,
|
||||
padSeconds: input.padSeconds,
|
||||
});
|
||||
const transcode = input.transcode ?? runAnimatedGifTranscode;
|
||||
try {
|
||||
await transcode({
|
||||
inputPath: input.sourcePath,
|
||||
outputPath: tmpPath,
|
||||
args,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
if (!isUsableFile(tmpPath)) {
|
||||
throw new Error("Animated GIF transcode produced an empty output");
|
||||
}
|
||||
if (!isUsableFile(input.cachePath)) {
|
||||
renameSync(tmpPath, input.cachePath);
|
||||
} else {
|
||||
rmSync(tmpPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
rmSync(tmpPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolve(input.outputPath) !== resolve(input.cachePath) && !isUsableFile(input.outputPath)) {
|
||||
copyFileSync(input.cachePath, input.outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureElementId(el: Element, document: Document, fallbackIndex: number): string {
|
||||
const existing = (el.getAttribute("id") || "").trim();
|
||||
if (existing) return existing;
|
||||
let next = fallbackIndex;
|
||||
while (document.getElementById(`hf-gif-${next}`)) next += 1;
|
||||
const id = `hf-gif-${next}`;
|
||||
el.setAttribute("id", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function ensureTimingAttributes(video: Element): void {
|
||||
if (!video.hasAttribute("data-start")) {
|
||||
video.setAttribute("data-start", "0");
|
||||
}
|
||||
if (!video.hasAttribute("data-end")) {
|
||||
const durationRaw = video.getAttribute("data-duration");
|
||||
if (durationRaw != null) {
|
||||
const start = Number.parseFloat(video.getAttribute("data-start") || "0");
|
||||
const duration = Number.parseFloat(durationRaw);
|
||||
if (Number.isFinite(start) && Number.isFinite(duration) && duration > 0) {
|
||||
video.setAttribute("data-end", String(start + duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceImageWithVideo(input: {
|
||||
img: Element;
|
||||
id: string;
|
||||
outputSrc: string;
|
||||
loop: boolean;
|
||||
}): Element {
|
||||
const document = input.img.ownerDocument;
|
||||
const video = document.createElement("video");
|
||||
for (const attr of Array.from(input.img.attributes)) {
|
||||
if (attr.name === "src") continue;
|
||||
video.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
video.setAttribute("id", input.id);
|
||||
video.setAttribute("src", input.outputSrc);
|
||||
video.setAttribute("muted", "");
|
||||
video.setAttribute("playsinline", "");
|
||||
video.setAttribute("preload", "auto");
|
||||
video.setAttribute("data-has-audio", "false");
|
||||
video.setAttribute("data-hf-prepared-gif", "true");
|
||||
if (input.loop) {
|
||||
video.setAttribute("loop", "");
|
||||
} else {
|
||||
video.removeAttribute("loop");
|
||||
}
|
||||
ensureTimingAttributes(video);
|
||||
input.img.replaceWith(video);
|
||||
return video;
|
||||
}
|
||||
|
||||
export async function prepareAnimatedGifInputs(
|
||||
html: string,
|
||||
options: PrepareAnimatedGifInputsOptions,
|
||||
): Promise<PrepareAnimatedGifInputsResult> {
|
||||
const outputDir = options.outputDir ?? join(options.downloadDir, PREPARED_GIF_SUBDIR);
|
||||
const outputSrcPrefix = normalizeRelPath(options.outputSrcPrefix ?? PREPARED_GIF_SUBDIR);
|
||||
const cacheDir = options.cacheDir ?? outputDir;
|
||||
const { document } = parseHTML(html);
|
||||
const preparedAssets = new Map<string, string>();
|
||||
const preparedGifs: PreparedAnimatedGif[] = [];
|
||||
const images = Array.from(document.querySelectorAll("img[src]"));
|
||||
const compositionDuration = resolveCompositionDurationSeconds(document);
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
const src = (img.getAttribute("src") || "").trim();
|
||||
if (!hasGifExtension(src)) continue;
|
||||
const sourcePath = resolveGifSourcePath(src, options);
|
||||
if (!sourcePath) continue;
|
||||
|
||||
const bytes = readFileSync(sourcePath);
|
||||
const metadata = parseAnimatedGifMetadata(bytes);
|
||||
if (!metadata?.animated) continue;
|
||||
|
||||
const loop = resolveLoop(metadata, readLoopOverride(img));
|
||||
const windowSeconds = resolveClipWindowSeconds(img, compositionDuration);
|
||||
const { loopIterations, padSeconds } = resolvePreparedPlayback(metadata, loop, windowSeconds);
|
||||
const hash = computePreparedGifHash(bytes, loopIterations, padSeconds);
|
||||
const filename = `${CACHE_SCHEMA}-${hash.slice(0, 24)}.webm`;
|
||||
const cachePath = join(cacheDir, filename);
|
||||
const outputPath = join(outputDir, filename);
|
||||
const outputSrc = `${outputSrcPrefix}/${filename}`;
|
||||
|
||||
await ensurePreparedWebm({
|
||||
sourcePath,
|
||||
cachePath,
|
||||
outputPath,
|
||||
loopIterations,
|
||||
padSeconds,
|
||||
timeoutMs: options.timeoutMs,
|
||||
transcode: options.transcode,
|
||||
});
|
||||
|
||||
const id = ensureElementId(img, document, i);
|
||||
replaceImageWithVideo({ img, id, outputSrc, loop });
|
||||
preparedAssets.set(outputSrc, outputPath);
|
||||
preparedGifs.push({
|
||||
id,
|
||||
sourceSrc: src,
|
||||
outputSrc,
|
||||
outputPath,
|
||||
metadata,
|
||||
loop,
|
||||
loopIterations,
|
||||
padSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
html: preparedGifs.length > 0 ? document.toString() : html,
|
||||
preparedAssets,
|
||||
preparedGifs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// fallow-ignore-file unused-file code-duplication complexity
|
||||
/**
|
||||
* Audio Extractor Service
|
||||
*
|
||||
* Extracts audio from media elements in the composition HTML,
|
||||
* applies timeline positioning, and mixes into a single audio track.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { getFfmpegBinary, trackChildProcess } from "@hyperframes/engine";
|
||||
|
||||
export interface AudioElement {
|
||||
id: string;
|
||||
src: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
mediaStart: number;
|
||||
volume: number;
|
||||
tagName: "audio" | "video";
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
id: string;
|
||||
srcPath: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
mediaStart: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse audio/video elements from HTML to find media with audio.
|
||||
*/
|
||||
export function parseAudioElements(html: string): AudioElement[] {
|
||||
const elements: AudioElement[] = [];
|
||||
|
||||
// Match <audio> and <video> elements with data-start
|
||||
const mediaRegex = /<(audio|video)\s[^>]*data-start=["']([^"']+)["'][^>]*>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = mediaRegex.exec(html)) !== null) {
|
||||
const fullTag = match[0];
|
||||
const tagName = (match[1] ?? "").toLowerCase() as "audio" | "video";
|
||||
const start = parseFloat(match[2] ?? "");
|
||||
|
||||
const idMatch = fullTag.match(/id=["']([^"']+)["']/);
|
||||
const srcMatch = fullTag.match(/src=["']([^"']+)["']/);
|
||||
if (!srcMatch) continue;
|
||||
|
||||
const durationMatch = fullTag.match(/data-duration=["']([^"']+)["']/);
|
||||
const endMatch = fullTag.match(/data-end=["']([^"']+)["']/);
|
||||
const mediaStartMatch = fullTag.match(/data-media-start=["']([^"']+)["']/);
|
||||
const volumeMatch = fullTag.match(/data-volume=["']([^"']+)["']/);
|
||||
const parsedVolume = volumeMatch ? parseFloat(volumeMatch[1] ?? "") : 1.0;
|
||||
const safeVolume = Number.isFinite(parsedVolume) ? parsedVolume : 1.0;
|
||||
const durationFromDuration = durationMatch ? parseFloat(durationMatch[1] ?? "") : NaN;
|
||||
const end = endMatch ? parseFloat(endMatch[1] ?? "") : NaN;
|
||||
const duration =
|
||||
!isNaN(durationFromDuration) && durationFromDuration > 0
|
||||
? durationFromDuration
|
||||
: !isNaN(end) && end > start
|
||||
? end - start
|
||||
: 0;
|
||||
|
||||
elements.push({
|
||||
id: idMatch?.[1] || `media-${elements.length}`,
|
||||
src: srcMatch[1] ?? "",
|
||||
start: isNaN(start) ? 0 : start,
|
||||
duration,
|
||||
mediaStart: mediaStartMatch ? parseFloat(mediaStartMatch[1] ?? "") : 0,
|
||||
volume: safeVolume,
|
||||
tagName,
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an FFmpeg command and return a promise.
|
||||
*/
|
||||
function runFFmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
|
||||
ffmpeg.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract audio from a single media file.
|
||||
*/
|
||||
async function extractAudioTrack(
|
||||
srcPath: string,
|
||||
outputPath: string,
|
||||
playbackStart: number,
|
||||
duration: number,
|
||||
): Promise<boolean> {
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
if (playbackStart > 0) {
|
||||
args.push("-ss", String(playbackStart));
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
args.push("-t", String(duration));
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-i",
|
||||
srcPath,
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2",
|
||||
"-y",
|
||||
outputPath,
|
||||
);
|
||||
|
||||
try {
|
||||
await runFFmpeg(args);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[AudioExtractor] Failed to extract audio from ${srcPath}: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a silence audio file.
|
||||
*/
|
||||
async function generateSilence(outputPath: string, duration: number): Promise<void> {
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
await runFFmpeg([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=r=48000:cl=stereo",
|
||||
"-t",
|
||||
String(duration),
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-y",
|
||||
outputPath,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mix multiple audio tracks with timeline positioning.
|
||||
*/
|
||||
async function mixTracks(
|
||||
tracks: AudioTrack[],
|
||||
outputPath: string,
|
||||
totalDuration: number,
|
||||
): Promise<void> {
|
||||
if (tracks.length === 0) {
|
||||
await generateSilence(outputPath, totalDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const inputs: string[] = [];
|
||||
const filterParts: string[] = [];
|
||||
|
||||
tracks.forEach((track, i) => {
|
||||
inputs.push("-i", track.srcPath);
|
||||
|
||||
const delayMs = Math.round(track.start * 1000);
|
||||
const trimDuration = track.duration > 0 ? track.duration : totalDuration;
|
||||
|
||||
filterParts.push(
|
||||
`[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
|
||||
);
|
||||
});
|
||||
|
||||
const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
|
||||
// amix divides by track count by default (normalize=true). Compensate with
|
||||
// a volume gain to preserve per-track levels across all FFmpeg versions.
|
||||
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest[mixed]`;
|
||||
const postMixGain = `[mixed]volume=${tracks.length}[out]`;
|
||||
const fullFilter = [...filterParts, mixFilter, postMixGain].join(";");
|
||||
|
||||
const args = [
|
||||
...inputs,
|
||||
"-filter_complex",
|
||||
fullFilter,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-t",
|
||||
String(totalDuration),
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
|
||||
await runFFmpeg(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all audio for a composition.
|
||||
*
|
||||
* @param htmlPath - Path to the composition HTML (for parsing media elements)
|
||||
* @param projectDir - Base directory for resolving relative media paths
|
||||
* @param workDir - Temporary working directory for intermediate files
|
||||
* @param outputPath - Final mixed audio output path
|
||||
* @param totalDuration - Total composition duration in seconds
|
||||
* @returns true if audio was produced, false if no audio elements found
|
||||
*/
|
||||
export async function processAudio(
|
||||
htmlPath: string,
|
||||
projectDir: string,
|
||||
workDir: string,
|
||||
outputPath: string,
|
||||
totalDuration: number,
|
||||
): Promise<boolean> {
|
||||
const html = readFileSync(htmlPath, "utf-8");
|
||||
const elements = parseAudioElements(html);
|
||||
|
||||
if (elements.length === 0) {
|
||||
console.log("[AudioExtractor] No audio elements found");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[AudioExtractor] Processing ${elements.length} audio element(s)...`);
|
||||
|
||||
if (!existsSync(workDir)) {
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tracks: AudioTrack[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
// Resolve source path relative to project directory
|
||||
let srcPath = element.src;
|
||||
if (!srcPath.startsWith("/") && !srcPath.startsWith("http")) {
|
||||
srcPath = join(projectDir, srcPath);
|
||||
}
|
||||
|
||||
if (!existsSync(srcPath)) {
|
||||
console.warn(`[AudioExtractor] Source not found: ${srcPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const extractedPath = join(workDir, `${element.id}-extracted.wav`);
|
||||
const success = await extractAudioTrack(
|
||||
srcPath,
|
||||
extractedPath,
|
||||
element.mediaStart,
|
||||
element.duration,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
tracks.push({
|
||||
id: element.id,
|
||||
srcPath: extractedPath,
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
mediaStart: element.mediaStart,
|
||||
volume: element.volume,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AudioExtractor] Mixing ${tracks.length} track(s) into final audio...`);
|
||||
await mixTracks(tracks, outputPath, totalDuration);
|
||||
|
||||
// Clean up work directory
|
||||
try {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
console.log("[AudioExtractor] Audio processing complete");
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/services/audioMixer.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
parseAudioElements,
|
||||
processCompositionAudio,
|
||||
type AudioElement,
|
||||
type AudioTrack,
|
||||
type MixResult,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/services/browserManager.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
acquireBrowser,
|
||||
releaseBrowser,
|
||||
drainBrowserPool,
|
||||
resolveHeadlessShellPath,
|
||||
buildChromeArgs,
|
||||
ENABLE_BROWSER_POOL,
|
||||
type BuildChromeArgsOptions,
|
||||
type CaptureMode,
|
||||
type AcquiredBrowser,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/services/chunkEncoder.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
encodeFramesFromDir,
|
||||
encodeFramesChunkedConcat,
|
||||
muxVideoWithAudio,
|
||||
applyFaststart,
|
||||
detectGpuEncoder,
|
||||
ENCODER_PRESETS,
|
||||
type EncoderOptions,
|
||||
type EncodeResult,
|
||||
type MuxResult,
|
||||
type GpuEncoder,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Compilation Test Runner
|
||||
*
|
||||
* Orchestrates compilation tests: compiles input HTML, compares to golden files.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
import { validateCompilation, type CompilationValidationResult } from "./compilationTester.js";
|
||||
|
||||
export interface CompilationTestResult {
|
||||
testId: string;
|
||||
passed: boolean;
|
||||
validation: CompilationValidationResult;
|
||||
compilationTimeMs: number;
|
||||
compiledHtmlPath?: string; // For --keep-temp
|
||||
}
|
||||
|
||||
interface TestSuite {
|
||||
id: string;
|
||||
dir: string;
|
||||
srcDir: string;
|
||||
goldenMp4: string;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run compilation test for a test suite.
|
||||
* Compiles src/index.html and compares against compiled.html golden file.
|
||||
*/
|
||||
export async function runCompilationTest(
|
||||
suite: TestSuite,
|
||||
keepTemp: boolean,
|
||||
): Promise<CompilationTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create temp directory for downloads (if HTML has HTTP URLs)
|
||||
const tempDir = mkdtempSync(join(tmpdir(), `compile-test-${suite.id}-`));
|
||||
|
||||
try {
|
||||
// Compile the input HTML
|
||||
const inputHtmlPath = join(suite.srcDir, "index.html");
|
||||
if (!existsSync(inputHtmlPath)) {
|
||||
throw new Error(`Input HTML not found: ${inputHtmlPath}`);
|
||||
}
|
||||
|
||||
const compiled = await compileForRender(suite.srcDir, inputHtmlPath, tempDir);
|
||||
|
||||
const actualHtml = compiled.html;
|
||||
|
||||
// Load golden compiled HTML
|
||||
const goldenPath = join(suite.dir, "output", "compiled.html");
|
||||
if (!existsSync(goldenPath)) {
|
||||
throw new Error(`Golden compiled.html not found: ${goldenPath}`);
|
||||
}
|
||||
|
||||
const goldenHtml = readFileSync(goldenPath, "utf-8");
|
||||
|
||||
// Validate
|
||||
const validation = validateCompilation(actualHtml, goldenHtml);
|
||||
|
||||
const compilationTimeMs = Date.now() - startTime;
|
||||
|
||||
// Save compiled HTML if --keep-temp
|
||||
let compiledHtmlPath: string | undefined;
|
||||
if (keepTemp) {
|
||||
compiledHtmlPath = join(tempDir, "compiled.html");
|
||||
writeFileSync(compiledHtmlPath, actualHtml, "utf-8");
|
||||
}
|
||||
|
||||
return {
|
||||
testId: suite.id,
|
||||
passed: validation.passed,
|
||||
validation,
|
||||
compilationTimeMs,
|
||||
compiledHtmlPath,
|
||||
};
|
||||
} catch (error) {
|
||||
const compilationTimeMs = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
testId: suite.id,
|
||||
passed: false,
|
||||
validation: {
|
||||
passed: false,
|
||||
actualElements: [],
|
||||
goldenElements: [],
|
||||
errors: [`Compilation failed: ${errorMessage}`],
|
||||
warnings: [],
|
||||
},
|
||||
compilationTimeMs,
|
||||
};
|
||||
} finally {
|
||||
// Cleanup temp directory unless --keep-temp
|
||||
if (!keepTemp && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or update compiled.html golden file for a test suite.
|
||||
* Compiles src/index.html and writes to compiled.html.
|
||||
*/
|
||||
export async function updateCompiledGolden(suite: TestSuite): Promise<void> {
|
||||
console.log(`[${suite.id}] Updating compiled.html golden file...`);
|
||||
|
||||
// Create temp directory for downloads
|
||||
const tempDir = mkdtempSync(join(tmpdir(), `update-golden-${suite.id}-`));
|
||||
|
||||
try {
|
||||
const inputHtmlPath = join(suite.srcDir, "index.html");
|
||||
if (!existsSync(inputHtmlPath)) {
|
||||
throw new Error(`Input HTML not found: ${inputHtmlPath}`);
|
||||
}
|
||||
|
||||
// Compile the input HTML
|
||||
const compiled = await compileForRender(suite.srcDir, inputHtmlPath, tempDir);
|
||||
|
||||
// Write to output/compiled.html
|
||||
const outputDir = join(suite.dir, "output");
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const goldenPath = join(outputDir, "compiled.html");
|
||||
writeFileSync(goldenPath, compiled.html, "utf-8");
|
||||
|
||||
console.log(
|
||||
`[${suite.id}] ✓ Updated output/compiled.html (${compiled.videos.length} video(s), ${compiled.audios.length} audio(s))`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[${suite.id}] ✗ Failed to update compiled.html: ${errorMessage}`);
|
||||
throw error;
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Compilation Testing Service
|
||||
*
|
||||
* Validates that HTML compilation produces correct timing attributes.
|
||||
* Compares compiled HTML against golden files using semantic attribute matching.
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
export interface CompiledElement {
|
||||
id: string;
|
||||
tagName: "video" | "audio" | "div";
|
||||
src?: string;
|
||||
dataStart: number;
|
||||
dataEnd: number | null;
|
||||
dataDuration: number | null;
|
||||
dataHasAudio?: boolean;
|
||||
dataMediaStart?: number;
|
||||
compositionSrc?: string;
|
||||
}
|
||||
|
||||
export interface CompilationValidationResult {
|
||||
passed: boolean;
|
||||
actualElements: CompiledElement[];
|
||||
goldenElements: CompiledElement[];
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type CompositionStructure = {
|
||||
id: string;
|
||||
selfCompositionId: string | null;
|
||||
descendantCompositionIds: string[];
|
||||
};
|
||||
|
||||
const EPSILON = 0.001; // Tolerance for floating-point timing comparisons
|
||||
|
||||
/**
|
||||
* Parse HTML and extract all elements with timing attributes.
|
||||
* Includes <video>, <audio>, and <div data-composition-src>.
|
||||
*/
|
||||
function extractTimedElements(html: string): CompiledElement[] {
|
||||
const elements: CompiledElement[] = [];
|
||||
|
||||
// Extract video elements
|
||||
const videoRegex = /<video[^>]*>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = videoRegex.exec(html)) !== null) {
|
||||
const fullTag = match[0];
|
||||
const idMatch = fullTag.match(/id=["']([^"']+)["']/);
|
||||
if (!idMatch) continue;
|
||||
|
||||
const srcMatch = fullTag.match(/src=["']([^"']+)["']/);
|
||||
const startMatch = fullTag.match(/data-start=["']([^"']+)["']/);
|
||||
const endMatch = fullTag.match(/data-end=["']([^"']+)["']/);
|
||||
const durationMatch = fullTag.match(/data-duration=["']([^"']+)["']/);
|
||||
const mediaStartMatch = fullTag.match(/data-media-start=["']([^"']+)["']/);
|
||||
const hasAudioMatch = fullTag.match(/data-has-audio=["']([^"']+)["']/);
|
||||
|
||||
elements.push({
|
||||
id: idMatch[1] ?? "",
|
||||
tagName: "video",
|
||||
src: srcMatch?.[1],
|
||||
dataStart: startMatch ? parseFloat(startMatch[1] ?? "") : 0,
|
||||
dataEnd: endMatch ? parseFloat(endMatch[1] ?? "") : null,
|
||||
dataDuration: durationMatch ? parseFloat(durationMatch[1] ?? "") : null,
|
||||
dataMediaStart: mediaStartMatch ? parseFloat(mediaStartMatch[1] ?? "") : undefined,
|
||||
dataHasAudio: hasAudioMatch ? hasAudioMatch[1] === "true" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract audio elements
|
||||
const audioRegex = /<audio[^>]*>/gi;
|
||||
while ((match = audioRegex.exec(html)) !== null) {
|
||||
const fullTag = match[0];
|
||||
const idMatch = fullTag.match(/id=["']([^"']+)["']/);
|
||||
if (!idMatch) continue;
|
||||
|
||||
const srcMatch = fullTag.match(/src=["']([^"']+)["']/);
|
||||
const startMatch = fullTag.match(/data-start=["']([^"']+)["']/);
|
||||
const endMatch = fullTag.match(/data-end=["']([^"']+)["']/);
|
||||
const durationMatch = fullTag.match(/data-duration=["']([^"']+)["']/);
|
||||
const mediaStartMatch = fullTag.match(/data-media-start=["']([^"']+)["']/);
|
||||
|
||||
elements.push({
|
||||
id: idMatch[1] ?? "",
|
||||
tagName: "audio",
|
||||
src: srcMatch?.[1],
|
||||
dataStart: startMatch ? parseFloat(startMatch[1] ?? "") : 0,
|
||||
dataEnd: endMatch ? parseFloat(endMatch[1] ?? "") : null,
|
||||
dataDuration: durationMatch ? parseFloat(durationMatch[1] ?? "") : null,
|
||||
dataMediaStart: mediaStartMatch ? parseFloat(mediaStartMatch[1] ?? "") : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract composition elements (div with data-composition-src)
|
||||
const compRegex = /<div[^>]*data-composition-src[^>]*>/gi;
|
||||
while ((match = compRegex.exec(html)) !== null) {
|
||||
const fullTag = match[0];
|
||||
const idMatch = fullTag.match(/id=["']([^"']+)["']/);
|
||||
if (!idMatch) continue;
|
||||
|
||||
const compositionSrcMatch = fullTag.match(/data-composition-src=["']([^"']+)["']/);
|
||||
const startMatch = fullTag.match(/data-start=["']([^"']+)["']/);
|
||||
const endMatch = fullTag.match(/data-end=["']([^"']+)["']/);
|
||||
const durationMatch = fullTag.match(/data-duration=["']([^"']+)["']/);
|
||||
|
||||
elements.push({
|
||||
id: idMatch[1] ?? "",
|
||||
tagName: "div",
|
||||
compositionSrc: compositionSrcMatch?.[1],
|
||||
dataStart: startMatch ? parseFloat(startMatch[1] ?? "") : 0,
|
||||
dataEnd: endMatch ? parseFloat(endMatch[1] ?? "") : null,
|
||||
dataDuration: durationMatch ? parseFloat(durationMatch[1] ?? "") : null,
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function extractCompositionStructures(html: string): CompositionStructure[] {
|
||||
const { document } = parseHTML(html);
|
||||
const elements = Array.from(document.querySelectorAll<HTMLElement>("[id]"));
|
||||
|
||||
return elements.map((element) => {
|
||||
const descendantCompositionIds = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("[data-composition-id]"),
|
||||
)
|
||||
.filter((candidate) => candidate !== element)
|
||||
.map((candidate) => candidate.getAttribute("data-composition-id") || "")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
id: element.id,
|
||||
selfCompositionId: element.getAttribute("data-composition-id"),
|
||||
descendantCompositionIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single element's timing attributes.
|
||||
* Returns array of error messages (empty if valid).
|
||||
*/
|
||||
function validateElementTiming(element: CompiledElement, label: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// For video and audio, require data-end and data-duration
|
||||
if (element.tagName === "video" || element.tagName === "audio") {
|
||||
if (element.dataEnd === null) {
|
||||
errors.push(`${label} [${element.id}]: missing data-end attribute`);
|
||||
}
|
||||
if (element.dataDuration === null) {
|
||||
errors.push(`${label} [${element.id}]: missing data-duration attribute`);
|
||||
}
|
||||
|
||||
// Check timing math: data-end should equal data-start + data-duration
|
||||
if (element.dataEnd !== null && element.dataDuration !== null) {
|
||||
const computed = element.dataStart + element.dataDuration;
|
||||
if (Math.abs(element.dataEnd - computed) > EPSILON) {
|
||||
errors.push(
|
||||
`${label} [${element.id}]: data-end (${element.dataEnd}) != data-start (${element.dataStart}) + data-duration (${element.dataDuration}) = ${computed}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video-specific: require data-has-audio
|
||||
if (element.tagName === "video" && element.dataHasAudio === undefined) {
|
||||
errors.push(`${label} [${element.id}]: missing data-has-audio attribute`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two elements and return differences.
|
||||
* Compares timing attributes with epsilon tolerance.
|
||||
*/
|
||||
function compareElements(actual: CompiledElement, golden: CompiledElement): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Compare tag names
|
||||
if (actual.tagName !== golden.tagName) {
|
||||
errors.push(
|
||||
`[${actual.id}]: tagName mismatch (actual: ${actual.tagName}, golden: ${golden.tagName})`,
|
||||
);
|
||||
return errors; // Don't continue if tag mismatch
|
||||
}
|
||||
|
||||
// Compare data-start (should be exact)
|
||||
if (Math.abs(actual.dataStart - golden.dataStart) > EPSILON) {
|
||||
errors.push(
|
||||
`[${actual.id}]: data-start mismatch (actual: ${actual.dataStart}, golden: ${golden.dataStart})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Compare data-end with epsilon tolerance
|
||||
if (golden.dataEnd !== null) {
|
||||
if (actual.dataEnd === null) {
|
||||
errors.push(`[${actual.id}]: missing data-end (golden has: ${golden.dataEnd})`);
|
||||
} else if (Math.abs(actual.dataEnd - golden.dataEnd) > EPSILON) {
|
||||
errors.push(
|
||||
`[${actual.id}]: data-end mismatch (actual: ${actual.dataEnd}, golden: ${golden.dataEnd})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare data-duration with epsilon tolerance
|
||||
if (golden.dataDuration !== null) {
|
||||
if (actual.dataDuration === null) {
|
||||
errors.push(`[${actual.id}]: missing data-duration (golden has: ${golden.dataDuration})`);
|
||||
} else if (Math.abs(actual.dataDuration - golden.dataDuration) > EPSILON) {
|
||||
errors.push(
|
||||
`[${actual.id}]: data-duration mismatch (actual: ${actual.dataDuration}, golden: ${golden.dataDuration})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare data-has-audio (video only)
|
||||
if (actual.tagName === "video") {
|
||||
if (actual.dataHasAudio !== golden.dataHasAudio) {
|
||||
errors.push(
|
||||
`[${actual.id}]: data-has-audio mismatch (actual: ${actual.dataHasAudio}, golden: ${golden.dataHasAudio})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare composition-src (composition only)
|
||||
if (actual.tagName === "div" && actual.compositionSrc !== golden.compositionSrc) {
|
||||
errors.push(
|
||||
`[${actual.id}]: data-composition-src mismatch (actual: ${actual.compositionSrc}, golden: ${golden.compositionSrc})`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate compiled HTML against golden HTML.
|
||||
* Returns detailed validation result with errors and warnings.
|
||||
*/
|
||||
export function validateCompilation(
|
||||
actualHtml: string,
|
||||
goldenHtml: string,
|
||||
): CompilationValidationResult {
|
||||
const actualElements = extractTimedElements(actualHtml);
|
||||
const goldenElements = extractTimedElements(goldenHtml);
|
||||
const actualStructures = extractCompositionStructures(actualHtml);
|
||||
const goldenStructures = extractCompositionStructures(goldenHtml);
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate actual element timings
|
||||
for (const element of actualElements) {
|
||||
const timingErrors = validateElementTiming(element, "actual");
|
||||
errors.push(...timingErrors);
|
||||
}
|
||||
|
||||
// Validate golden element timings (sanity check)
|
||||
for (const element of goldenElements) {
|
||||
const timingErrors = validateElementTiming(element, "golden");
|
||||
if (timingErrors.length > 0) {
|
||||
warnings.push(`Golden file has invalid timing: ${timingErrors.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create maps for comparison
|
||||
const actualMap = new Map<string, CompiledElement>();
|
||||
const goldenMap = new Map<string, CompiledElement>();
|
||||
const actualStructureMap = new Map<string, CompositionStructure>();
|
||||
const goldenStructureMap = new Map<string, CompositionStructure>();
|
||||
|
||||
for (const el of actualElements) {
|
||||
actualMap.set(el.id, el);
|
||||
}
|
||||
|
||||
for (const el of goldenElements) {
|
||||
goldenMap.set(el.id, el);
|
||||
}
|
||||
|
||||
for (const structure of actualStructures) {
|
||||
actualStructureMap.set(structure.id, structure);
|
||||
}
|
||||
|
||||
for (const structure of goldenStructures) {
|
||||
goldenStructureMap.set(structure.id, structure);
|
||||
}
|
||||
|
||||
// Check for missing elements (in golden but not in actual)
|
||||
for (const [id] of goldenMap) {
|
||||
if (!actualMap.has(id)) {
|
||||
errors.push(`Missing element [${id}] (present in golden, not in actual)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra elements (in actual but not in golden)
|
||||
for (const [id, actualEl] of actualMap) {
|
||||
if (!goldenMap.has(id)) {
|
||||
warnings.push(
|
||||
`Extra element [${id}] <${actualEl.tagName}> (present in actual, not in golden)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare matching elements
|
||||
for (const [id, actualEl] of actualMap) {
|
||||
const goldenEl = goldenMap.get(id);
|
||||
if (!goldenEl) continue;
|
||||
|
||||
const compareErrors = compareElements(actualEl, goldenEl);
|
||||
errors.push(...compareErrors);
|
||||
}
|
||||
|
||||
for (const [id, goldenStructure] of goldenStructureMap) {
|
||||
const actualStructure = actualStructureMap.get(id);
|
||||
if (!actualStructure) continue;
|
||||
|
||||
if (actualStructure.selfCompositionId !== goldenStructure.selfCompositionId) {
|
||||
errors.push(
|
||||
`[${id}]: data-composition-id mismatch (actual: ${actualStructure.selfCompositionId ?? "null"}, golden: ${goldenStructure.selfCompositionId ?? "null"})`,
|
||||
);
|
||||
}
|
||||
|
||||
const actualDescendants = actualStructure.descendantCompositionIds.join(",");
|
||||
const goldenDescendants = goldenStructure.descendantCompositionIds.join(",");
|
||||
if (actualDescendants !== goldenDescendants) {
|
||||
errors.push(
|
||||
`[${id}]: descendant composition ids mismatch (actual: ${actualDescendants || "none"}, golden: ${goldenDescendants || "none"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const passed = errors.length === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
actualElements,
|
||||
goldenElements,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import puppeteer, { type Browser, type Page } from "puppeteer";
|
||||
|
||||
const RUNTIME_PATH = resolve(import.meta.dirname, "../../../core/dist/hyperframe.runtime.iife.js");
|
||||
|
||||
describe("core runtime browser contract", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
page = await browser.newPage();
|
||||
await page.setContent(`<!doctype html>
|
||||
<style>
|
||||
@keyframes slide { from { transform: translateX(0); } to { transform: translateX(100px); } }
|
||||
#box { animation: slide 2s linear both; }
|
||||
</style>
|
||||
<div data-composition-id="root" data-start="0" data-duration="2" data-width="320" data-height="180">
|
||||
<div id="box"></div>
|
||||
</div>`);
|
||||
await page.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as unknown as { __playerReady?: boolean }).__playerReady === true &&
|
||||
(window as unknown as { __renderReady?: boolean }).__renderReady === true,
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it("initializes the public player contract and seeks the CSS adapter", async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
const runtimeWindow = window as unknown as {
|
||||
__player?: {
|
||||
play?: () => void;
|
||||
pause?: () => void;
|
||||
renderSeek?: (timeSeconds: number) => void;
|
||||
getDuration?: () => number;
|
||||
isPlaying?: () => boolean;
|
||||
};
|
||||
};
|
||||
const player = runtimeWindow.__player;
|
||||
player?.renderSeek?.(1);
|
||||
const animation = document.getElementById("box")?.getAnimations()[0];
|
||||
return {
|
||||
hasPlay: typeof player?.play === "function",
|
||||
hasPause: typeof player?.pause === "function",
|
||||
hasRenderSeek: typeof player?.renderSeek === "function",
|
||||
duration: player?.getDuration?.(),
|
||||
animationTime: Number(animation?.currentTime),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasPlay: true,
|
||||
hasPause: true,
|
||||
hasRenderSeek: true,
|
||||
duration: 2,
|
||||
animationTime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the control bridge during teardown", async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtimeWindow = window as unknown as {
|
||||
__hfRuntimeTeardown?: (() => void) | null;
|
||||
__player?: { isPlaying?: () => boolean };
|
||||
};
|
||||
const hadTeardown = typeof runtimeWindow.__hfRuntimeTeardown === "function";
|
||||
runtimeWindow.__hfRuntimeTeardown?.();
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "hf-parent", type: "control", action: "play" },
|
||||
}),
|
||||
);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => resolveFrame(undefined)));
|
||||
return {
|
||||
hadTeardown,
|
||||
teardownCleared: runtimeWindow.__hfRuntimeTeardown === null,
|
||||
isPlaying: runtimeWindow.__player?.isPlaying?.(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result).toEqual({ hadTeardown: true, teardownCleared: true, isPlaying: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Tests for `injectDeterministicFontFaces`'s `failClosedFontFetch` gate.
|
||||
*
|
||||
* Production callers (the in-process `htmlCompiler`) call the function
|
||||
* without options and get the legacy behavior: external font fetch failures
|
||||
* are swallowed and a warning is logged. Distributed callers pass
|
||||
* `failClosedFontFetch: true` so non-deterministic infrastructure failures
|
||||
* (5xx, network errors, DNS) surface as typed non-retryable failures before
|
||||
* any chunk is rendered. 4xx responses are treated as a *deterministic*
|
||||
* "Google Fonts does not serve this family" answer — same outcome on every
|
||||
* retry — and pass through to the embedded-face fallback without
|
||||
* tripping `failClosedFontFetch` in either mode.
|
||||
*
|
||||
* The tests inject `fetchImpl` so no real network call happens.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
FONT_FETCH_FAILED,
|
||||
FontFetchError,
|
||||
injectDeterministicFontFaces,
|
||||
} from "./deterministicFonts.js";
|
||||
|
||||
// HTML that requests a font NOT in FONT_ALIASES, so the resolver falls
|
||||
// through to the Google Fonts fetch path. (Bundled fonts like Inter
|
||||
// bypass fetch entirely.)
|
||||
const HTML_REQUESTING_UNRESOLVED_FONT = `<!doctype html>
|
||||
<html><head><style>
|
||||
body { font-family: "NotARealFontFamilyForTest", sans-serif; }
|
||||
</style></head>
|
||||
<body><h1>hello</h1></body>
|
||||
</html>`;
|
||||
|
||||
function makeFailingFetch(): typeof fetch {
|
||||
return (async () => {
|
||||
throw new TypeError("simulated network failure");
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function makeHttp404Fetch(): typeof fetch {
|
||||
return (async () =>
|
||||
new Response("", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function makeHttp400Fetch(): typeof fetch {
|
||||
return (async () =>
|
||||
new Response("", { status: 400, statusText: "Bad Request" })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function makeHttp503Fetch(): typeof fetch {
|
||||
return (async () =>
|
||||
new Response("", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)", () => {
|
||||
it("swallows a network failure and returns the original HTML (no throw)", async () => {
|
||||
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: false,
|
||||
allowSystemFontCapture: false,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
// No @font-face was injected because the fetch failed — but the call
|
||||
// resolves successfully with the original HTML.
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("swallows a 404 response and returns the original HTML (no throw)", async () => {
|
||||
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: false,
|
||||
allowSystemFontCapture: false,
|
||||
fetchImpl: makeHttp404Fetch(),
|
||||
});
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("swallows a 5xx response and returns the original HTML (no throw)", async () => {
|
||||
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: false,
|
||||
allowSystemFontCapture: false,
|
||||
fetchImpl: makeHttp503Fetch(),
|
||||
});
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves legacy behavior when no options object is supplied at all", async () => {
|
||||
// injectDeterministicFontFaces(html) — no second arg.
|
||||
// We can't easily mock fetch globally here, so just assert the call
|
||||
// signature still accepts a single argument.
|
||||
const fn = injectDeterministicFontFaces;
|
||||
expect(fn.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
|
||||
it("throws FontFetchError on a network failure", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
expect((caught as FontFetchError).code).toBe("FONT_FETCH_FAILED");
|
||||
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
|
||||
expect((caught as Error).message).toContain("simulated network failure");
|
||||
});
|
||||
|
||||
it("throws on a 4xx response when font is completely unresolvable", async () => {
|
||||
// 4xx from Google Fonts is deterministic ("this family isn't served"),
|
||||
// so it doesn't throw at the *fetch* level. But the font still ends up
|
||||
// unresolvable (no alias, no Google Fonts, no system capture) which IS
|
||||
// a fail-closed error — the render would use a fallback font, producing
|
||||
// non-deterministic output across machines.
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
allowSystemFontCapture: false,
|
||||
fetchImpl: makeHttp400Fetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
|
||||
});
|
||||
|
||||
it("throws on a 404 response when font is completely unresolvable", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
allowSystemFontCapture: false,
|
||||
fetchImpl: makeHttp404Fetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
});
|
||||
|
||||
it("throws FontFetchError on a 5xx response — non-deterministic, could differ on retry", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeHttp503Fetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FontFetchError);
|
||||
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
|
||||
expect((caught as Error).message).toContain("HTTP 503");
|
||||
expect((caught as Error).message).toContain("NotARealFontFamilyForTest");
|
||||
});
|
||||
|
||||
it("includes the requested URL in 5xx errors", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeHttp503Fetch(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as FontFetchError).url).toContain("fonts.googleapis.com");
|
||||
expect((caught as FontFetchError).url).toContain("NotARealFontFamilyForTest");
|
||||
});
|
||||
|
||||
it("does NOT throw when bundled-font Google Fonts supplement returns no extra faces", async () => {
|
||||
// "Inter" is in FONT_ALIASES → uses the embedded font bundle. Since
|
||||
// c8e8fdcf, the resolver also queries Google Fonts to supplement any
|
||||
// weights not in the embedded bundle. A successful empty CSS response
|
||||
// means the bundle was sufficient — `failClosedFontFetch` doesn't
|
||||
// trip. (The full <html><head> wrap is required because
|
||||
// injectDeterministicFontFaces injects into <head>.)
|
||||
const html = `<!doctype html><html><head><style>body { font-family: "Inter", sans-serif; }</style></head><body></body></html>`;
|
||||
const fetchImpl = (async () =>
|
||||
new Response("/* no faces */", { status: 200 })) as unknown as typeof fetch;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||
});
|
||||
|
||||
it("does NOT throw when font-family uses unresolved CSS var() references", async () => {
|
||||
const html = `<!doctype html><html><head><style>
|
||||
body { font-family: var(--missing-font), sans-serif; }
|
||||
</style></head><body><h1>hello</h1></body></html>`;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("resolves simple CSS var() font aliases when injecting deterministic fonts", async () => {
|
||||
const html = `<!doctype html><html><head><style>
|
||||
:root { --ui-font: "Inter"; --vowel-font: "Montserrat"; }
|
||||
body { font-family: var(--ui-font), sans-serif; }
|
||||
h1 { font-family: var(--vowel-font), serif; }
|
||||
</style></head><body><h1>hello</h1></body></html>`;
|
||||
const fetchImpl = (async () =>
|
||||
new Response("/* no extra faces */", { status: 200 })) as unknown as typeof fetch;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||
});
|
||||
|
||||
it("still resolves concrete fonts alongside var() in mixed declarations", async () => {
|
||||
const html = `<!doctype html><html><head><style>
|
||||
body { font-family: var(--ui-font), "Inter", sans-serif; }
|
||||
</style></head><body><p>mixed</p></body></html>`;
|
||||
const fetchImpl = (async () =>
|
||||
new Response("/* no extra faces */", { status: 200 })) as unknown as typeof fetch;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||
});
|
||||
|
||||
it("does NOT throw when the HTML requests no fonts at all", async () => {
|
||||
const html = `<!doctype html><html><body><p>no fonts</p></body></html>`;
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
failClosedFontFetch: true,
|
||||
fetchImpl: makeFailingFetch(),
|
||||
});
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FontFetchError", () => {
|
||||
it("exposes the FONT_FETCH_FAILED typed-failure code", () => {
|
||||
const err = new FontFetchError("Foo", "https://example.com", "boom");
|
||||
expect(err.code).toBe(FONT_FETCH_FAILED);
|
||||
expect(err.code).toBe("FONT_FETCH_FAILED");
|
||||
expect(err.familyName).toBe("Foo");
|
||||
expect(err.url).toBe("https://example.com");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Regression test for the Google Fonts multi-subset cache collision.
|
||||
*
|
||||
* Google Fonts' css2 API returns ONE @font-face per (weight × unicode-range
|
||||
* subset) — e.g. for a single weight you get separate `vietnamese`,
|
||||
* `latin-ext`, and `latin` faces, each pointing at a DISTINCT woff2 whose
|
||||
* glyph coverage matches its `unicode-range`.
|
||||
*
|
||||
* The bug: the on-disk cache keyed woff2 files by `${weight}-${style}` only,
|
||||
* ignoring the subset. So all subsets of a weight collided on one filename —
|
||||
* only the FIRST subset in the CSS (vietnamese, for many display families)
|
||||
* was ever downloaded, and every later subset read that same file back.
|
||||
* Compounding it, the injected @font-face dropped `unicode-range`, so the face
|
||||
* claimed to cover every codepoint while only containing the first subset's
|
||||
* glyphs. Result: Latin letters absent from the embedded font fell back to a
|
||||
* different font (the visible "wrong A" glitch).
|
||||
*
|
||||
* These tests inject `fetchImpl` (no network) and a temp `HYPERFRAMES_FONT_CACHE_DIR`
|
||||
* so they are hermetic.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
let cacheDir: string;
|
||||
let prevCacheEnv: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
cacheDir = mkdtempSync(join(tmpdir(), "hf-font-cache-"));
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
||||
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const VIET_RANGE = "U+0102-0103, U+1EA0-1EF9, U+20AB";
|
||||
const LATIN_RANGE = "U+0000-00FF, U+0131, U+2000-206F";
|
||||
const VIET_URL = "https://fonts.gstatic.com/s/testfam/v1/VIET-subset.woff2";
|
||||
const LATIN_URL = "https://fonts.gstatic.com/s/testfam/v1/LATIN-subset.woff2";
|
||||
// distinct, identifiable "woff2" bodies (content need not be a real font here)
|
||||
const VIET_BYTES = "VIET_SUBSET_BYTES";
|
||||
const LATIN_BYTES = "LATIN_SUBSET_BYTES";
|
||||
const b64 = (s: string) => Buffer.from(s).toString("base64");
|
||||
|
||||
// Two subsets for the SAME weight, vietnamese FIRST (as Google orders it for
|
||||
// display families) then latin — exactly the shape that triggered the bug.
|
||||
const CSS = `/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'TestFam';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(${VIET_URL}) format('woff2');
|
||||
unicode-range: ${VIET_RANGE};
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'TestFam';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(${LATIN_URL}) format('woff2');
|
||||
unicode-range: ${LATIN_RANGE};
|
||||
}`;
|
||||
|
||||
function makeGoogleFetch(): typeof fetch {
|
||||
return (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.includes("css2")) return new Response(CSS, { status: 200 });
|
||||
if (url === VIET_URL) return new Response(VIET_BYTES, { status: 200 });
|
||||
if (url === LATIN_URL) return new Response(LATIN_BYTES, { status: 200 });
|
||||
return new Response("", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const HTML = `<!doctype html><html><head><style>
|
||||
h1 { font-family: "TestFam", sans-serif; }
|
||||
</style></head><body><h1>CATALOG</h1></body></html>`;
|
||||
|
||||
describe("Google Fonts multi-subset embedding", () => {
|
||||
it("downloads and embeds EACH subset distinctly (no cache collision)", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const result = await injectDeterministicFontFaces(HTML, { fetchImpl: makeGoogleFetch() });
|
||||
|
||||
// Both subsets' distinct bytes must be present — the latin subset must NOT
|
||||
// be clobbered by the vietnamese one. (Before the fix, the latin face
|
||||
// carried the vietnamese bytes because both shared one cache filename.)
|
||||
expect(result).toContain(b64(VIET_BYTES));
|
||||
expect(result).toContain(b64(LATIN_BYTES));
|
||||
});
|
||||
|
||||
it("preserves each face's unicode-range so the browser picks the right subset per codepoint", async () => {
|
||||
const { injectDeterministicFontFaces } = await import("./deterministicFonts.js");
|
||||
const result = await injectDeterministicFontFaces(HTML, { fetchImpl: makeGoogleFetch() });
|
||||
|
||||
expect(result).toContain(VIET_RANGE);
|
||||
expect(result).toContain(LATIN_RANGE);
|
||||
// the latin bytes and the latin range belong to the same @font-face block
|
||||
const faces = result.split("@font-face").filter((b) => b.includes("TestFam"));
|
||||
const latinFace = faces.find((f) => f.includes(b64(LATIN_BYTES)));
|
||||
expect(latinFace).toBeDefined();
|
||||
expect(latinFace).toContain(LATIN_RANGE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Tests for the system font capture path (Path 3) in `buildFontFaceCss`.
|
||||
*
|
||||
* When a font family is not in FONT_ALIASES and not served by Google Fonts,
|
||||
* the resolver can locate it on the local filesystem, compress to woff2,
|
||||
* and embed it as a data URI. This path is controlled by the
|
||||
* `allowSystemFontCapture` option.
|
||||
*
|
||||
* Tests inject `fetchImpl` to simulate Google Fonts returning 400 (family
|
||||
* not served) so the resolver falls through to the system font path.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
|
||||
// A font family that is NOT in FONT_ALIASES but exists on macOS as
|
||||
// /System/Library/Fonts/Supplemental/Impact.ttf. When Google Fonts
|
||||
// returns 400 for it, the system font locator should find it locally.
|
||||
const SYSTEM_ONLY_FONT = "Impact";
|
||||
const SYSTEM_ONLY_FONT_PATH = "/System/Library/Fonts/Supplemental/Impact.ttf";
|
||||
|
||||
function makeHtml(fontFamily: string): string {
|
||||
return `<!doctype html>
|
||||
<html><head><style>
|
||||
body { font-family: "${fontFamily}", sans-serif; }
|
||||
</style></head>
|
||||
<body><h1>test</h1></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Google Fonts returns 400 for families it doesn't serve. */
|
||||
function makeHttp400Fetch(): typeof fetch {
|
||||
return (async () =>
|
||||
new Response("", { status: 400, statusText: "Bad Request" })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("system font capture — allowSystemFontCapture option", () => {
|
||||
it("does not attempt system font capture when allowSystemFontCapture is false", async () => {
|
||||
const html = makeHtml("NotARealFontFamilyForTest");
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
fetchImpl: makeHttp400Fetch(),
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
// No @font-face injected — the font is unresolved.
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults allowSystemFontCapture to true when omitted", async () => {
|
||||
// Even without an explicit `allowSystemFontCapture`, the default is
|
||||
// `true` (`options.allowSystemFontCapture !== false`). We test this
|
||||
// with a non-existent font — the locator won't find it, so no
|
||||
// @font-face is injected, but importantly we verify it doesn't throw.
|
||||
const html = makeHtml("ZZZDefinitelyNotInstalledAnywhere");
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
fetchImpl: makeHttp400Fetch(),
|
||||
// allowSystemFontCapture intentionally omitted
|
||||
});
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
}, 15_000); // The system font locator runs system_profiler on macOS which can take >5s.
|
||||
});
|
||||
|
||||
describe("system font capture — integration (macOS only)", () => {
|
||||
const hasFontLocally = existsSync(SYSTEM_ONLY_FONT_PATH);
|
||||
|
||||
it("embeds a local system font when Google Fonts returns 400", async () => {
|
||||
if (!hasFontLocally) {
|
||||
console.warn(`Skipping: ${SYSTEM_ONLY_FONT_PATH} not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
const html = makeHtml(SYSTEM_ONLY_FONT);
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
fetchImpl: makeHttp400Fetch(),
|
||||
allowSystemFontCapture: true,
|
||||
});
|
||||
|
||||
// The system font was found and embedded as a data URI.
|
||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||
expect(result).toContain(`font-family: "${SYSTEM_ONLY_FONT}"`);
|
||||
expect(result).toContain("data:font/woff2;base64,");
|
||||
});
|
||||
|
||||
it("skips system font capture when allowSystemFontCapture is false even if font exists locally", async () => {
|
||||
if (!hasFontLocally) {
|
||||
console.warn(`Skipping: ${SYSTEM_ONLY_FONT_PATH} not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
const html = makeHtml(SYSTEM_ONLY_FONT);
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
fetchImpl: makeHttp400Fetch(),
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
|
||||
// System font capture disabled — font stays unresolved.
|
||||
expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false);
|
||||
});
|
||||
|
||||
it("still resolves alias-mapped fonts via embedded bundle regardless of allowSystemFontCapture", async () => {
|
||||
// "Inter" is in FONT_ALIASES → resolved via embedded bundle, not system font capture.
|
||||
const html = makeHtml("Inter");
|
||||
const result = await injectDeterministicFontFaces(html, {
|
||||
fetchImpl: makeHttp400Fetch(),
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
|
||||
// Even with system font capture disabled, Inter resolves via the alias map.
|
||||
expect(result).toContain("data-hyperframes-deterministic-fonts");
|
||||
expect(result).toContain('font-family: "Inter"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { FONT_ALIASES, FONT_ALIAS_KEYS } from "./deterministicFonts.js";
|
||||
|
||||
describe("FONT_ALIASES cross-platform coverage", () => {
|
||||
it("maps macOS sans-serif system fonts to inter", () => {
|
||||
expect(FONT_ALIASES["sf pro"]).toBe("inter");
|
||||
expect(FONT_ALIASES["sf pro display"]).toBe("inter");
|
||||
expect(FONT_ALIASES["sf pro text"]).toBe("inter");
|
||||
expect(FONT_ALIASES["sf pro rounded"]).toBe("inter");
|
||||
expect(FONT_ALIASES["avenir"]).toBe("inter");
|
||||
expect(FONT_ALIASES["avenir next"]).toBe("inter");
|
||||
expect(FONT_ALIASES["geneva"]).toBe("inter");
|
||||
expect(FONT_ALIASES["optima"]).toBe("inter");
|
||||
expect(FONT_ALIASES["lucida grande"]).toBe("inter");
|
||||
});
|
||||
|
||||
it("maps Windows sans-serif system fonts to inter", () => {
|
||||
expect(FONT_ALIASES["calibri"]).toBe("inter");
|
||||
expect(FONT_ALIASES["candara"]).toBe("inter");
|
||||
expect(FONT_ALIASES["corbel"]).toBe("inter");
|
||||
expect(FONT_ALIASES["verdana"]).toBe("inter");
|
||||
expect(FONT_ALIASES["tahoma"]).toBe("inter");
|
||||
expect(FONT_ALIASES["trebuchet ms"]).toBe("inter");
|
||||
expect(FONT_ALIASES["lucida sans"]).toBe("inter");
|
||||
expect(FONT_ALIASES["lucida sans unicode"]).toBe("inter");
|
||||
});
|
||||
|
||||
it("maps Linux sans-serif system fonts to inter", () => {
|
||||
expect(FONT_ALIASES["noto sans"]).toBe("inter");
|
||||
expect(FONT_ALIASES["dejavu sans"]).toBe("inter");
|
||||
expect(FONT_ALIASES["liberation sans"]).toBe("inter");
|
||||
});
|
||||
|
||||
it("maps monospace system fonts to jetbrains-mono", () => {
|
||||
expect(FONT_ALIASES["sf mono"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["menlo"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["monaco"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["consolas"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["lucida console"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["lucida sans typewriter"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["andale mono"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["dejavu sans mono"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["liberation mono"]).toBe("jetbrains-mono");
|
||||
});
|
||||
|
||||
it("maps serif system fonts to eb-garamond", () => {
|
||||
expect(FONT_ALIASES["georgia"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["palatino"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["palatino linotype"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["book antiqua"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["cambria"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["times"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["times new roman"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["dejavu serif"]).toBe("eb-garamond");
|
||||
expect(FONT_ALIASES["liberation serif"]).toBe("eb-garamond");
|
||||
});
|
||||
|
||||
it("preserves all existing aliases", () => {
|
||||
expect(FONT_ALIASES["helvetica neue"]).toBe("inter");
|
||||
expect(FONT_ALIASES["arial"]).toBe("inter");
|
||||
expect(FONT_ALIASES["courier new"]).toBe("jetbrains-mono");
|
||||
expect(FONT_ALIASES["segoe ui"]).toBe("roboto");
|
||||
expect(FONT_ALIASES["futura"]).toBe("montserrat");
|
||||
expect(FONT_ALIASES["bebas neue"]).toBe("league-gothic");
|
||||
});
|
||||
|
||||
it("exports FONT_ALIAS_KEYS containing all alias entries", () => {
|
||||
expect(FONT_ALIAS_KEYS).toBeInstanceOf(Set);
|
||||
expect(FONT_ALIAS_KEYS.has("sf mono")).toBe(true);
|
||||
expect(FONT_ALIAS_KEYS.has("menlo")).toBe(true);
|
||||
expect(FONT_ALIAS_KEYS.has("consolas")).toBe(true);
|
||||
expect(FONT_ALIAS_KEYS.has("inter")).toBe(true);
|
||||
expect(FONT_ALIAS_KEYS.size).toBe(Object.keys(FONT_ALIASES).length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,909 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { defaultLogger } from "../logger.js";
|
||||
|
||||
import { FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases";
|
||||
import {
|
||||
locateSystemFontVariants,
|
||||
SYSTEM_FONT_SIZE_LIMIT,
|
||||
} from "@hyperframes/core/fonts/system-locator";
|
||||
import { parseHTML } from "linkedom";
|
||||
import postcss, { type AtRule, type Declaration, type Rule } from "postcss";
|
||||
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
|
||||
import { fontToDataUri } from "./fontCompression.js";
|
||||
|
||||
type FontFaceSpec = {
|
||||
weight: string;
|
||||
style?: "normal" | "italic";
|
||||
};
|
||||
|
||||
type CanonicalFontSpec = {
|
||||
packageName: string;
|
||||
faces: FontFaceSpec[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Family names that resolve to a host-OS font (or a CSS generic that the
|
||||
* browser substitutes with a host-OS font). Exported so plan-time validators
|
||||
* can reject them as primary families in distributed renders.
|
||||
*
|
||||
* Lower-cased — call `normalizeFamilyName` on declared values before lookup.
|
||||
*/
|
||||
export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
|
||||
"sans-serif",
|
||||
"serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-sans-serif",
|
||||
"ui-serif",
|
||||
"ui-monospace",
|
||||
"emoji",
|
||||
"math",
|
||||
"fangsong",
|
||||
"-apple-system",
|
||||
"blinkmacsystemfont",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse a single `font-family` value (e.g. `"Inter", -apple-system,
|
||||
* sans-serif`) into a list of unquoted family names in declaration order.
|
||||
* Whitespace and surrounding `"…"` / `'…'` quotes are stripped; case is
|
||||
* preserved. Pass each name through `normalizeFamilyName` for case-
|
||||
* insensitive comparisons.
|
||||
*/
|
||||
export function parseFontFamilyValue(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
|
||||
.filter((piece) => piece.length > 0);
|
||||
}
|
||||
|
||||
function systemPrimaryReplacement(value: string, deterministicPrimary: string): string | null {
|
||||
const families = parseFontFamilyValue(value);
|
||||
if (families.length === 0) return null;
|
||||
if (!GENERIC_FAMILIES.has(normalizeFamilyName(families[0]!))) return null;
|
||||
return `${deterministicPrimary}, ${value.trim()}`;
|
||||
}
|
||||
|
||||
function parseCssRoot(css: string): postcss.Root | null {
|
||||
try {
|
||||
return postcss.parse(css);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isFontFaceDeclaration(decl: Declaration): boolean {
|
||||
const parent = decl.parent;
|
||||
return parent?.type === "atrule" && (parent as AtRule).name.toLowerCase() === "font-face";
|
||||
}
|
||||
|
||||
function normalizeCssDeclarations(root: postcss.Root, deterministicPrimary: string): boolean {
|
||||
let changed = false;
|
||||
root.walkDecls((decl) => {
|
||||
if (decl.prop.startsWith("--")) {
|
||||
const replacement = systemPrimaryReplacement(decl.value, deterministicPrimary);
|
||||
if (!replacement) return;
|
||||
decl.value = replacement;
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (decl.prop.toLowerCase() !== "font-family") return;
|
||||
if (isFontFaceDeclaration(decl)) {
|
||||
return;
|
||||
}
|
||||
const replacement = systemPrimaryReplacement(decl.value, deterministicPrimary);
|
||||
if (!replacement) return;
|
||||
decl.value = replacement;
|
||||
changed = true;
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function normalizeCssFontFamilyDeclarations(css: string, deterministicPrimary: string): string {
|
||||
const root = parseCssRoot(css);
|
||||
if (!root) return css;
|
||||
const changed = normalizeCssDeclarations(root, deterministicPrimary);
|
||||
return changed ? root.toString() : css;
|
||||
}
|
||||
|
||||
function normalizeInlineStyleAttribute(style: string, deterministicPrimary: string): string {
|
||||
const root = parseCssRoot(`*{${style}}`);
|
||||
if (!root) return style;
|
||||
const rule = root.first;
|
||||
if (rule?.type !== "rule") return style;
|
||||
const before = rule.toString();
|
||||
normalizeCssDeclarations(root, deterministicPrimary);
|
||||
if (rule.toString() === before) return style;
|
||||
const serialized = ((rule as Rule).nodes ?? []).map((node) => node.toString()).join("; ");
|
||||
return serialized.endsWith(";") ? serialized : `${serialized};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import/generated HTML often uses host UI stacks such as
|
||||
* `-apple-system, BlinkMacSystemFont, sans-serif` as a primary family. That is
|
||||
* fine on the author's machine but not in distributed render workers, where
|
||||
* host fonts differ by OS. Promote a bundled deterministic family to the
|
||||
* primary slot while preserving the original stack as fallbacks.
|
||||
*/
|
||||
export function normalizeSystemFontPrimaryFamilies(
|
||||
html: string,
|
||||
deterministicPrimary = "Inter",
|
||||
): string {
|
||||
const { document } = parseHTML(html);
|
||||
let changed = false;
|
||||
|
||||
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||||
const current = styleEl.textContent ?? "";
|
||||
const next = normalizeCssFontFamilyDeclarations(current, deterministicPrimary);
|
||||
if (next === current) continue;
|
||||
styleEl.textContent = next;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||||
const current = el.getAttribute("style") ?? "";
|
||||
const next = normalizeInlineStyleAttribute(current, deterministicPrimary);
|
||||
if (next === current) continue;
|
||||
el.setAttribute("style", next);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
|
||||
const current = el.getAttribute("data-font-family") ?? "";
|
||||
const next = systemPrimaryReplacement(current, deterministicPrimary);
|
||||
if (!next) continue;
|
||||
el.setAttribute("data-font-family", next);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? document.toString() : html;
|
||||
}
|
||||
|
||||
/** Surfaces font-family is declared on in served HTML. */
|
||||
export type FontFamilySurface = "font-family" | "data-font-family";
|
||||
|
||||
export type FontFamilyDeclaration = {
|
||||
surface: FontFamilySurface;
|
||||
declaration: string;
|
||||
families: string[];
|
||||
};
|
||||
|
||||
function collectCssCustomProperties(css: string, customProperties: Map<string, string>): void {
|
||||
const root = parseCssRoot(css);
|
||||
if (!root) return;
|
||||
root.walkDecls((decl) => {
|
||||
if (!decl.prop.startsWith("--")) return;
|
||||
customProperties.set(decl.prop, decl.value);
|
||||
});
|
||||
}
|
||||
|
||||
function* iterateCssRootFontFamilyDeclarations(
|
||||
root: postcss.Root,
|
||||
): Generator<FontFamilyDeclaration> {
|
||||
const declarations: FontFamilyDeclaration[] = [];
|
||||
root.walkDecls((decl) => {
|
||||
if (decl.prop.toLowerCase() !== "font-family") return;
|
||||
if (isFontFaceDeclaration(decl)) return;
|
||||
const declaration = decl.value;
|
||||
declarations.push({
|
||||
surface: "font-family",
|
||||
declaration,
|
||||
families: parseFontFamilyValue(declaration),
|
||||
});
|
||||
});
|
||||
yield* declarations;
|
||||
}
|
||||
|
||||
function* iterateCssFontFamilyDeclarations(css: string): Generator<FontFamilyDeclaration> {
|
||||
const root = parseCssRoot(css);
|
||||
if (!root) return;
|
||||
yield* iterateCssRootFontFamilyDeclarations(root);
|
||||
}
|
||||
|
||||
function* iterateInlineStyleFontFamilyDeclarations(
|
||||
style: string,
|
||||
): Generator<FontFamilyDeclaration> {
|
||||
const root = parseCssRoot(`*{${style}}`);
|
||||
if (!root) return;
|
||||
yield* iterateCssRootFontFamilyDeclarations(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect simple CSS custom-property font aliases from style blocks and inline
|
||||
* styles. CSS cascade is richer than this map, but for compiler-generated
|
||||
* imports the common shape is `--font: Inter, sans-serif` paired with
|
||||
* `font-family: var(--font)`.
|
||||
*/
|
||||
export function collectFontFamilyCustomProperties(html: string): Map<string, string> {
|
||||
const { document } = parseHTML(html);
|
||||
const customProperties = new Map<string, string>();
|
||||
|
||||
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||||
collectCssCustomProperties(styleEl.textContent ?? "", customProperties);
|
||||
}
|
||||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||||
collectCssCustomProperties(`*{${el.getAttribute("style") ?? ""}}`, customProperties);
|
||||
}
|
||||
|
||||
return customProperties;
|
||||
}
|
||||
|
||||
function primaryCssVariableName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("var(")) return null;
|
||||
|
||||
let depth = 0;
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const char = trimmed[index];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char !== ")") continue;
|
||||
depth -= 1;
|
||||
if (depth !== 0) continue;
|
||||
|
||||
const varExpression = trimmed.slice(0, index + 1);
|
||||
const inner = varExpression.slice(4, -1).trim();
|
||||
const commaIndex = inner.indexOf(",");
|
||||
const variableName = (commaIndex === -1 ? inner : inner.slice(0, commaIndex)).trim();
|
||||
return /^--[A-Za-z0-9_-]+$/.test(variableName) ? variableName : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveFontFamilyDeclarationFamilies(
|
||||
declaration: string,
|
||||
customProperties: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
const families = parseFontFamilyValue(declaration);
|
||||
const variableName = primaryCssVariableName(declaration);
|
||||
if (!variableName) return families;
|
||||
|
||||
const resolved = customProperties.get(variableName);
|
||||
if (!resolved) return families;
|
||||
return [...parseFontFamilyValue(resolved), ...families.slice(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate every font-family declaration in a compiled HTML document. Yields
|
||||
* each declaration's surface (CSS property vs HTML attribute), raw value,
|
||||
* and the parsed family list. Used by both the @font-face injector and the
|
||||
* plan-time validator so they read the same surface area.
|
||||
*/
|
||||
export function* iterateFontFamilyDeclarations(
|
||||
html: string,
|
||||
): Generator<FontFamilyDeclaration, void, void> {
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
||||
yield* iterateCssFontFamilyDeclarations(styleEl.textContent ?? "");
|
||||
}
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||||
yield* iterateInlineStyleFontFamilyDeclarations(el.getAttribute("style") ?? "");
|
||||
}
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
|
||||
const declaration = el.getAttribute("data-font-family") ?? "";
|
||||
yield { surface: "data-font-family", declaration, families: parseFontFamilyValue(declaration) };
|
||||
}
|
||||
}
|
||||
|
||||
const CANONICAL_FONTS: Record<string, CanonicalFontSpec> = {
|
||||
inter: {
|
||||
packageName: "@fontsource/inter",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
montserrat: {
|
||||
packageName: "@fontsource/montserrat",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
outfit: {
|
||||
packageName: "@fontsource/outfit",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
nunito: {
|
||||
packageName: "@fontsource/nunito",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
oswald: {
|
||||
packageName: "@fontsource/oswald",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"league-gothic": {
|
||||
packageName: "@fontsource/league-gothic",
|
||||
faces: [{ weight: "400" }],
|
||||
},
|
||||
"archivo-black": {
|
||||
packageName: "@fontsource/archivo-black",
|
||||
faces: [{ weight: "400" }],
|
||||
},
|
||||
"space-mono": {
|
||||
packageName: "@fontsource/space-mono",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"ibm-plex-mono": {
|
||||
packageName: "@fontsource/ibm-plex-mono",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"jetbrains-mono": {
|
||||
packageName: "@fontsource/jetbrains-mono",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"eb-garamond": {
|
||||
packageName: "@fontsource/eb-garamond",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"playfair-display": {
|
||||
packageName: "@fontsource/playfair-display",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
"source-code-pro": {
|
||||
packageName: "@fontsource/source-code-pro",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
"noto-sans-jp": {
|
||||
packageName: "@fontsource/noto-sans-jp",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
roboto: {
|
||||
packageName: "@fontsource/roboto",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
"open-sans": {
|
||||
packageName: "@fontsource/open-sans",
|
||||
faces: [{ weight: "400" }, { weight: "700" }],
|
||||
},
|
||||
lato: {
|
||||
packageName: "@fontsource/lato",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
poppins: {
|
||||
packageName: "@fontsource/poppins",
|
||||
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
||||
},
|
||||
};
|
||||
|
||||
// FONT_ALIASES derives from the shared alias map in @hyperframes/core.
|
||||
// The cast is safe: every value in FONT_ALIAS_MAP is a valid CANONICAL_FONTS key.
|
||||
export const FONT_ALIASES = FONT_ALIAS_MAP as Record<string, keyof typeof CANONICAL_FONTS>;
|
||||
|
||||
export { FONT_ALIAS_KEYS } from "@hyperframes/core/fonts/aliases";
|
||||
|
||||
function normalizeFamilyName(family: string): string {
|
||||
return family
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function fontDataUri(
|
||||
packageName: string,
|
||||
weight: string,
|
||||
style: "normal" | "italic" = "normal",
|
||||
): string {
|
||||
const key = `${packageName}:${weight}:${style}`;
|
||||
const uri = EMBEDDED_FONT_DATA.get(key);
|
||||
if (!uri) {
|
||||
throw new Error(
|
||||
`No embedded font data for ${key}. Regenerate with: tsx scripts/generate-font-data.ts`,
|
||||
);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
function extractExistingFontFaces(html: string): Set<string> {
|
||||
const families = new Set<string>();
|
||||
const fontFaceRegex = /@font-face\s*\{[\s\S]*?font-family\s*:\s*([^;]+);[\s\S]*?\}/gi;
|
||||
for (const match of html.matchAll(fontFaceRegex)) {
|
||||
const raw = match[1] || "";
|
||||
const normalized = normalizeFamilyName(raw);
|
||||
if (normalized) {
|
||||
families.add(normalized);
|
||||
}
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
function extractRequestedFontFamilies(html: string): Map<string, string> {
|
||||
const requested = new Map<string, string>();
|
||||
const customProperties = collectFontFamilyCustomProperties(html);
|
||||
for (const { declaration } of iterateFontFamilyDeclarations(html)) {
|
||||
for (const originalCase of resolveFontFamilyDeclarationFamilies(
|
||||
declaration,
|
||||
customProperties,
|
||||
)) {
|
||||
const normalized = originalCase.toLowerCase();
|
||||
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
|
||||
if (normalized.startsWith("var(")) continue;
|
||||
if (!requested.has(normalized)) requested.set(normalized, originalCase);
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
function buildFontFaceRule(
|
||||
familyName: string,
|
||||
src: string,
|
||||
weight: string,
|
||||
style: string,
|
||||
unicodeRange?: string,
|
||||
): string {
|
||||
return [
|
||||
"@font-face {",
|
||||
` font-family: "${familyName}";`,
|
||||
` src: url("${src}") format("woff2");`,
|
||||
` font-style: ${style};`,
|
||||
` font-weight: ${weight};`,
|
||||
" font-display: block;",
|
||||
// Preserve the subset's unicode-range so the browser selects the right
|
||||
// per-codepoint subset (matching Google Fonts' own CSS semantics).
|
||||
...(unicodeRange ? [` unicode-range: ${unicodeRange};`] : []),
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildFontFaceCss(
|
||||
requestedFamilies: Map<string, string>,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<{
|
||||
css: string;
|
||||
unresolved: string[];
|
||||
}> {
|
||||
const rules: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
|
||||
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
|
||||
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
|
||||
// then fetch from Google Fonts to fill any weights not in the bundle.
|
||||
const canonicalKey = FONT_ALIASES[normalizedFamily];
|
||||
if (canonicalKey) {
|
||||
const canonical = CANONICAL_FONTS[canonicalKey];
|
||||
if (!canonical) continue;
|
||||
|
||||
const coveredWeights = new Set<string>();
|
||||
for (const face of canonical.faces) {
|
||||
const style = face.style || "normal";
|
||||
const src = fontDataUri(canonical.packageName, face.weight, style);
|
||||
rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
|
||||
coveredWeights.add(`${face.weight}:${style}`);
|
||||
}
|
||||
|
||||
// Fetch all weights from Google Fonts and add any that aren't
|
||||
// already covered by the embedded bundle. This ensures that
|
||||
// compositions requesting e.g. wght@200 get that weight even
|
||||
// if the bundle only ships 400/700/900.
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
|
||||
for (const face of googleFaces) {
|
||||
// A weight covered by the embedded bundle is already full-coverage —
|
||||
// skip it. For weights the bundle lacks, add EVERY subset face (a
|
||||
// weight has one face per unicode-range subset), not just the first.
|
||||
if (coveredWeights.has(`${face.weight}:${face.style}`)) continue;
|
||||
rules.push(
|
||||
buildFontFaceRule(
|
||||
originalCaseFamily,
|
||||
face.dataUri,
|
||||
face.weight,
|
||||
face.style,
|
||||
face.unicodeRange,
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path 2: fetch from Google Fonts (with local cache)
|
||||
const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
|
||||
if (googleFaces.length > 0) {
|
||||
for (const face of googleFaces) {
|
||||
rules.push(
|
||||
buildFontFaceRule(
|
||||
originalCaseFamily,
|
||||
face.dataUri,
|
||||
face.weight,
|
||||
face.style,
|
||||
face.unicodeRange,
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path 3: locate font on the local filesystem, compress, and embed.
|
||||
if (options.allowSystemFontCapture) {
|
||||
const variants = locateSystemFontVariants(originalCaseFamily);
|
||||
if (variants.length > 0) {
|
||||
let totalBytes = 0;
|
||||
for (const variant of variants) {
|
||||
const fontBuffer = readFileSync(variant.path);
|
||||
totalBytes += fontBuffer.length;
|
||||
const dataUri = await fontToDataUri(fontBuffer, variant.format);
|
||||
rules.push(buildFontFaceRule(originalCaseFamily, dataUri, variant.weight, variant.style));
|
||||
}
|
||||
if (totalBytes > SYSTEM_FONT_SIZE_LIMIT) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] System font "${originalCaseFamily}" is large (${(totalBytes / 1024 / 1024).toFixed(1)} MB total across ${variants.length} variant(s)) — embedding anyway. Consider font subsetting for production.`,
|
||||
);
|
||||
}
|
||||
defaultLogger.info(
|
||||
`[Compiler] Embedded system font "${originalCaseFamily}" — ${variants.length} variant(s), ${(totalBytes / 1024).toFixed(0)} KB total`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No path resolved
|
||||
unresolved.push(originalCaseFamily);
|
||||
}
|
||||
|
||||
return {
|
||||
css: rules.join("\n\n").trim(),
|
||||
unresolved: unresolved.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function warnUnresolvedFonts(unresolved: string[]): void {
|
||||
const mapped = Object.entries(FONT_ALIASES)
|
||||
.reduce<string[]>((acc, [alias, canonical]) => {
|
||||
const display = alias === canonical ? alias : `${alias} → ${canonical}`;
|
||||
if (!acc.includes(display)) acc.push(display);
|
||||
return acc;
|
||||
}, [])
|
||||
.sort();
|
||||
defaultLogger.warn(
|
||||
`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}\n` +
|
||||
` Mapped fonts: ${mapped.join(", ")}\n` +
|
||||
` To fix, pick one:\n` +
|
||||
` 1. Use a mapped font name instead (see list above)\n` +
|
||||
` 2. Add a @font-face block in your HTML with a local or hosted font file\n` +
|
||||
` 3. Install the font locally on the render machine (Docker: add to Dockerfile)\n` +
|
||||
` 4. Add an alias to FONT_ALIAS_MAP in packages/core/src/fonts/aliases.ts (for contributors)\n` +
|
||||
` Docs: https://hyperframes.heygen.com/docs/fonts`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google Fonts on-demand fetch + local cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's
|
||||
// read-only; only `/tmp` is writable. Route the cache there when
|
||||
// running inside Lambda, and honor `HYPERFRAMES_FONT_CACHE_DIR` as
|
||||
// an explicit override for any environment.
|
||||
function resolveFontCacheRoot(): string {
|
||||
return (
|
||||
process.env.HYPERFRAMES_FONT_CACHE_DIR ??
|
||||
(process.env.AWS_LAMBDA_FUNCTION_NAME
|
||||
? join(tmpdir(), "hyperframes", "fonts")
|
||||
: join(homedir(), ".cache", "hyperframes", "fonts"))
|
||||
);
|
||||
}
|
||||
const GOOGLE_FONTS_CACHE_DIR = resolveFontCacheRoot();
|
||||
|
||||
// Chrome UA triggers woff2 responses from Google Fonts CSS API
|
||||
const WOFF2_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
function fontSlug(familyName: string): string {
|
||||
return familyName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function fontCacheDir(slug: string): string {
|
||||
const dir = join(GOOGLE_FONTS_CACHE_DIR, slug);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// A short, stable discriminator for a single subset's woff2. Google Fonts'
|
||||
// css2 API returns one @font-face per (weight × unicode-range subset) — e.g.
|
||||
// `vietnamese`, `latin-ext`, and `latin` faces for the SAME weight, each with
|
||||
// a distinct woff2 URL and glyph set. Keying the cache by weight+style alone
|
||||
// collides every subset onto one filename, so only the first subset in the
|
||||
// CSS gets downloaded and the rest read it back. Derive the cache key from the
|
||||
// (subset-unique, version-stable) woff2 URL so each subset is cached on its own.
|
||||
function subsetToken(woff2Url: string): string {
|
||||
return createHash("sha1").update(woff2Url).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function cachedWoff2Path(slug: string, weight: string, style: string, subset: string): string {
|
||||
return join(fontCacheDir(slug), `${weight}-${style}-${subset}.woff2`);
|
||||
}
|
||||
|
||||
type GoogleFontFace = {
|
||||
weight: string;
|
||||
style: string;
|
||||
dataUri: string;
|
||||
unicodeRange?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed code classifying a font-fetch failure as non-retryable for
|
||||
* distributed workflow adapters — a missing Google Fonts entry will not heal
|
||||
* on retry.
|
||||
*/
|
||||
export const FONT_FETCH_FAILED = "FONT_FETCH_FAILED";
|
||||
|
||||
/**
|
||||
* Typed error thrown by {@link injectDeterministicFontFaces} when
|
||||
* `failClosedFontFetch === true` and an external font fetch fails. The
|
||||
* default (swallow + warn) preserves the in-process behavior.
|
||||
*/
|
||||
export class FontFetchError extends Error {
|
||||
readonly code: typeof FONT_FETCH_FAILED = FONT_FETCH_FAILED;
|
||||
readonly familyName: string;
|
||||
readonly url: string;
|
||||
readonly cause?: unknown;
|
||||
|
||||
constructor(familyName: string, url: string, message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "FontFetchError";
|
||||
this.familyName = familyName;
|
||||
this.url = url;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal threading of the failClosed flag + fetch override through callers. */
|
||||
interface InternalFontFetchOptions {
|
||||
failClosedFontFetch: boolean;
|
||||
fetchImpl: typeof fetch;
|
||||
allowSystemFontCapture: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a typed FontFetchError describing why a Google Fonts request failed.
|
||||
* Centralizes the message wording so all four call sites (CSS/woff2 ×
|
||||
* HTTP-error/exception) stay phrased identically.
|
||||
*/
|
||||
function fontFetchError(
|
||||
familyName: string,
|
||||
url: string,
|
||||
what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
|
||||
cause: { status: number } | { error: unknown },
|
||||
): FontFetchError {
|
||||
const reason =
|
||||
"status" in cause
|
||||
? `returned HTTP ${cause.status}`
|
||||
: `failed: ${(cause.error as Error).message}`;
|
||||
const message =
|
||||
`[deterministicFonts] ${what} fetch for ${JSON.stringify(familyName)} ${reason}. ` +
|
||||
`Distributed renders require deterministic fonts; system-font fallback would produce ` +
|
||||
`non-byte-identical output.`;
|
||||
return new FontFetchError(familyName, url, message, "error" in cause ? cause.error : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure one subset's woff2 is cached on disk (downloading if absent) and
|
||||
* return it as a `data:` URI. Returns `null` when the woff2 isn't served
|
||||
* (4xx) so the caller skips that face. Throws {@link FontFetchError} on
|
||||
* transient (5xx / network) failures when `failClosedFontFetch` is set.
|
||||
*/
|
||||
async function ensureWoff2DataUri(
|
||||
cachePath: string,
|
||||
woff2Url: string,
|
||||
familyName: string,
|
||||
weight: string,
|
||||
style: string,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
||||
} catch {
|
||||
// Not cached yet — fall through to fetch.
|
||||
}
|
||||
|
||||
const woff2What = `Google Fonts woff2 (${weight}/${style})` as const;
|
||||
try {
|
||||
const fontRes = await options.fetchImpl(woff2Url);
|
||||
if (!fontRes.ok) {
|
||||
if (fontRes.status >= 500 && options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, woff2Url, woff2What, { status: fontRes.status });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// wx = O_CREAT|O_EXCL: atomic create, rejects symlinks, fails with
|
||||
// EEXIST if a concurrent call cached it between our read and write.
|
||||
writeFileSync(cachePath, Buffer.from(await fontRes.arrayBuffer()), { flag: "wx", mode: 0o644 });
|
||||
} catch (err) {
|
||||
if (err instanceof FontFetchError) throw err;
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
// Concurrent call wrote it — read their result below.
|
||||
} else if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, woff2Url, woff2What, { error: err });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
||||
}
|
||||
|
||||
async function fetchGoogleFont(
|
||||
familyName: string,
|
||||
options: InternalFontFetchOptions,
|
||||
): Promise<GoogleFontFace[]> {
|
||||
const slug = fontSlug(familyName);
|
||||
const encodedFamily = encodeURIComponent(familyName);
|
||||
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700`;
|
||||
|
||||
let cssText: string;
|
||||
try {
|
||||
const res = await options.fetchImpl(url, {
|
||||
headers: { "User-Agent": WOFF2_USER_AGENT },
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 4xx is a *deterministic* answer from Google Fonts that this
|
||||
// family is not served (e.g. HTTP 400 for "Segoe UI", "Arial",
|
||||
// "Futura" — names absent from Google's catalog) or is misnamed.
|
||||
// The render falls back to embedded faces / the composition's
|
||||
// font-family chain; we return [] in both modes. 5xx (and other
|
||||
// transient upstream failures) could return faces on retry, which
|
||||
// would break the byte-identical-retry contract distributed
|
||||
// renders rely on — those still fail closed when requested.
|
||||
if (res.status >= 500 && options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, url, "Google Fonts CSS", { status: res.status });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
cssText = await res.text();
|
||||
} catch (err) {
|
||||
// Rethrow typed error untouched. Network / DNS / fetch-throws are
|
||||
// non-deterministic infrastructure failures — wrapped when failClosed
|
||||
// is on, swallowed otherwise.
|
||||
if (err instanceof FontFetchError) throw err;
|
||||
if (options.failClosedFontFetch) {
|
||||
throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse @font-face blocks from the CSS response. The optional trailing
|
||||
// capture grabs each face's `unicode-range` (Google emits it after `src`)
|
||||
// so the injected face only claims the codepoints the subset actually
|
||||
// covers — without it the face would advertise full coverage it lacks.
|
||||
const faceRegex =
|
||||
/@font-face\s*\{[^}]*font-style:\s*(normal|italic)[^}]*font-weight:\s*(\d+)[^}]*src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)(?:[^}]*?unicode-range:\s*([^;}]+))?[^}]*\}/gi;
|
||||
|
||||
const faces: GoogleFontFace[] = [];
|
||||
|
||||
for (const match of cssText.matchAll(faceRegex)) {
|
||||
const style = match[1] || "normal";
|
||||
const weight = match[2] || "400";
|
||||
const woff2Url = match[3] || "";
|
||||
const unicodeRange = match[4]?.trim() || undefined;
|
||||
|
||||
if (!woff2Url) continue;
|
||||
|
||||
const cachePath = cachedWoff2Path(slug, weight, style, subsetToken(woff2Url));
|
||||
const dataUri = await ensureWoff2DataUri(
|
||||
cachePath,
|
||||
woff2Url,
|
||||
familyName,
|
||||
weight,
|
||||
style,
|
||||
options,
|
||||
);
|
||||
if (dataUri) faces.push({ weight, style, dataUri, unicodeRange });
|
||||
}
|
||||
|
||||
if (faces.length > 0) {
|
||||
defaultLogger.info(
|
||||
`[Compiler] Fetched ${faces.length} font face(s) for "${familyName}" from Google Fonts (cached to ${fontCacheDir(slug)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return faces;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for {@link injectDeterministicFontFaces}.
|
||||
*/
|
||||
export interface InjectDeterministicFontFacesOptions {
|
||||
/**
|
||||
* When `true`, any external font fetch failure (Google Fonts CSS or
|
||||
* woff2) throws {@link FontFetchError} with code `FONT_FETCH_FAILED`.
|
||||
*
|
||||
* Default `false`: failed fetches are silently swallowed; the composition
|
||||
* falls back to system fonts via `warnUnresolvedFonts`. This preserves the
|
||||
* in-process behavior.
|
||||
*
|
||||
* Distributed callers pass `true` so font availability is part of the
|
||||
* planDir's content-addressed hash and fetch failures surface as typed
|
||||
* non-retryable errors.
|
||||
*/
|
||||
failClosedFontFetch?: boolean;
|
||||
/**
|
||||
* Injectable `fetch` implementation. Defaults to the global `fetch`.
|
||||
* Tests pass a stub to simulate fetch failures without going over the
|
||||
* network.
|
||||
*/
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* When `true` (default for local renders), fonts that aren't resolved by
|
||||
* the bundled alias map or Google Fonts are located on the local filesystem,
|
||||
* compressed to woff2, and embedded as data URIs. Set to `false` for
|
||||
* distributed/Lambda renders where the host filesystem is not guaranteed
|
||||
* to contain the same fonts as the authoring machine.
|
||||
*/
|
||||
allowSystemFontCapture?: boolean;
|
||||
}
|
||||
|
||||
export async function injectDeterministicFontFaces(
|
||||
html: string,
|
||||
options: InjectDeterministicFontFacesOptions = {},
|
||||
): Promise<string> {
|
||||
const failClosedFontFetch = options.failClosedFontFetch === true;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const allowSystemFontCapture = options.allowSystemFontCapture !== false;
|
||||
const fetchOptions: InternalFontFetchOptions = {
|
||||
failClosedFontFetch,
|
||||
fetchImpl,
|
||||
allowSystemFontCapture,
|
||||
};
|
||||
|
||||
const existingFaces = extractExistingFontFaces(html);
|
||||
const requestedFamilies = extractRequestedFontFamilies(html);
|
||||
const pendingFamilies = new Map<string, string>();
|
||||
|
||||
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
|
||||
if (!existingFaces.has(normalizedFamily)) {
|
||||
pendingFamilies.set(normalizedFamily, originalCaseFamily);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingFamilies.size === 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const { css, unresolved } = await buildFontFaceCss(pendingFamilies, fetchOptions);
|
||||
if (unresolved.length > 0 && options.failClosedFontFetch) {
|
||||
throw new FontFetchError(
|
||||
unresolved.join(", "),
|
||||
"",
|
||||
`[Compiler] Unresolved fonts in fail-closed mode: ${unresolved.join(", ")}. ` +
|
||||
`Distributed renders require all fonts to be resolvable.`,
|
||||
);
|
||||
}
|
||||
if (!css) {
|
||||
if (unresolved.length > 0) {
|
||||
warnUnresolvedFonts(unresolved);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
const { document } = parseHTML(html);
|
||||
const head = document.querySelector("head");
|
||||
if (!head) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
|
||||
styleEl.textContent = css;
|
||||
head.insertBefore(styleEl, head.firstChild);
|
||||
|
||||
defaultLogger.info(
|
||||
`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,
|
||||
);
|
||||
if (unresolved.length > 0) {
|
||||
warnUnresolvedFonts(unresolved);
|
||||
}
|
||||
|
||||
return document.toString();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Shared soft-skip regex used by every `services/distributed/*.test.ts` that
|
||||
* drives `renderChunk()` through a real chrome-headless-shell. Matches the
|
||||
* failure signatures we've observed on dev/CI hosts whose GL stack can't
|
||||
* initialize:
|
||||
*
|
||||
* - `chrome://gpu` / `BROWSER_GPU_NOT_SOFTWARE` / SwiftShader text:
|
||||
* `assertSwiftShader` can't read the gpu info table.
|
||||
* - `HeadlessExperimental.beginFrame` / `Target closed`:
|
||||
* chrome-headless-shell's GL process exited because the build doesn't
|
||||
* honor `--use-gl=swiftshader` on the host distro (`gl_factory.cc:111`
|
||||
* errors out before BeginFrame can run).
|
||||
*
|
||||
* Production-shaped Docker images (`Dockerfile.test` / `Dockerfile.chunk-runner`)
|
||||
* carry a chrome-headless-shell build matched to the planDir's `ffmpegVersion`,
|
||||
* so the determinism contract is exercised there. Tests that fail to render
|
||||
* on the host soft-skip and rely on the Docker harness for ground truth.
|
||||
*
|
||||
* This module lives under `__test_utils__/` and is excluded from `tsc`'s
|
||||
* declaration output via `tsconfig.json` so it never ships in the published
|
||||
* package.
|
||||
*/
|
||||
export const HOST_CHROME_FAILURE_PATTERNS =
|
||||
/chrome:\/\/gpu|BROWSER_GPU_NOT_SOFTWARE|SwiftShader|HeadlessExperimental\.beginFrame|Target closed/i;
|
||||
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* Unit tests for `services/distributed/assemble.ts`.
|
||||
*
|
||||
* Contracts:
|
||||
* - mp4/mov: pre-rendered chunks → assembled output passes ffprobe
|
||||
* (correct frame count, audio present and exactly `frames / fps`
|
||||
* long, faststart applied).
|
||||
* - png-sequence: chunk frame directories merge into one continuous
|
||||
* numbered sequence (chunk N's `frame_NNNNNN.png` files renumber
|
||||
* into `outputPath/frame_NNNNNN.png` with a global index).
|
||||
*
|
||||
* The mp4 fixture pre-renders chunk inputs via raw ffmpeg (test color
|
||||
* bars + AAC silence) so we don't need a working Chrome to exercise
|
||||
* assemble. The capture pipeline is covered by `renderChunk.test.ts`.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
|
||||
import { assemble } from "./assemble.js";
|
||||
|
||||
let runRoot: string;
|
||||
let hasFfmpeg = false;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-assemble-test-"));
|
||||
hasFfmpeg = spawnSync("ffmpeg", ["-version"]).status === 0;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a synthetic planDir whose `meta/chunks.json` declares N chunks of
|
||||
* `framesPerChunk` frames each. Does NOT materialize compiled/, video-frames/,
|
||||
* audio.aac — assemble only reads `plan.json` + `meta/chunks.json`, and we
|
||||
* pass chunk paths explicitly. Keeping the dir lean speeds up the test
|
||||
* loop.
|
||||
*/
|
||||
function buildPlanDir(
|
||||
format: "mp4" | "png-sequence",
|
||||
chunks: ChunkSliceJson[],
|
||||
totalFrames: number,
|
||||
hasAudio: boolean,
|
||||
encoder: "libx264-software" | "libx265-software" = "libx264-software",
|
||||
): string {
|
||||
const planDir = mkdtempSync(join(runRoot, `plan-${format}-`));
|
||||
mkdirSync(join(planDir, "meta"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(planDir, "plan.json"),
|
||||
JSON.stringify({
|
||||
planHash: "fake",
|
||||
totalFrames,
|
||||
hasAudio,
|
||||
dimensions: { fpsNum: 30, fpsDen: 1, width: 160, height: 120, format },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(planDir, "meta", "chunks.json"), JSON.stringify(chunks), "utf-8");
|
||||
// Minimal encoder.json — assemble reads this when cfr=true to detect h265
|
||||
// chunks (the cfr re-encode hardcodes libx264 and would silently transcode
|
||||
// h265). Tests default to libx264 to match the in-production default.
|
||||
writeFileSync(join(planDir, "meta", "encoder.json"), JSON.stringify({ encoder }), "utf-8");
|
||||
return planDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a tiny mp4 chunk via raw ffmpeg with closed-GOP libx264 args
|
||||
* matching what `renderChunk` produces. Uses ffmpeg's `testsrc` filter
|
||||
* so the test doesn't depend on any image assets. Each chunk is
|
||||
* independently concatenable because GOP === frame count and the first
|
||||
* frame is forced as a keyframe.
|
||||
*/
|
||||
function makeMp4Chunk(outputPath: string, frameCount: number): void {
|
||||
const args = [
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc=size=160x120:rate=30:duration=${frameCount / 30}`,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-g",
|
||||
String(frameCount),
|
||||
"-keyint_min",
|
||||
String(frameCount),
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-force_key_frames",
|
||||
`expr:eq(mod(n,${frameCount}),0)`,
|
||||
"-bf",
|
||||
"0",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-vframes",
|
||||
String(frameCount),
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
const result = spawnSync("ffmpeg", args, { stdio: "pipe" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffmpeg testsrc chunk failed: ${result.stderr.toString().slice(-400)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate an AAC audio file of `durationSeconds` of silence. */
|
||||
function makeAacAudio(outputPath: string, durationSeconds: number): void {
|
||||
const result = spawnSync("ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`anullsrc=channel_layout=stereo:sample_rate=48000`,
|
||||
"-t",
|
||||
String(durationSeconds),
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-y",
|
||||
outputPath,
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffmpeg anullsrc failed: ${result.stderr.toString().slice(-400)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read ffprobe JSON for one stream of `outputPath`. */
|
||||
function probeStream(
|
||||
outputPath: string,
|
||||
streamSelector: "v:0" | "a:0",
|
||||
): Record<string, unknown> | null {
|
||||
const result = spawnSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
streamSelector,
|
||||
"-show_entries",
|
||||
"stream=start_time,duration,nb_frames,nb_read_packets,codec_name,r_frame_rate",
|
||||
"-count_packets",
|
||||
"-of",
|
||||
"json",
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
if (result.status !== 0) return null;
|
||||
const parsed = JSON.parse(result.stdout.toString()) as {
|
||||
streams?: Array<Record<string, unknown>>;
|
||||
};
|
||||
return parsed.streams?.[0] ?? null;
|
||||
}
|
||||
|
||||
describe("assemble()", () => {
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"concat-copies two mp4 chunks and applies faststart",
|
||||
async () => {
|
||||
if (!hasFfmpeg) {
|
||||
console.warn(
|
||||
"[assemble.test] skipping mp4 concat test — ffmpeg not available on this host",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 5 },
|
||||
{ index: 1, startFrame: 5, endFrame: 10 },
|
||||
];
|
||||
const planDir = buildPlanDir("mp4", chunks, 10, false);
|
||||
|
||||
const chunkAPath = join(planDir, "chunk-0.mp4");
|
||||
const chunkBPath = join(planDir, "chunk-1.mp4");
|
||||
makeMp4Chunk(chunkAPath, 5);
|
||||
makeMp4Chunk(chunkBPath, 5);
|
||||
|
||||
const outputPath = join(planDir, "output.mp4");
|
||||
const result = await assemble(planDir, [chunkAPath, chunkBPath], null, outputPath);
|
||||
|
||||
expect(result.outputPath).toBe(outputPath);
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(result.fileSize).toBeGreaterThan(0);
|
||||
expect(result.framesEncoded).toBe(10);
|
||||
|
||||
// ── ffprobe: correct frame count + codec ───────────────────────────
|
||||
const videoStream = probeStream(outputPath, "v:0");
|
||||
expect(videoStream).toBeDefined();
|
||||
expect(videoStream?.codec_name).toBe("h264");
|
||||
const probedFrames = Number(videoStream?.nb_read_packets ?? videoStream?.nb_frames);
|
||||
expect(probedFrames).toBe(10);
|
||||
|
||||
// ── ffprobe: exact framerate + duration equivalence ────────────────
|
||||
// The container's `r_frame_rate` must match the planDir's exact
|
||||
// rational (30/1 here) — not a PTS-averaged fraction like
|
||||
// `360000/12001`. This guards the `-r` flag on the concat /
|
||||
// mux / faststart steps from regressing.
|
||||
expect(videoStream?.r_frame_rate).toBe("30/1");
|
||||
// Duration must equal `totalFrames * fpsDen / fpsNum` within 1ms.
|
||||
const expectedDuration = (10 * 1) / 30;
|
||||
const probedDuration = Number(videoStream?.duration ?? 0);
|
||||
expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001);
|
||||
|
||||
// ── faststart applied ──────────────────────────────────────────────
|
||||
// Bun.file is async; resolve before asserting.
|
||||
const buf = await Bun.file(outputPath).arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let cursor = 0;
|
||||
let moovBeforeMdat = false;
|
||||
while (cursor + 8 <= bytes.length) {
|
||||
const size =
|
||||
(bytes[cursor]! << 24) |
|
||||
(bytes[cursor + 1]! << 16) |
|
||||
(bytes[cursor + 2]! << 8) |
|
||||
bytes[cursor + 3]!;
|
||||
const fourcc = String.fromCharCode(
|
||||
bytes[cursor + 4]!,
|
||||
bytes[cursor + 5]!,
|
||||
bytes[cursor + 6]!,
|
||||
bytes[cursor + 7]!,
|
||||
);
|
||||
if (fourcc === "moov") {
|
||||
moovBeforeMdat = true;
|
||||
break;
|
||||
}
|
||||
if (fourcc === "mdat") break;
|
||||
if (size <= 0) break;
|
||||
cursor += size;
|
||||
}
|
||||
expect(moovBeforeMdat).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"single-chunk render stamps exact r_frame_rate on the output container",
|
||||
async () => {
|
||||
if (!hasFfmpeg) {
|
||||
console.warn(
|
||||
"[assemble.test] skipping single-chunk r_frame_rate test — ffmpeg not available on this host",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reproducer for the single-chunk pass-through regression: when
|
||||
// `chunkPaths.length === 1`, assemble must still stamp an exact
|
||||
// `r_frame_rate` matching the planDir's rational (here 30/1), not
|
||||
// a PTS-derived fraction like `359/12`. Multi-chunk renders go
|
||||
// through the concat demuxer; single-chunk renders skip it and
|
||||
// need the `-r <fps>` flag on a direct remux step.
|
||||
const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 10 }];
|
||||
const planDir = buildPlanDir("mp4", chunks, 10, false);
|
||||
|
||||
const chunkPath = join(planDir, "chunk-0.mp4");
|
||||
makeMp4Chunk(chunkPath, 10);
|
||||
|
||||
const outputPath = join(planDir, "output-single-chunk.mp4");
|
||||
const result = await assemble(planDir, [chunkPath], null, outputPath);
|
||||
|
||||
expect(result.outputPath).toBe(outputPath);
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(result.framesEncoded).toBe(10);
|
||||
|
||||
const videoStream = probeStream(outputPath, "v:0");
|
||||
expect(videoStream).toBeDefined();
|
||||
expect(videoStream?.codec_name).toBe("h264");
|
||||
// The exact-rational assertion — the regression hole that this
|
||||
// test closes. Before the single-chunk -r fix, this came back as
|
||||
// a PTS-derived fraction (e.g. `359/12`) on 1-chunk renders.
|
||||
expect(videoStream?.r_frame_rate).toBe("30/1");
|
||||
const expectedDuration = 10 / 30;
|
||||
const probedDuration = Number(videoStream?.duration ?? 0);
|
||||
expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"muxes audio with frame-count-derived duration when audio.aac is present",
|
||||
async () => {
|
||||
if (!hasFfmpeg) return;
|
||||
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 6 },
|
||||
{ index: 1, startFrame: 6, endFrame: 12 },
|
||||
];
|
||||
const totalFrames = 12;
|
||||
const fps = 30;
|
||||
const planDir = buildPlanDir("mp4", chunks, totalFrames, true);
|
||||
|
||||
const chunkAPath = join(planDir, "chunk-0.mp4");
|
||||
const chunkBPath = join(planDir, "chunk-1.mp4");
|
||||
const audioPath = join(planDir, "audio.aac");
|
||||
makeMp4Chunk(chunkAPath, 6);
|
||||
makeMp4Chunk(chunkBPath, 6);
|
||||
// Audio is half a second longer than the video — `padOrTrimAudioToVideoFrameCount`
|
||||
// should trim it down to `totalFrames / fps`.
|
||||
makeAacAudio(audioPath, totalFrames / fps + 0.5);
|
||||
|
||||
const outputPath = join(planDir, "output-audio.mp4");
|
||||
const result = await assemble(planDir, [chunkAPath, chunkBPath], audioPath, outputPath);
|
||||
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(result.framesEncoded).toBe(totalFrames);
|
||||
|
||||
const audioStream = probeStream(outputPath, "a:0");
|
||||
expect(audioStream).toBeDefined();
|
||||
expect(audioStream?.codec_name).toBe("aac");
|
||||
const videoStream = probeStream(outputPath, "v:0");
|
||||
expect(videoStream).toBeDefined();
|
||||
expect(Number(videoStream?.start_time ?? NaN)).toBeLessThan(0.001);
|
||||
expect(Number(audioStream?.start_time ?? NaN)).toBeLessThan(0.001);
|
||||
// Audio duration should be within ~25ms of `totalFrames / fps` after
|
||||
// pad/trim. The 25ms tolerance absorbs AAC frame quantization (1024
|
||||
// samples @ 48kHz = ~21ms).
|
||||
const audioDuration = Number(audioStream?.duration ?? 0);
|
||||
const expected = totalFrames / fps;
|
||||
expect(Math.abs(audioDuration - expected)).toBeLessThan(0.05);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"muxes padded short audio without shifting the first video frame",
|
||||
async () => {
|
||||
if (!hasFfmpeg) return;
|
||||
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 6 },
|
||||
{ index: 1, startFrame: 6, endFrame: 12 },
|
||||
];
|
||||
const totalFrames = 12;
|
||||
const fps = 30;
|
||||
const planDir = buildPlanDir("mp4", chunks, totalFrames, true);
|
||||
|
||||
const chunkAPath = join(planDir, "chunk-0.mp4");
|
||||
const chunkBPath = join(planDir, "chunk-1.mp4");
|
||||
const audioPath = join(planDir, "audio.aac");
|
||||
makeMp4Chunk(chunkAPath, 6);
|
||||
makeMp4Chunk(chunkBPath, 6);
|
||||
// Audio is shorter than the video, forcing the distributed pad branch.
|
||||
makeAacAudio(audioPath, totalFrames / fps - 0.2);
|
||||
|
||||
const outputPath = join(planDir, "output-audio-padded.mp4");
|
||||
const result = await assemble(planDir, [chunkAPath, chunkBPath], audioPath, outputPath);
|
||||
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(result.framesEncoded).toBe(totalFrames);
|
||||
|
||||
const audioStream = probeStream(outputPath, "a:0");
|
||||
expect(audioStream).toBeDefined();
|
||||
expect(audioStream?.codec_name).toBe("aac");
|
||||
const videoStream = probeStream(outputPath, "v:0");
|
||||
expect(videoStream).toBeDefined();
|
||||
expect(Number(videoStream?.start_time ?? NaN)).toBeLessThan(0.001);
|
||||
expect(Number(audioStream?.start_time ?? NaN)).toBeLessThan(0.001);
|
||||
|
||||
const audioDuration = Number(audioStream?.duration ?? 0);
|
||||
const expected = totalFrames / fps;
|
||||
expect(Math.abs(audioDuration - expected)).toBeLessThan(0.05);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"cfr:true re-encodes for exact avg_frame_rate matching r_frame_rate",
|
||||
async () => {
|
||||
if (!hasFfmpeg) {
|
||||
console.warn("[assemble.test] skipping cfr test — ffmpeg not available on this host");
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in CFR: the re-encode pass with `-fps_mode cfr -r <fps>` must
|
||||
// land the stream's `avg_frame_rate` on the requested rational
|
||||
// exactly, not a PTS-derived fraction. Default `cfr=false` path is
|
||||
// covered by the existing concat-copy tests above.
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 5 },
|
||||
{ index: 1, startFrame: 5, endFrame: 10 },
|
||||
];
|
||||
const planDir = buildPlanDir("mp4", chunks, 10, false);
|
||||
|
||||
const chunkAPath = join(planDir, "chunk-0.mp4");
|
||||
const chunkBPath = join(planDir, "chunk-1.mp4");
|
||||
makeMp4Chunk(chunkAPath, 5);
|
||||
makeMp4Chunk(chunkBPath, 5);
|
||||
|
||||
const outputPath = join(planDir, "output-cfr.mp4");
|
||||
const result = await assemble(planDir, [chunkAPath, chunkBPath], null, outputPath, {
|
||||
cfr: true,
|
||||
});
|
||||
|
||||
expect(result.outputPath).toBe(outputPath);
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(result.framesEncoded).toBe(10);
|
||||
|
||||
// ffprobe both r_frame_rate AND avg_frame_rate — the CFR re-encode's
|
||||
// contract is that they're equal and both exactly match the
|
||||
// requested rate.
|
||||
const probe = spawnSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=r_frame_rate,avg_frame_rate,duration",
|
||||
"-of",
|
||||
"json",
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
expect(probe.status).toBe(0);
|
||||
const parsed = JSON.parse(probe.stdout.toString()) as {
|
||||
streams?: Array<{ r_frame_rate?: string; avg_frame_rate?: string; duration?: string }>;
|
||||
};
|
||||
const stream = parsed.streams?.[0];
|
||||
expect(stream).toBeDefined();
|
||||
expect(stream?.r_frame_rate).toBe("30/1");
|
||||
expect(stream?.avg_frame_rate).toBe("30/1");
|
||||
const expectedDuration = 10 / 30;
|
||||
const probedDuration = Number(stream?.duration ?? 0);
|
||||
expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"cfr:true rejects non-mp4 formats with a clear error",
|
||||
async () => {
|
||||
const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 5 }];
|
||||
// png-sequence path short-circuits before the cfr check; webm/mov
|
||||
// would hit the runtime guard. We rebuild plan.json with a non-mp4
|
||||
// format manually so this test runs without a webm encoder.
|
||||
const planDir = mkdtempSync(join(runRoot, "plan-webm-cfr-"));
|
||||
mkdirSync(join(planDir, "meta"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(planDir, "plan.json"),
|
||||
JSON.stringify({
|
||||
planHash: "fake",
|
||||
totalFrames: 5,
|
||||
hasAudio: false,
|
||||
dimensions: { fpsNum: 30, fpsDen: 1, width: 160, height: 120, format: "webm" },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(join(planDir, "meta", "chunks.json"), JSON.stringify(chunks), "utf-8");
|
||||
// Fabricate a placeholder file so the existence check passes — the
|
||||
// cfr-guard error fires before we actually run the concat invocation
|
||||
// in the multi-chunk branch; the single-chunk remux path runs first
|
||||
// here, then we hit the cfr guard. Since the remux is real, only
|
||||
// run this test when ffmpeg is present.
|
||||
if (!hasFfmpeg) {
|
||||
console.warn("[assemble.test] skipping cfr-non-mp4 test — ffmpeg not available");
|
||||
return;
|
||||
}
|
||||
const chunkPath = join(planDir, "chunk-0.webm");
|
||||
// Build a real 5-frame webm chunk so the concat step succeeds and
|
||||
// the cfr guard is what actually trips.
|
||||
const buildResult = spawnSync("ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=size=160x120:rate=30:duration=0.166666",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-deadline",
|
||||
"realtime",
|
||||
"-cpu-used",
|
||||
"8",
|
||||
"-g",
|
||||
"5",
|
||||
"-keyint_min",
|
||||
"5",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-vframes",
|
||||
"5",
|
||||
"-y",
|
||||
chunkPath,
|
||||
]);
|
||||
if (buildResult.status !== 0) {
|
||||
console.warn(
|
||||
"[assemble.test] skipping cfr-non-mp4 test — libvpx-vp9 not available on this host",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let caught: unknown;
|
||||
try {
|
||||
await assemble(planDir, [chunkPath], null, join(planDir, "out.webm"), { cfr: true });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as Error).message).toContain("cfr=true is only supported");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"cfr:true rejects h265 chunks with a clear error",
|
||||
async () => {
|
||||
if (!hasFfmpeg) {
|
||||
console.warn("[assemble.test] skipping cfr-h265 test — ffmpeg not available");
|
||||
return;
|
||||
}
|
||||
// The cfr re-encode hardcodes `-c:v libx264`; pairing it with h265
|
||||
// chunks would silently transcode them to h264. Assemble must throw
|
||||
// a typed error instead of producing a wrong-codec deliverable. We
|
||||
// stage a plan whose `meta/encoder.json` reports `libx265-software`
|
||||
// and chunks built with libx264 (the bytes don't matter — the guard
|
||||
// trips on the encoder discriminant before the re-encode runs).
|
||||
const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 5 }];
|
||||
const planDir = buildPlanDir("mp4", chunks, 5, false, "libx265-software");
|
||||
|
||||
const chunkPath = join(planDir, "chunk-0.mp4");
|
||||
makeMp4Chunk(chunkPath, 5);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await assemble(planDir, [chunkPath], null, join(planDir, "out.mp4"), { cfr: true });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as Error).message).toContain(
|
||||
`cfr=true is not yet supported with codec: "h265"`,
|
||||
);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"merges png-sequence chunk directories with continuous global numbering",
|
||||
() => {
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 3 },
|
||||
{ index: 1, startFrame: 3, endFrame: 7 },
|
||||
];
|
||||
const planDir = buildPlanDir("png-sequence", chunks, 7, false);
|
||||
|
||||
// Fabricate two chunk directories with 3 + 4 frames respectively.
|
||||
// Each chunk uses a 0-indexed naming scheme — `renderChunk` writes
|
||||
// them this way today.
|
||||
const chunkADir = join(planDir, "chunk-a");
|
||||
const chunkBDir = join(planDir, "chunk-b");
|
||||
mkdirSync(chunkADir, { recursive: true });
|
||||
mkdirSync(chunkBDir, { recursive: true });
|
||||
const minimalPngHeader = Buffer.from([
|
||||
// 8-byte PNG signature followed by an IHDR chunk for a 1×1 RGB image.
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
|
||||
0x77, 0x53, 0xde,
|
||||
]);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Each frame's bytes differ (suffix index) so the merged sequence's
|
||||
// ordering assertion has something to bite on.
|
||||
writeFileSync(
|
||||
join(chunkADir, `frame_${String(i).padStart(6, "0")}.png`),
|
||||
Buffer.concat([minimalPngHeader, Buffer.from([0xaa, i])]),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
writeFileSync(
|
||||
join(chunkBDir, `frame_${String(i).padStart(6, "0")}.png`),
|
||||
Buffer.concat([minimalPngHeader, Buffer.from([0xbb, i])]),
|
||||
);
|
||||
}
|
||||
|
||||
const outputPath = join(planDir, "merged");
|
||||
// No await — png-sequence assemble is synchronous internally.
|
||||
const promise = assemble(planDir, [chunkADir, chunkBDir], null, outputPath);
|
||||
return promise.then((result) => {
|
||||
expect(result.outputPath).toBe(outputPath);
|
||||
expect(result.framesEncoded).toBe(7);
|
||||
const merged = readdirSync(outputPath).sort();
|
||||
expect(merged).toEqual([
|
||||
"frame_000001.png",
|
||||
"frame_000002.png",
|
||||
"frame_000003.png",
|
||||
"frame_000004.png",
|
||||
"frame_000005.png",
|
||||
"frame_000006.png",
|
||||
"frame_000007.png",
|
||||
]);
|
||||
});
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("rejects when chunkPaths.length does not match chunks.json length", async () => {
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 5 },
|
||||
{ index: 1, startFrame: 5, endFrame: 10 },
|
||||
];
|
||||
const planDir = buildPlanDir("mp4", chunks, 10, false);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await assemble(planDir, ["/tmp/nonexistent.mp4"], null, join(planDir, "out.mp4"));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as Error).message).toContain("does not match");
|
||||
});
|
||||
|
||||
it("rejects a planDir missing plan.json", async () => {
|
||||
const emptyDir = join(runRoot, "empty");
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await assemble(emptyDir, [], null, join(emptyDir, "out.mp4"));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect((caught as Error).message).toContain("plan.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Activity C of the distributed render pipeline.
|
||||
*
|
||||
* `assemble(planDir, chunkPaths, audioPath, outputPath)` stitches per-chunk
|
||||
* outputs into the final deliverable. For mp4 / mov / webm this is
|
||||
* `ffmpeg -f concat -c copy` (free of re-encode loss because every
|
||||
* chunk's first frame is an IDR keyframe — the chunk encoder sets
|
||||
* `lockGopForChunkConcat` to enforce this, which for libvpx-vp9 also
|
||||
* disables alt-ref frames so concat seams remain independently
|
||||
* decodable). For png-sequence chunks (each chunk is a directory of
|
||||
* frames) this is a straight directory merge with global re-numbering.
|
||||
*
|
||||
* Mux + faststart for mp4 / mov / webm go through the engine's
|
||||
* `muxVideoWithAudio` + `applyFaststart` helpers — same path the
|
||||
* in-process renderer uses; we just feed concat output rather than
|
||||
* streaming-encoder output. (Faststart is a no-op for webm and mov —
|
||||
* applyFaststart copies the input verbatim.) Audio length is
|
||||
* pad-or-trimmed to `frameCount / fps` via
|
||||
* `padOrTrimAudioToVideoFrameCount` so the mux step doesn't introduce
|
||||
* sub-millisecond drift at the end of long renders.
|
||||
*
|
||||
* Pure function over local paths. No networking. The caller is responsible
|
||||
* for moving `outputPath` to its orchestration-level storage.
|
||||
*/
|
||||
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine";
|
||||
import { fpsToFfmpegArg } from "@hyperframes/core";
|
||||
import { defaultLogger, type ProducerLogger } from "../../logger.js";
|
||||
import { formatExportFrameName } from "../../utils/paths.js";
|
||||
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
|
||||
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
|
||||
import type { DistributedFormat } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Result of {@link assemble}. `fileSize` reflects the final file on disk
|
||||
* (mp4/mov) or the cumulative byte total of the frame directory
|
||||
* (png-sequence).
|
||||
*/
|
||||
export interface AssembleResult {
|
||||
outputPath: string;
|
||||
durationMs: number;
|
||||
framesEncoded: number;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
/** Shape of the planDir's top-level `plan.json` — only the fields `assemble` needs. */
|
||||
interface PlanJsonForAssemble {
|
||||
planHash: string;
|
||||
totalFrames: number;
|
||||
hasAudio: boolean;
|
||||
dimensions: {
|
||||
fpsNum: number;
|
||||
fpsDen: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: DistributedFormat;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the chunk outputs into a single deliverable.
|
||||
*
|
||||
* @param planDir — absolute path to the planDir produced by `plan()`.
|
||||
* @param chunkPaths — ordered chunk outputs, length === `chunks.json` length.
|
||||
* For mp4/mov each entry is a path to an encoded chunk file; for
|
||||
* png-sequence each entry is a path to a directory of frames.
|
||||
* @param audioPath — `<planDir>/audio.aac` for mux'd formats. Pass `null`
|
||||
* when the composition has no audio (or `assemble` is being called for a
|
||||
* format whose audio is muxed elsewhere). `assemble` always normalizes
|
||||
* audio length against the assembled video's frame count when
|
||||
* `audioPath` is non-null.
|
||||
* @param outputPath — final on-disk output (file for mp4/mov; directory
|
||||
* for png-sequence — created if missing).
|
||||
*/
|
||||
export async function assemble(
|
||||
planDir: string,
|
||||
chunkPaths: readonly string[],
|
||||
audioPath: string | null,
|
||||
outputPath: string,
|
||||
options?: {
|
||||
logger?: ProducerLogger;
|
||||
abortSignal?: AbortSignal;
|
||||
/**
|
||||
* Opt-in exact-CFR re-encode. When `true`, the assembled video is
|
||||
* re-encoded once at the end of the concat/single-chunk step with
|
||||
* `-fps_mode cfr -r <fps>` so the stream-level `avg_frame_rate`
|
||||
* matches the container's `r_frame_rate` exactly (and the file's
|
||||
* duration lands on the requested `frameCount / fps` to ms
|
||||
* precision, with no PTS-derived drift). Trade-off: ~2-5x the
|
||||
* stitch time for a 60s 1080p clip plus second-generation H.264
|
||||
* quality loss (negligible at `-crf 18` but non-zero). Default
|
||||
* `false` preserves the existing `-c copy` behavior. mp4 only
|
||||
* (libx264); webm / mov pass through unchanged because their
|
||||
* stream-copy paths don't exhibit the same avg-frame-rate drift.
|
||||
*/
|
||||
cfr?: boolean;
|
||||
},
|
||||
): Promise<AssembleResult> {
|
||||
const start = Date.now();
|
||||
const log = options?.logger ?? defaultLogger;
|
||||
const abortSignal = options?.abortSignal;
|
||||
const cfr = options?.cfr === true;
|
||||
|
||||
// ── 1. Validate planDir manifest matches chunkPaths shape ──────────────
|
||||
const planJsonPath = join(planDir, "plan.json");
|
||||
const chunksJsonPath = join(planDir, "meta", "chunks.json");
|
||||
if (!existsSync(planJsonPath)) {
|
||||
throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
|
||||
}
|
||||
if (!existsSync(chunksJsonPath)) {
|
||||
throw new Error(`[assemble] planDir missing meta/chunks.json: ${chunksJsonPath}`);
|
||||
}
|
||||
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJsonForAssemble;
|
||||
const chunks = JSON.parse(readFileSync(chunksJsonPath, "utf-8")) as ChunkSliceJson[];
|
||||
if (chunkPaths.length !== chunks.length) {
|
||||
throw new Error(
|
||||
`[assemble] chunkPaths length (${chunkPaths.length}) does not match ` +
|
||||
`chunks.json length (${chunks.length}). Adapters must pass one path ` +
|
||||
`per chunk, ordered by index.`,
|
||||
);
|
||||
}
|
||||
for (const path of chunkPaths) {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`[assemble] chunk path does not exist: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.dimensions.format === "png-sequence") {
|
||||
// ── 2a. png-sequence: merge frame directories with global re-numbering
|
||||
return mergePngFrameDirs(chunkPaths, outputPath, plan.totalFrames, audioPath, start);
|
||||
}
|
||||
|
||||
// ── 2b. mp4 / mov / webm: concat-copy then mux + faststart ────────────
|
||||
if (!existsSync(dirname(outputPath))) {
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
}
|
||||
const workDir = `${outputPath}.assemble-work`;
|
||||
if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const concatOutputPath = join(workDir, `concat.${plan.dimensions.format}`);
|
||||
const fpsArg = fpsToFfmpegArg({
|
||||
num: plan.dimensions.fpsNum,
|
||||
den: plan.dimensions.fpsDen,
|
||||
});
|
||||
|
||||
// Single-chunk renders bypass the concat demuxer entirely. ffmpeg's
|
||||
// concat demuxer with a one-entry list re-runs as a straight remux of
|
||||
// the single source, and in that path the input-side `-r` flag does
|
||||
// not consistently override the source's PTS-derived r_frame_rate
|
||||
// (observed: `359/12` carrying through to the output container while
|
||||
// the equivalent multi-chunk path produces `30/1` exact). Running a
|
||||
// direct `-c copy` remux with `-r <fps>` as an output flag gives the
|
||||
// muxer the authoritative rate to stamp into the container without
|
||||
// touching the encoded stream. Multi-chunk renders continue through
|
||||
// the concat demuxer where the existing `-r` input flag works.
|
||||
if (chunkPaths.length === 1) {
|
||||
const remuxArgs = ["-i", chunkPaths[0]!, "-c", "copy", "-r", fpsArg, "-y", concatOutputPath];
|
||||
const remuxResult = await runFfmpeg(remuxArgs, { signal: abortSignal });
|
||||
if (!remuxResult.success) {
|
||||
throw new Error(
|
||||
`[assemble] ffmpeg single-chunk remux failed (exit ${remuxResult.exitCode}): ` +
|
||||
`${remuxResult.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Concat list file — one `file '<path>'` per chunk, in order. ffmpeg's
|
||||
// concat demuxer escapes single quotes via `'\''`; we replicate that
|
||||
// here so chunk paths containing quotes don't break the parser.
|
||||
const concatListPath = join(workDir, "concat-list.txt");
|
||||
const concatBody = chunkPaths
|
||||
.map((path) => `file '${path.replace(/'/g, "'\\''")}'`)
|
||||
.join("\n");
|
||||
writeFileSync(concatListPath, `${concatBody}\n`, "utf-8");
|
||||
|
||||
// Set the exact input framerate so the concat demuxer doesn't
|
||||
// PTS-average a fractional rational like `360000/12001` instead
|
||||
// of `30/1` into the output container metadata. `-c copy` is
|
||||
// retained; no re-encode.
|
||||
const concatArgs = [
|
||||
"-r",
|
||||
fpsArg,
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
concatOutputPath,
|
||||
];
|
||||
const concatResult = await runFfmpeg(concatArgs, { signal: abortSignal });
|
||||
if (!concatResult.success) {
|
||||
throw new Error(
|
||||
`[assemble] ffmpeg concat-copy failed (exit ${concatResult.exitCode}): ` +
|
||||
`${concatResult.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2c. Optional exact-CFR re-encode ──────────────────────────────────
|
||||
// The concat / single-chunk step produces a stream-copy intermediate
|
||||
// whose container `r_frame_rate` is exact but whose stream-level
|
||||
// `avg_frame_rate` stays PTS-derived (concat-copy carries each chunk's
|
||||
// original PTS unmodified). For consumers that strict-check
|
||||
// `avg_frame_rate` or ms-precision duration (broadcast workflows,
|
||||
// frame-accurate compositors, some third-party transcoders), an
|
||||
// opt-in re-encode with `-fps_mode cfr -r <fps>` lands the stream's
|
||||
// avg-frame-rate on the requested rational exactly. Restricted to
|
||||
// mp4 / libx264 — webm and mov go through their own stream-copy
|
||||
// paths that don't exhibit the same avg-frame-rate drift, and h265
|
||||
// mp4 would silently transcode to h264 under the hardcoded
|
||||
// `-c:v libx264` re-encode (a typed throw is preferable to silent
|
||||
// codec loss).
|
||||
let postConcatPath = concatOutputPath;
|
||||
if (cfr) {
|
||||
if (plan.dimensions.format !== "mp4") {
|
||||
throw new Error(
|
||||
`[assemble] cfr=true is only supported for format="mp4" (got ` +
|
||||
`"${plan.dimensions.format}"). Stream-copy paths for webm and mov ` +
|
||||
`already produce exact avg_frame_rate; cfr re-encode is not needed.`,
|
||||
);
|
||||
}
|
||||
// Read `meta/encoder.json` to detect the chunk encoder. The cfr
|
||||
// re-encode hardcodes `-c:v libx264`; pairing it with h265 chunks
|
||||
// would silently transcode them to h264. Throw a typed error so the
|
||||
// caller surfaces the conflict instead of producing a wrong-codec
|
||||
// deliverable.
|
||||
const encoderJsonPath = join(planDir, "meta", "encoder.json");
|
||||
if (!existsSync(encoderJsonPath)) {
|
||||
throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`);
|
||||
}
|
||||
const encoderJson = JSON.parse(readFileSync(encoderJsonPath, "utf-8")) as {
|
||||
encoder?: string;
|
||||
};
|
||||
if (encoderJson.encoder === "libx265-software") {
|
||||
throw new Error(
|
||||
`[assemble] cfr=true is not yet supported with codec: "h265". The ` +
|
||||
`cfr re-encode pass uses libx264 and would silently transcode the ` +
|
||||
`h265 chunks. Either disable cfr or render with codec: "h264".`,
|
||||
);
|
||||
}
|
||||
const cfrOutputPath = join(workDir, `cfr.${plan.dimensions.format}`);
|
||||
const cfrArgs = [
|
||||
"-i",
|
||||
concatOutputPath,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-crf",
|
||||
"18",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-fps_mode",
|
||||
"cfr",
|
||||
"-r",
|
||||
fpsArg,
|
||||
"-y",
|
||||
cfrOutputPath,
|
||||
];
|
||||
const cfrResult = await runFfmpeg(cfrArgs, { signal: abortSignal });
|
||||
if (!cfrResult.success) {
|
||||
throw new Error(
|
||||
`[assemble] ffmpeg cfr re-encode failed (exit ${cfrResult.exitCode}): ` +
|
||||
`${cfrResult.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
postConcatPath = cfrOutputPath;
|
||||
log.info("[assemble] cfr re-encode applied", {
|
||||
format: plan.dimensions.format,
|
||||
fpsNum: plan.dimensions.fpsNum,
|
||||
fpsDen: plan.dimensions.fpsDen,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Audio: pad-or-trim then mux ────────────────────────────────────
|
||||
let audioForMux: string | null = null;
|
||||
if (audioPath !== null && existsSync(audioPath)) {
|
||||
const paddedAudioPath = join(workDir, "audio-padded.aac");
|
||||
const padTrimResult = await padOrTrimAudioToVideoFrameCount({
|
||||
videoPath: postConcatPath,
|
||||
audioPath,
|
||||
outputPath: paddedAudioPath,
|
||||
});
|
||||
if (!padTrimResult.success) {
|
||||
throw new Error(`[assemble] audio pad/trim failed: ${padTrimResult.error}`);
|
||||
}
|
||||
audioForMux = paddedAudioPath;
|
||||
log.info("[assemble] audio normalized for mux", {
|
||||
operation: padTrimResult.operation,
|
||||
targetDurationSeconds: padTrimResult.targetDurationSeconds,
|
||||
sourceDurationSeconds: padTrimResult.sourceDurationSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
// mux + faststart paths mirror `runAssembleStage` for in-process renders
|
||||
// (`render/stages/assembleStage.ts`). We can't call that stage directly
|
||||
// because it operates on a `RenderJob` and emits `updateJobStatus`
|
||||
// payloads — the distributed activity has no job to thread through.
|
||||
const muxOutputPath =
|
||||
audioForMux !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath;
|
||||
if (audioForMux !== null) {
|
||||
const muxResult = await muxVideoWithAudio(
|
||||
postConcatPath,
|
||||
audioForMux,
|
||||
muxOutputPath,
|
||||
abortSignal,
|
||||
{ audioCodec: "aac" },
|
||||
{ num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
|
||||
);
|
||||
if (!muxResult.success) {
|
||||
throw new Error(`[assemble] audio mux failed: ${muxResult.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// applyFaststart is a no-op for `.mov` (it copies the input to output);
|
||||
// we still call it so the success path produces `outputPath` regardless.
|
||||
const faststartResult = await applyFaststart(
|
||||
muxOutputPath,
|
||||
outputPath,
|
||||
abortSignal,
|
||||
undefined,
|
||||
{
|
||||
num: plan.dimensions.fpsNum,
|
||||
den: plan.dimensions.fpsDen,
|
||||
},
|
||||
);
|
||||
if (!faststartResult.success) {
|
||||
throw new Error(`[assemble] faststart failed: ${faststartResult.error}`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
log.warn("[assemble] failed to remove work dir", {
|
||||
workDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
|
||||
return {
|
||||
outputPath,
|
||||
durationMs: Date.now() - start,
|
||||
framesEncoded: plan.totalFrames,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge per-chunk PNG frame directories into a single directory with
|
||||
* globally-incrementing frame numbers. Each chunk's `frame_NNNNNN.png` files
|
||||
* (which `renderChunk` writes normalized to zero per chunk) are re-numbered
|
||||
* into the merged output so consumers see one continuous numbered sequence.
|
||||
*
|
||||
* Audio is intentionally NOT muxed here — png-sequence has no container.
|
||||
* If `audioPath` is non-null we copy it alongside as `audio.aac` so callers
|
||||
* who need to re-mux later (After Effects, Nuke, ffmpeg image2 + audio) can
|
||||
* find it.
|
||||
*/
|
||||
function mergePngFrameDirs(
|
||||
chunkPaths: readonly string[],
|
||||
outputPath: string,
|
||||
totalFrames: number,
|
||||
audioPath: string | null,
|
||||
startTimeMs: number,
|
||||
): AssembleResult {
|
||||
if (existsSync(outputPath)) rmSync(outputPath, { recursive: true, force: true });
|
||||
mkdirSync(outputPath, { recursive: true });
|
||||
|
||||
let globalIdx = 0;
|
||||
for (const chunkDir of chunkPaths) {
|
||||
if (!statSync(chunkDir).isDirectory()) {
|
||||
throw new Error(
|
||||
`[assemble] png-sequence chunk must be a directory: ${chunkDir} (got a file)`,
|
||||
);
|
||||
}
|
||||
const frames = readdirSync(chunkDir)
|
||||
.filter((name) => name.endsWith(".png"))
|
||||
.sort();
|
||||
if (frames.length === 0) {
|
||||
throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`);
|
||||
}
|
||||
for (const frame of frames) {
|
||||
const dst = join(outputPath, formatExportFrameName(globalIdx, "png"));
|
||||
cpSync(join(chunkDir, frame), dst);
|
||||
globalIdx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (globalIdx !== totalFrames) {
|
||||
// Don't throw — surface as a warning. Some compositions report total
|
||||
// frame count via the duration math (`ceil(duration * fps)`) but the
|
||||
// actual captured frame count can differ by ±1 across hosts in edge
|
||||
// cases. The merged sequence is still complete; consumers should rely
|
||||
// on the on-disk count.
|
||||
console.warn(
|
||||
`[assemble] png-sequence frame count mismatch: merged ${globalIdx} frames vs ` +
|
||||
`plan.totalFrames=${totalFrames}. Using on-disk count.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pad-or-trim is encoder-side (audio length normalization for muxed
|
||||
// containers); png-sequence has no encoder, so we copy the audio
|
||||
// verbatim. The sidecar matches the in-process png-sequence convention.
|
||||
if (audioPath !== null && existsSync(audioPath)) {
|
||||
const sidecar = join(outputPath, "audio.aac");
|
||||
cpSync(audioPath, sidecar);
|
||||
}
|
||||
|
||||
let fileSize = 0;
|
||||
for (const name of readdirSync(outputPath)) {
|
||||
try {
|
||||
fileSize += statSync(join(outputPath, name)).size;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTimeMs,
|
||||
framesEncoded: globalIdx,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Per-adapter chunk-boundary contract: rendering the same composition at
|
||||
* chunkSize=N (single chunk, no seams) vs chunkSize=N/4 (four chunks, three
|
||||
* seams at frames 15, 30, 45) MUST produce byte-identical *frames*. Anything
|
||||
* weaker means the worker's seek-determinism leaks across chunk boundaries.
|
||||
*
|
||||
* Output is png-sequence rather than mp4 because mp4 bitstreams encode
|
||||
* keyframe placement directly: chunkSize=60 emits 1 IDR; chunkSize=15 emits
|
||||
* 4 IDRs at frames 0/15/30/45. Those are legitimately different bytes even
|
||||
* when the captured pixels are identical. The png-sequence assemble path
|
||||
* merges chunk frame directories with no re-encode, so per-frame byte
|
||||
* equality is exactly pixel equality.
|
||||
*
|
||||
* For each first-party adapter (GSAP, Anime.js, Three.js, Lottie, CSS,
|
||||
* WAAPI), `tests/distributed/<adapter>-boundary/src/index.html` is a
|
||||
* 60-frame composition that drives the adapter through its registered seek
|
||||
* hook. The fixtures intentionally lack a `meta.json` so they're invisible
|
||||
* to the regression harness; this test owns them. On hosts whose
|
||||
* chrome-headless-shell can't render (no SwiftShader / missing GL stack),
|
||||
* each subtest soft-skips and the Docker harness covers the contract.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { HOST_CHROME_FAILURE_PATTERNS } from "./__test_utils__/hostChromeFailures.js";
|
||||
import { assemble } from "./assemble.js";
|
||||
import { plan } from "./plan.js";
|
||||
import { renderChunk } from "./renderChunk.js";
|
||||
|
||||
// Per-adapter fixture directories under `packages/producer/tests/distributed/`.
|
||||
// Each must hold `src/index.html`; this test owns the planning + render +
|
||||
// assemble pipeline so no `output/` baseline is required.
|
||||
const ADAPTERS = ["gsap", "anime", "three", "lottie", "css", "waapi"] as const;
|
||||
|
||||
// Every adapter fixture is a 2-second composition at 30fps. Pin the absolute
|
||||
// count so a regression that produces fewer frames in both runs (e.g. a
|
||||
// probe stage that reads duration as 0s) doesn't pass vacuously.
|
||||
const EXPECTED_FRAME_COUNT = 60;
|
||||
|
||||
let runRoot: string;
|
||||
let testsDistributedDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-chunk-boundary-test-"));
|
||||
// `__dirname`-equivalent in ESM.
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
// packages/producer/src/services/distributed/ → packages/producer/tests/distributed/
|
||||
testsDistributedDir = resolve(moduleDir, "..", "..", "..", "tests", "distributed");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function planAndAssemble(input: {
|
||||
projectDir: string;
|
||||
workDir: string;
|
||||
chunkSize: number;
|
||||
}): Promise<string> {
|
||||
const planDir = join(input.workDir, "plan");
|
||||
const chunksDir = join(input.workDir, "chunks");
|
||||
const outputPath = join(input.workDir, "frames");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
mkdirSync(chunksDir, { recursive: true });
|
||||
|
||||
const planResult = await plan(
|
||||
input.projectDir,
|
||||
{
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 180,
|
||||
// png-sequence: every chunk emits a directory of PNGs and assemble()
|
||||
// merges them with no re-encode. Byte equality at the file level is
|
||||
// pixel equality. mp4 would muddy this because chunkSize directly
|
||||
// affects keyframe placement in the bitstream.
|
||||
format: "png-sequence",
|
||||
chunkSize: input.chunkSize,
|
||||
// anime.js's IIFE bundle embeds `font-family: ui-monospace, monospace`
|
||||
// as a string literal inside its JS, which `validateNoSystemFonts`'s
|
||||
// document-wide regex false-positives. These fixtures display no text,
|
||||
// so disabling the check (the documented escape hatch on this flag) is
|
||||
// safe.
|
||||
rejectOnSystemFonts: false,
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
|
||||
const chunkPaths: string[] = [];
|
||||
for (let i = 0; i < planResult.chunkCount; i++) {
|
||||
// png-sequence chunks are directories, not files.
|
||||
const chunkPath = join(chunksDir, `chunk-${String(i).padStart(4, "0")}`);
|
||||
await renderChunk(planDir, i, chunkPath);
|
||||
chunkPaths.push(chunkPath);
|
||||
}
|
||||
|
||||
const audioPath = join(planDir, "audio.aac");
|
||||
const audioForAssemble = existsSync(audioPath) ? audioPath : null;
|
||||
await assemble(planDir, chunkPaths, audioForAssemble, outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
describe("per-adapter chunk-boundary byte equality", () => {
|
||||
// Two renders × ~5s each × cold-Chrome × six adapters can run long on the
|
||||
// CI host. Per-adapter timeout keeps each `it()` failure local rather
|
||||
// than smearing a single slow adapter across the suite cap.
|
||||
const TIMEOUT_MS = 240_000;
|
||||
|
||||
for (const adapter of ADAPTERS) {
|
||||
it(
|
||||
`${adapter}: chunkSize=60 (N=1) vs chunkSize=15 (N=4) produces byte-identical frames`,
|
||||
async () => {
|
||||
const fixtureDir = join(testsDistributedDir, `${adapter}-boundary`);
|
||||
if (!existsSync(join(fixtureDir, "src", "index.html"))) {
|
||||
throw new Error(
|
||||
`[chunkBoundary.test] missing fixture src for adapter ${adapter}: ${fixtureDir}/src/index.html`,
|
||||
);
|
||||
}
|
||||
const projectDir = join(fixtureDir, "src");
|
||||
|
||||
const workOne = join(runRoot, `${adapter}-n1`);
|
||||
const workFour = join(runRoot, `${adapter}-n4`);
|
||||
mkdirSync(workOne, { recursive: true });
|
||||
mkdirSync(workFour, { recursive: true });
|
||||
|
||||
// Soft-skip when host Chrome can't render. Wrap *both* renders —
|
||||
// cold-Chrome / SwiftShader flakes happen on the second render
|
||||
// as readily as the first, and a hard-fail on the N=4 path would
|
||||
// diverge from the rest of the harness's soft-skip convention.
|
||||
const runRender = async (workDir: string, chunkSize: number): Promise<string | null> => {
|
||||
try {
|
||||
return await planAndAssemble({ projectDir, workDir, chunkSize });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
`[chunkBoundary.test] skipping ${adapter} — host Chrome can't render. ` +
|
||||
"Docker harness covers the contract. Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const outOne = await runRender(workOne, 60);
|
||||
if (outOne === null) return;
|
||||
const outFour = await runRender(workFour, 15);
|
||||
if (outFour === null) return;
|
||||
|
||||
// Per-frame byte equality across the two frames directories. A
|
||||
// boundary regression in the adapter's seek-determinism would
|
||||
// show up as one or more frames differing at the seam offsets
|
||||
// (frames 15/30/45 — the chunk transitions in the N=4 run).
|
||||
const framesOne = readdirSync(outOne)
|
||||
.filter((n) => n.toLowerCase().endsWith(".png"))
|
||||
.sort();
|
||||
const framesFour = readdirSync(outFour)
|
||||
.filter((n) => n.toLowerCase().endsWith(".png"))
|
||||
.sort();
|
||||
// Pin the absolute count, not just equality between the two runs.
|
||||
// Otherwise a regression that truncates BOTH renders identically
|
||||
// (e.g. a probe stage that misreads duration as 0s) would pass
|
||||
// vacuously — `0 === 0` is true.
|
||||
expect(framesOne.length).toBe(EXPECTED_FRAME_COUNT);
|
||||
expect(framesFour.length).toBe(EXPECTED_FRAME_COUNT);
|
||||
expect(framesOne).toEqual(framesFour);
|
||||
for (let i = 0; i < framesOne.length; i++) {
|
||||
const frameName = framesOne[i];
|
||||
if (frameName === undefined) continue;
|
||||
const a = readFileSync(join(outOne, frameName));
|
||||
const b = readFileSync(join(outFour, frameName));
|
||||
if (a.byteLength !== b.byteLength || !a.equals(b)) {
|
||||
throw new Error(
|
||||
`${adapter}: frame ${frameName} differs between N=1 and N=4 ` +
|
||||
`(a=${a.byteLength}B, b=${b.byteLength}B)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
it("expected fixture directories exist", () => {
|
||||
// Cheap sanity check so a `bun test` filter that excludes the
|
||||
// per-adapter `it()` blocks still verifies the fixture layout.
|
||||
const present = readdirSync(testsDistributedDir).filter((name) => name.endsWith("-boundary"));
|
||||
for (const adapter of ADAPTERS) {
|
||||
expect(present).toContain(`${adapter}-boundary`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// fallow-ignore-file code-duplication complexity
|
||||
/**
|
||||
* Cross-worker idempotency: rendering the same `(planDir, chunkIndex)` on two
|
||||
* different workers MUST produce byte-identical output. This is what makes a
|
||||
* fan-out architecture safe — Worker A can crash mid-chunk and Worker B can
|
||||
* pick up the same slice without producing a frame that disagrees with what
|
||||
* Worker A would have written.
|
||||
*
|
||||
* `renderChunk.test.ts` already pins the byte-identical-retry contract for
|
||||
* png-sequence by comparing the engineered `ChunkResult.sha256` fingerprint.
|
||||
* This file complements that with:
|
||||
*
|
||||
* 1. Explicit `Buffer.equals` comparison of the raw bytes of every output
|
||||
* file, not just a derived fingerprint. This independently verifies the
|
||||
* property `renderChunk`'s sha256 helper is supposed to imply.
|
||||
* 2. The mp4 path. mp4 chunks go through the BeginFrame capture path +
|
||||
* libx264 encode; png-sequence chunks go through the screenshot capture
|
||||
* path with no encoder. Both must be byte-identical across temp dirs;
|
||||
* pinning only one would let an mp4-specific regression slip past.
|
||||
*
|
||||
* Both subtests soft-skip when `chrome-headless-shell` on the host can't
|
||||
* render — the Docker harness exercises the same code paths against a
|
||||
* matched chrome + ffmpeg build.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { HOST_CHROME_FAILURE_PATTERNS } from "./__test_utils__/hostChromeFailures.js";
|
||||
import { plan } from "./plan.js";
|
||||
import { renderChunk } from "./renderChunk.js";
|
||||
|
||||
// Tiny composition shared by every subtest. 5 frames at 30fps + chunkSize=2
|
||||
// lands three chunks of sizes [2, 2, 1] within a few seconds inside Docker
|
||||
// and keeps host runs (where this test soft-skips most of the time) cheap
|
||||
// when they do exercise the full path. We assert byte-identity on chunk 0
|
||||
// (the always-special first chunk) and chunk 1 (the smallest non-zero
|
||||
// chunk index, exercising the seek-offset + frame-indexing code path that
|
||||
// a regression specific to chunks N>0 would land in).
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>cross-worker idempotency fixture</title></head>
|
||||
<body style="margin:0;background:#000;color:#fff;font:32px sans-serif">
|
||||
<div data-composition-id="root" data-no-timeline data-width="160" data-height="120" data-duration="0.16667">
|
||||
<p style="padding:1rem">chunk fixture</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Force multi-chunk plans on a tiny fixture. With 5 frames the resolver
|
||||
// produces chunks of sizes [2, 2, 1] — enough to cover chunkIndex 0 + a
|
||||
// non-zero chunk without inflating render wall time.
|
||||
const PLAN_CHUNK_SIZE = 2;
|
||||
|
||||
// Subtests render this set of chunk indices, twice each, and assert every
|
||||
// pair is byte-identical. Chunk 0 is the always-present special case;
|
||||
// chunk 1 catches regressions in the seek-offset / frame-indexing logic
|
||||
// that would only fire for chunks N>0.
|
||||
const CHUNK_INDICES_UNDER_TEST = [0, 1] as const;
|
||||
|
||||
let runRoot: string;
|
||||
let projectDir: string;
|
||||
let pngPlanDir: string;
|
||||
let mp4PlanDir: string;
|
||||
let pngPlanReady = false;
|
||||
let mp4PlanReady = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-cross-worker-test-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
|
||||
// Plan once per format. plan() is cheap for this fixture (statically-resolvable
|
||||
// duration means the probe stage never launches a browser), and re-planning
|
||||
// per `it()` would dominate the test wall time.
|
||||
//
|
||||
// A plan failure is treated as a soft skip — most commonly it's the
|
||||
// ffmpeg-version readout fighting a host with no ffmpeg on PATH, or the
|
||||
// compile stage hitting a missing font binary. Either way, the Docker
|
||||
// harness exercises the same code path against a working image, so the
|
||||
// host failure is informational rather than load-bearing.
|
||||
pngPlanDir = join(runRoot, "plan-pngseq");
|
||||
mkdirSync(pngPlanDir, { recursive: true });
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 160, height: 120, format: "png-sequence", chunkSize: PLAN_CHUNK_SIZE },
|
||||
pngPlanDir,
|
||||
);
|
||||
pngPlanReady = true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] png-sequence plan() failed on host — subtest will soft-skip.",
|
||||
"Diagnostic:",
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, 240),
|
||||
);
|
||||
}
|
||||
|
||||
mp4PlanDir = join(runRoot, "plan-mp4");
|
||||
mkdirSync(mp4PlanDir, { recursive: true });
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 160, height: 120, format: "mp4", chunkSize: PLAN_CHUNK_SIZE },
|
||||
mp4PlanDir,
|
||||
);
|
||||
mp4PlanReady = true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[crossWorkerIdempotency.test] mp4 plan() failed on host — subtest will soft-skip.",
|
||||
"Diagnostic:",
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, 240),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Compare two chunk outputs byte-by-byte. For `file` outputs (mp4/mov) the
|
||||
* whole file is compared; for `frame-dir` outputs (png-sequence) every PNG is
|
||||
* compared (including the directory listing, so a missing or extra frame
|
||||
* trips the assertion).
|
||||
*/
|
||||
function assertBytesEqual(
|
||||
outA: string,
|
||||
outB: string,
|
||||
kind: "file" | "frame-dir",
|
||||
label: string,
|
||||
): void {
|
||||
if (kind === "file") {
|
||||
const bytesA = readFileSync(outA);
|
||||
const bytesB = readFileSync(outB);
|
||||
expect(bytesA.byteLength).toBe(bytesB.byteLength);
|
||||
// Buffer.equals returns boolean — `toBe(true)` gives a clearer message on
|
||||
// failure than `toEqual` on two large Buffers.
|
||||
expect(bytesA.equals(bytesB)).toBe(true);
|
||||
return;
|
||||
}
|
||||
const framesA = readdirSync(outA).sort();
|
||||
const framesB = readdirSync(outB).sort();
|
||||
expect(framesA).toEqual(framesB);
|
||||
for (const name of framesA) {
|
||||
const a = readFileSync(join(outA, name));
|
||||
const b = readFileSync(join(outB, name));
|
||||
if (a.byteLength !== b.byteLength || !a.equals(b)) {
|
||||
throw new Error(`${label}: frame ${name} differs (a=${a.byteLength}B, b=${b.byteLength}B)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("cross-worker idempotency", () => {
|
||||
// Generous timeout for slower CI: cold Chrome start + 5-frame capture +
|
||||
// ffmpeg encode is the dominant cost, repeated twice per chunk index. With
|
||||
// PLAN_CHUNK_SIZE=2 each chunk has at most 2 frames, so cold-start
|
||||
// dominates even when iterating multiple indices.
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
for (const chunkIndex of CHUNK_INDICES_UNDER_TEST) {
|
||||
it(
|
||||
`png-sequence: chunk ${chunkIndex} is byte-identical across two distinct output dirs`,
|
||||
async () => {
|
||||
if (!pngPlanReady) {
|
||||
console.warn(
|
||||
`[crossWorkerIdempotency.test] skipping png-sequence chunk ${chunkIndex} — plan() didn't complete on host`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const outA = join(runRoot, `pngseq-chunk-${chunkIndex}-a`);
|
||||
const outB = join(runRoot, `pngseq-chunk-${chunkIndex}-b`);
|
||||
let a, b;
|
||||
try {
|
||||
a = await renderChunk(pngPlanDir, chunkIndex, outA);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
`[crossWorkerIdempotency.test] skipping png-sequence chunk ${chunkIndex} — host Chrome can't render. `,
|
||||
"Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
b = await renderChunk(pngPlanDir, chunkIndex, outB);
|
||||
|
||||
expect(a.outputKind).toBe("frame-dir");
|
||||
expect(b.outputKind).toBe("frame-dir");
|
||||
expect(a.framesEncoded).toBeGreaterThan(0);
|
||||
expect(b.framesEncoded).toBe(a.framesEncoded);
|
||||
|
||||
// sha256 fingerprint match — the contract `ChunkResult.sha256` implies.
|
||||
expect(a.sha256).toBe(b.sha256);
|
||||
// Independent byte-level verification. If the sha256 helper ever
|
||||
// regresses (e.g. starts hashing metadata instead of pixels), this
|
||||
// assertion still fails the test honestly.
|
||||
assertBytesEqual(outA, outB, "frame-dir", `png-sequence chunk ${chunkIndex}`);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
`mp4: chunk ${chunkIndex} is byte-identical across two distinct output paths`,
|
||||
async () => {
|
||||
if (!mp4PlanReady) {
|
||||
console.warn(
|
||||
`[crossWorkerIdempotency.test] skipping mp4 chunk ${chunkIndex} — plan() didn't complete on host`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const outDir = join(runRoot, "mp4-chunks");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outA = join(outDir, `chunk-${chunkIndex}-a.mp4`);
|
||||
const outB = join(outDir, `chunk-${chunkIndex}-b.mp4`);
|
||||
let a, b;
|
||||
try {
|
||||
a = await renderChunk(mp4PlanDir, chunkIndex, outA);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
`[crossWorkerIdempotency.test] skipping mp4 chunk ${chunkIndex} — host Chrome can't render. `,
|
||||
"Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
b = await renderChunk(mp4PlanDir, chunkIndex, outB);
|
||||
|
||||
expect(a.outputKind).toBe("file");
|
||||
expect(b.outputKind).toBe("file");
|
||||
expect(a.framesEncoded).toBeGreaterThan(0);
|
||||
expect(b.framesEncoded).toBe(a.framesEncoded);
|
||||
|
||||
expect(a.sha256).toBe(b.sha256);
|
||||
assertBytesEqual(outA, outB, "file", `mp4 chunk ${chunkIndex}`);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* Unit tests for `services/distributed/plan.ts`.
|
||||
*
|
||||
* Covers:
|
||||
* - Golden planDir layout produced from a tiny fixture (no browser probe
|
||||
* required — the fixture declares `data-duration` so the probe stage
|
||||
* short-circuits).
|
||||
* - planHash determinism across two `plan()` calls on the same inputs.
|
||||
* - Chunking math (`resolveChunkPlan`, `buildChunkSlices`).
|
||||
*
|
||||
* The "no browser probe" path is deliberate: spinning Chrome inside `bun test`
|
||||
* is expensive and flaky. The chunking helpers + planDir layout are tested
|
||||
* with synchronous compile-only fixtures; the BeginFrame / probe path lives
|
||||
* inside the regression harness (`bun run --cwd packages/producer docker:test`).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
|
||||
import {
|
||||
buildChunkSlices,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_MAX_PARALLEL_CHUNKS,
|
||||
MIN_CHUNK_SIZE,
|
||||
plan,
|
||||
resolveChunkPlan,
|
||||
} from "./plan.js";
|
||||
|
||||
// Composition the tests render. `data-duration="1"` keeps the probe stage's
|
||||
// `needsBrowser` gate `false` so plan() completes without launching Chrome.
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>plan-test fixture</title></head>
|
||||
<body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
|
||||
<p>plan-test fixture</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
let projectDir: string;
|
||||
let runRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-test-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveChunkPlan", () => {
|
||||
it("returns 1 chunk when totalFrames fits in configChunkSize", () => {
|
||||
const result = resolveChunkPlan(60, 240, 16);
|
||||
expect(result.chunkCount).toBe(1);
|
||||
expect(result.effectiveChunkSize).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("caps chunkCount at maxParallelChunks for very long renders", () => {
|
||||
// 54000 frames / 240 = 225 naive chunks → must cap at 16.
|
||||
const result = resolveChunkPlan(54000, 240, 16);
|
||||
expect(result.chunkCount).toBe(16);
|
||||
// 54000 / 16 = 3375 → chunkSize at least that big so the union covers
|
||||
// all frames in 16 slices.
|
||||
expect(result.effectiveChunkSize).toBeGreaterThanOrEqual(Math.ceil(54000 / 16));
|
||||
});
|
||||
|
||||
it("naive count drives chunkCount when below cap", () => {
|
||||
// 600 frames / 240 = 3 naive chunks; well below the 16 cap.
|
||||
const result = resolveChunkPlan(600, 240, 16);
|
||||
expect(result.chunkCount).toBe(3);
|
||||
expect(result.effectiveChunkSize).toBe(240);
|
||||
});
|
||||
|
||||
it("rejects non-positive totalFrames", () => {
|
||||
expect(() => resolveChunkPlan(0, 240, 16)).toThrow();
|
||||
expect(() => resolveChunkPlan(-1, 240, 16)).toThrow();
|
||||
expect(() => resolveChunkPlan(Number.NaN, 240, 16)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-positive configChunkSize / maxParallelChunks", () => {
|
||||
expect(() => resolveChunkPlan(60, 0, 16)).toThrow();
|
||||
expect(() => resolveChunkPlan(60, 240, 0)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer inputs (would produce fractional endFrames)", () => {
|
||||
expect(() => resolveChunkPlan(10.5, 240, 16)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(60, 240.5, 16)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(60, 240, 16.5)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(60, 240, Number.POSITIVE_INFINITY)).toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
// ── Auto-size when configChunkSize is undefined ───────────────────────
|
||||
// The auto-sizer picks `max(MIN_CHUNK_SIZE, ceil(totalFrames /
|
||||
// maxParallelChunks))` whenever the caller leaves `chunkSize` undefined,
|
||||
// honoring `maxParallelChunks` instead of clamping at a 240-frame default.
|
||||
|
||||
it("explicit chunkSize wins: 660 frames + chunkSize=240 + maxParallelChunks=16 → 3 chunks", () => {
|
||||
// Regression guard for the "explicit number still works" half of the
|
||||
// contract — passing 240 explicitly must not get auto-sized.
|
||||
const result = resolveChunkPlan(660, 240, 16);
|
||||
expect(result.chunkCount).toBe(3);
|
||||
expect(result.effectiveChunkSize).toBe(240);
|
||||
});
|
||||
|
||||
it("auto-sizes when chunkSize=undefined: 660 frames + maxParallelChunks=16 → 16 chunks", () => {
|
||||
// ceil(660 / 16) = 42; max(MIN_CHUNK_SIZE=10, 42) = 42. naiveCount =
|
||||
// ceil(660 / 42) = 16, which lands exactly at the cap.
|
||||
const result = resolveChunkPlan(660, undefined, 16);
|
||||
expect(result.chunkCount).toBe(16);
|
||||
expect(result.effectiveChunkSize).toBe(42);
|
||||
});
|
||||
|
||||
it("auto-size floor: tiny renders cap at MIN_CHUNK_SIZE rather than fragmenting infinitely", () => {
|
||||
// 50 frames / 16 workers naively gives a 4-frame chunk size, which
|
||||
// would produce 13 chunks of 4 frames each — per-chunk fixed overhead
|
||||
// dwarfs the parallelism gain. The MIN_CHUNK_SIZE=10 floor pins
|
||||
// chunkSize at 10, producing ceil(50/10) = 5 chunks instead.
|
||||
const result = resolveChunkPlan(50, undefined, 16);
|
||||
expect(result.chunkCount).toBe(5);
|
||||
expect(result.effectiveChunkSize).toBe(MIN_CHUNK_SIZE);
|
||||
});
|
||||
|
||||
it("tightens chunkCount so an explicit small chunkSize leaves no empty trailing slice", () => {
|
||||
// 121 frames, chunkSize=10, maxParallelChunks=12: ceil(121/10)=13 naive →
|
||||
// capped at 12, but effectiveChunkSize rounds up to ceil(121/12)=11. Eleven
|
||||
// 11-frame chunks already cover [0,121), so a 12th would be the empty slice
|
||||
// [121,121) that renderChunk rejects (framesInChunk <= 0). chunkCount must
|
||||
// tighten to 11.
|
||||
const result = resolveChunkPlan(121, 10, 12);
|
||||
expect(result.effectiveChunkSize).toBe(11);
|
||||
expect(result.chunkCount).toBe(11);
|
||||
const slices = buildChunkSlices(121, result.chunkCount, result.effectiveChunkSize);
|
||||
expect(slices).toHaveLength(11);
|
||||
expect(slices[slices.length - 1]).toEqual({ index: 10, startFrame: 110, endFrame: 121 });
|
||||
});
|
||||
|
||||
it("never emits an empty or inverted slice across a grid of explicit chunk sizes", () => {
|
||||
for (const totalFrames of [37, 50, 121, 333, 1000, 4001]) {
|
||||
for (const maxParallel of [2, 4, 12, 16, 40]) {
|
||||
for (const chunkSize of [10, 11, 15, 24, 100]) {
|
||||
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
|
||||
totalFrames,
|
||||
chunkSize,
|
||||
maxParallel,
|
||||
);
|
||||
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
|
||||
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
|
||||
let cursor = 0;
|
||||
for (const s of slices) {
|
||||
expect(s.startFrame).toBe(cursor); // contiguous from 0
|
||||
expect(s.endFrame).toBeGreaterThan(s.startFrame); // non-empty
|
||||
cursor = s.endFrame;
|
||||
}
|
||||
expect(cursor).toBe(totalFrames); // union is exactly [0, totalFrames)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── targetChunkFrames (optional per-chunk frame ceiling) ──
|
||||
|
||||
it("targetChunkFrames omitted is a no-op: identical to the 3-arg auto-sized result", () => {
|
||||
// The default path must be byte-identical whether the 4th arg is absent or
|
||||
// explicitly undefined.
|
||||
for (const totalFrames of [50, 660, 1466, 54000]) {
|
||||
for (const maxParallel of [8, 16, 64]) {
|
||||
const base = resolveChunkPlan(totalFrames, undefined, maxParallel);
|
||||
const withUndef = resolveChunkPlan(totalFrames, undefined, maxParallel, undefined);
|
||||
expect(withUndef).toEqual(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("targetChunkFrames collapses a short video to fewer chunks than the parallelism cap", () => {
|
||||
// 1466 frames, target 300, cap 16: ceil(1466/300)=5 chunks (not 16), each
|
||||
// ~293 frames. Fewer chunks → less per-chunk fixed overhead.
|
||||
const result = resolveChunkPlan(1466, undefined, 16, 300);
|
||||
expect(result.chunkCount).toBe(5);
|
||||
expect(result.effectiveChunkSize).toBeLessThanOrEqual(300);
|
||||
});
|
||||
|
||||
it("targetChunkFrames bounds a long video's per-chunk frames, adding chunks up to the cap", () => {
|
||||
// 54000 frames (30 min @30fps), target 1600, cap 64: ceil(54000/1600)=34
|
||||
// chunks, each <= 1600 frames so per-chunk render time stays under budget.
|
||||
const result = resolveChunkPlan(54000, undefined, 64, 1600);
|
||||
expect(result.chunkCount).toBe(34);
|
||||
expect(result.effectiveChunkSize).toBeLessThanOrEqual(1600);
|
||||
});
|
||||
|
||||
it("targetChunkFrames is clamped by maxParallelChunks: an extreme length stays at the cap (still over budget)", () => {
|
||||
// 216000 frames (2 h), target 1600, cap 64: needs 135 chunks but clamps to
|
||||
// 64; per-chunk frames then exceed the target — the genuine tier ceiling.
|
||||
const result = resolveChunkPlan(216000, undefined, 64, 1600);
|
||||
expect(result.chunkCount).toBe(64);
|
||||
expect(result.effectiveChunkSize).toBeGreaterThan(1600);
|
||||
});
|
||||
|
||||
it("explicit chunkSize wins over targetChunkFrames (targetChunkFrames is a no-op)", () => {
|
||||
const withTarget = resolveChunkPlan(54000, 240, 64, 1600);
|
||||
const chunkSizeOnly = resolveChunkPlan(54000, 240, 64);
|
||||
expect(withTarget).toEqual(chunkSizeOnly);
|
||||
});
|
||||
|
||||
it("rejects a non-positive or non-integer targetChunkFrames", () => {
|
||||
expect(() => resolveChunkPlan(1466, undefined, 16, 0)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(1466, undefined, 16, -100)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(1466, undefined, 16, 300.5)).toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
it("never emits an empty or inverted slice across a grid of targetChunkFrames", () => {
|
||||
for (const totalFrames of [37, 660, 1466, 12793, 54000]) {
|
||||
for (const maxParallel of [8, 16, 64]) {
|
||||
for (const target of [100, 300, 1600]) {
|
||||
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
|
||||
totalFrames,
|
||||
undefined,
|
||||
maxParallel,
|
||||
target,
|
||||
);
|
||||
expect(chunkCount).toBeGreaterThanOrEqual(1);
|
||||
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
|
||||
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
|
||||
let cursor = 0;
|
||||
for (const s of slices) {
|
||||
expect(s.startFrame).toBe(cursor);
|
||||
expect(s.endFrame).toBeGreaterThan(s.startFrame);
|
||||
cursor = s.endFrame;
|
||||
}
|
||||
expect(cursor).toBe(totalFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChunkSlices", () => {
|
||||
it("produces consecutive non-overlapping ranges covering all frames", () => {
|
||||
const slices = buildChunkSlices(700, 3, 240);
|
||||
expect(slices).toHaveLength(3);
|
||||
expect(slices[0]).toEqual({ index: 0, startFrame: 0, endFrame: 240 });
|
||||
expect(slices[1]).toEqual({ index: 1, startFrame: 240, endFrame: 480 });
|
||||
// Last chunk absorbs the remainder so endFrame === totalFrames exactly.
|
||||
expect(slices[2]).toEqual({ index: 2, startFrame: 480, endFrame: 700 });
|
||||
});
|
||||
|
||||
it("handles a single-chunk render", () => {
|
||||
const slices = buildChunkSlices(50, 1, 240);
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0]).toEqual({ index: 0, startFrame: 0, endFrame: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() defaults", () => {
|
||||
it("exports the documented chunking defaults", () => {
|
||||
expect(DEFAULT_CHUNK_SIZE).toBe(240);
|
||||
expect(DEFAULT_MAX_PARALLEL_CHUNKS).toBe(16);
|
||||
expect(MIN_CHUNK_SIZE).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() — golden planDir + planHash determinism", () => {
|
||||
// Each `plan()` call is reasonably expensive (compile pass parses + inlines
|
||||
// the HTML), so we run it once for the layout assertions and once more for
|
||||
// the determinism assertion. The 30s timeout absorbs cold-start font /
|
||||
// runtime resolution variance on the CI host.
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"produces the documented planDir layout",
|
||||
async () => {
|
||||
const planDir = join(runRoot, "plan-layout");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
// Pin chunkSize=240 so this fixture exercises the single-chunk path
|
||||
// (totalFrames=30 → ceil(30/240)=1 chunk). The auto-sized variant
|
||||
// (chunkSize=undefined) is exercised by the dedicated test below.
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
|
||||
planDir,
|
||||
);
|
||||
|
||||
// planDir directory layout
|
||||
expect(existsSync(join(planDir, "plan.json"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "compiled", "index.html"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "video-frames"))).toBe(true);
|
||||
// No audio in the fixture — audio.aac must NOT exist.
|
||||
expect(existsSync(join(planDir, "audio.aac"))).toBe(false);
|
||||
expect(existsSync(join(planDir, "meta", "composition.json"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "meta", "encoder.json"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "meta", "chunks.json"))).toBe(true);
|
||||
// The temporary work tree must be cleaned up.
|
||||
expect(existsSync(join(planDir, ".plan-work"))).toBe(false);
|
||||
|
||||
// ── PlanResult contract ─────────────────────────────────────────────
|
||||
expect(result.planDir).toBe(planDir);
|
||||
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.chunkCount).toBe(1);
|
||||
expect(result.totalFrames).toBe(30); // 1s @ 30fps
|
||||
expect(result.width).toBe(320);
|
||||
expect(result.height).toBe(240);
|
||||
expect(result.format).toBe("mp4");
|
||||
expect(result.ffmpegVersion).toMatch(/ffmpeg/i);
|
||||
expect(result.producerVersion).toMatch(/^\d+\.\d+\.\d+/);
|
||||
|
||||
// ── chunks.json shape ───────────────────────────────────────────────
|
||||
const chunks = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"),
|
||||
) as Array<{ index: number; startFrame: number; endFrame: number }>;
|
||||
expect(chunks).toHaveLength(result.chunkCount);
|
||||
// Slices must cover [0, totalFrames) with no gaps.
|
||||
let cursor = 0;
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.startFrame).toBe(cursor);
|
||||
cursor = chunk.endFrame;
|
||||
}
|
||||
expect(cursor).toBe(result.totalFrames);
|
||||
|
||||
// ── plan.json shape ─────────────────────────────────────────────────
|
||||
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(planJson.planHash).toBe(result.planHash);
|
||||
expect(planJson.hasAudio).toBe(false);
|
||||
expect(planJson.totalFrames).toBe(result.totalFrames);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"auto-sizes chunkSize end-to-end when caller omits it",
|
||||
async () => {
|
||||
// Integration check that the auto-sizer wired through plan() actually
|
||||
// produces multi-chunk output for the same fixture that single-chunks
|
||||
// when chunkSize is pinned. With totalFrames=30 and the default
|
||||
// maxParallelChunks=16, the auto-sizer picks
|
||||
// max(MIN_CHUNK_SIZE=10, ceil(30/16)=2) = 10 → ceil(30/10) = 3 chunks.
|
||||
const planDir = join(runRoot, "plan-autosized");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
expect(result.chunkCount).toBe(3);
|
||||
const chunks = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"),
|
||||
) as Array<{ index: number; startFrame: number; endFrame: number }>;
|
||||
expect(chunks).toHaveLength(3);
|
||||
// Encoder gopSize must follow the auto-sized chunk so chunk-boundary
|
||||
// IDR keyframes still land at frame 0 of each chunk.
|
||||
const encoder = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(encoder.gopSize).toBe(10);
|
||||
expect(encoder.chunkSize).toBe(10);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"produces a byte-identical planHash on a second invocation",
|
||||
async () => {
|
||||
const planDirA = join(runRoot, "plan-determinism-a");
|
||||
const planDirB = join(runRoot, "plan-determinism-b");
|
||||
mkdirSync(planDirA, { recursive: true });
|
||||
mkdirSync(planDirB, { recursive: true });
|
||||
|
||||
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
||||
const a = await plan(projectDir, config, planDirA);
|
||||
const b = await plan(projectDir, config, planDirB);
|
||||
|
||||
expect(a.planHash).toBe(b.planHash);
|
||||
expect(a.chunkCount).toBe(b.chunkCount);
|
||||
expect(a.totalFrames).toBe(b.totalFrames);
|
||||
|
||||
// Encoder JSON must be byte-identical — its bytes feed planHash, so any
|
||||
// drift here would silently change the hash framing.
|
||||
const encoderA = readFileSync(join(planDirA, "meta", "encoder.json"));
|
||||
const encoderB = readFileSync(join(planDirB, "meta", "encoder.json"));
|
||||
expect(encoderA.equals(encoderB)).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"plan.json.planHash matches recomputePlanHashFromPlanDir(planDir) on the same disk",
|
||||
async () => {
|
||||
// Regression guard for a real-world bug observed on audio-bearing
|
||||
// fixtures: plan() left a temporary `.plan-work/` subtree inside
|
||||
// planDir while freezePlan walked it, so the hash baked into
|
||||
// plan.json included artifacts the chunk worker would never see.
|
||||
// The chunk worker's `recomputePlanHashFromPlanDir` walk then
|
||||
// returned a different hash, tripping PLAN_HASH_MISMATCH at the
|
||||
// first chunk invocation.
|
||||
//
|
||||
// This test verifies that the hash plan() writes matches the hash
|
||||
// recomputed from the on-disk planDir contents — i.e. the chunk
|
||||
// worker's view. Holds for any plan, audio or not.
|
||||
const planDir = join(runRoot, "plan-hash-recompute");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
const recomputed = recomputePlanHashFromPlanDir(planDir);
|
||||
expect(recomputed).toBe(result.planHash);
|
||||
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as {
|
||||
planHash: string;
|
||||
};
|
||||
expect(planJson.planHash).toBe(result.planHash);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Audio-bearing variant of the planHash recompute test. The pre-fix bug
|
||||
// surfaced because `runAudioStage` downloads/mixes source audio into
|
||||
// `<planDir>/.plan-work/`, and `freezePlan` walked that subtree before
|
||||
// plan.ts cleaned it up. A composition without `<audio>` short-circuits
|
||||
// the audio stage and never materialises `.plan-work/downloads/`, so
|
||||
// the no-audio test above would pass even on the broken code. This
|
||||
// variant generates a 1s silent wav via `ffmpeg`, references it from
|
||||
// the composition, and runs plan() — exercising the audio-mix path
|
||||
// that produced the original bug.
|
||||
const HAS_FFMPEG = (() => {
|
||||
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
||||
return spawnSync("ffmpeg", ["-version"]).status === 0;
|
||||
})();
|
||||
|
||||
it.skipIf(!HAS_FFMPEG)(
|
||||
"plan.json.planHash matches recompute on an audio-bearing composition",
|
||||
async () => {
|
||||
const audioProjectDir = join(runRoot, "project-with-audio");
|
||||
mkdirSync(audioProjectDir, { recursive: true });
|
||||
// Generate a 1s mono silent wav. PCM keeps the file tiny without
|
||||
// pulling in an audio asset fixture.
|
||||
const audioPath = join(audioProjectDir, "silence.wav");
|
||||
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
||||
const ffmpeg = spawnSync("ffmpeg", [
|
||||
"-nostdin",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=channel_layout=mono:sample_rate=44100",
|
||||
"-t",
|
||||
"1",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
audioPath,
|
||||
]);
|
||||
if (ffmpeg.status !== 0) {
|
||||
throw new Error(`ffmpeg silence-wav generation failed: ${ffmpeg.stderr?.toString()}`);
|
||||
}
|
||||
writeFileSync(
|
||||
join(audioProjectDir, "index.html"),
|
||||
`<!doctype html>
|
||||
<html><head><meta charset="utf-8"></head><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
|
||||
<audio data-composition-audio src="silence.wav"></audio>
|
||||
<p>audio-bearing fixture</p>
|
||||
</div>
|
||||
</body></html>`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const planDir = join(runRoot, "plan-hash-recompute-audio");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
audioProjectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
const recomputed = recomputePlanHashFromPlanDir(planDir);
|
||||
// This assertion fails on the pre-fix code: freezePlan saw
|
||||
// `.plan-work/downloads/` (or whatever the audio stage leaves
|
||||
// behind) inside planDir, baked it into plan.json's planHash,
|
||||
// and then plan.ts rm'd it — so recompute walks a different
|
||||
// file set than freezePlan did.
|
||||
expect(recomputed).toBe(result.planHash);
|
||||
// Verify the audio stage actually fired (otherwise the test
|
||||
// pins the wrong path — the same false-pass mode as the
|
||||
// no-audio variant above).
|
||||
expect(existsSync(join(planDir, "audio.aac"))).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("plan() — codec knob", () => {
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"defaults `codec` to h264 (libx264-software) for mp4",
|
||||
async () => {
|
||||
const planDir = join(runRoot, "plan-codec-default");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
|
||||
const encoder = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(encoder.encoder).toBe("libx264-software");
|
||||
expect(encoder.pixelFormat).toBe("yuv420p");
|
||||
expect(encoder).not.toHaveProperty("vp9CpuUsed");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'maps `codec: "h265"` to libx265-software for mp4',
|
||||
async () => {
|
||||
const planDir = join(runRoot, "plan-codec-h265");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4", codec: "h265" },
|
||||
planDir,
|
||||
);
|
||||
const encoder = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(encoder.encoder).toBe("libx265-software");
|
||||
// SDR 8-bit yuv420p, same as h264 — distributed mode is SDR-only and
|
||||
// 10-bit / HDR pixelFormat selection is not exposed on this surface.
|
||||
expect(encoder.pixelFormat).toBe("yuv420p");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("rejects `codec` with format other than mp4", async () => {
|
||||
const planDir = join(runRoot, "plan-codec-bad-format");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
// @ts-expect-error — runtime check is the test's purpose.
|
||||
{ fps: 30, width: 320, height: 240, format: "mov", codec: "h265" },
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/);
|
||||
});
|
||||
|
||||
it("rejects unknown codec strings for format=mp4 (no silent fall-through to h264)", async () => {
|
||||
const planDir = join(runRoot, "plan-codec-unknown");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
// @ts-expect-error — runtime check is the test's purpose. Catches
|
||||
// typos ("H265") and future codec additions ("av1") that a JS
|
||||
// caller building config from JSON might pass.
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4", codec: "h266" },
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/codec must be "h264" or "h265"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() — variables", () => {
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"snapshots variables into meta/encoder.json so chunk workers see the controller's set",
|
||||
async () => {
|
||||
const planDir = join(runRoot, "plan-variables-snapshot");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const variables = { title: "Hello", accent: "#ff0000" };
|
||||
await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4", variables },
|
||||
planDir,
|
||||
);
|
||||
const encoder = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(encoder.variables).toEqual(variables);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"omits the variables key from canonical encoder.json when the caller passes no variables",
|
||||
async () => {
|
||||
// Backwards compat: a no-variables plan must hash identically to
|
||||
// pre-Phase-9 plans. Since the canonical encoder.json strips
|
||||
// `undefined` values, the key must not appear at all.
|
||||
const planDir = join(runRoot, "plan-variables-absent");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
|
||||
const encoderRaw = readFileSync(join(planDir, "meta", "encoder.json"), "utf-8");
|
||||
expect(encoderRaw).not.toContain("variables");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"produces a DIFFERENT planHash when variables differ",
|
||||
async () => {
|
||||
// Variables fold into planHash via meta/encoder.json bytes. Two plans
|
||||
// with different variables must produce different hashes — chunked
|
||||
// output depends on the injected values, and the byte-identical-
|
||||
// retry contract has to bind to the controller's choice.
|
||||
const planDirA = join(runRoot, "plan-variables-different-a");
|
||||
const planDirB = join(runRoot, "plan-variables-different-b");
|
||||
mkdirSync(planDirA, { recursive: true });
|
||||
mkdirSync(planDirB, { recursive: true });
|
||||
|
||||
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
||||
const a = await plan(projectDir, { ...base, variables: { title: "Alice" } }, planDirA);
|
||||
const b = await plan(projectDir, { ...base, variables: { title: "Bob" } }, planDirB);
|
||||
expect(a.planHash).not.toBe(b.planHash);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"produces the SAME planHash for two plans with the same variables",
|
||||
async () => {
|
||||
// Canonical-JSON sorts object keys, so the same variables (regardless
|
||||
// of insertion order on the caller side) must round-trip to the
|
||||
// same encoder.json bytes and therefore the same planHash.
|
||||
const planDirA = join(runRoot, "plan-variables-same-a");
|
||||
const planDirB = join(runRoot, "plan-variables-same-b");
|
||||
mkdirSync(planDirA, { recursive: true });
|
||||
mkdirSync(planDirB, { recursive: true });
|
||||
|
||||
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
||||
const a = await plan(
|
||||
projectDir,
|
||||
{ ...base, variables: { title: "Alice", accent: "#ff0000" } },
|
||||
planDirA,
|
||||
);
|
||||
const b = await plan(
|
||||
projectDir,
|
||||
// Same values, opposite insertion order.
|
||||
{ ...base, variables: { accent: "#ff0000", title: "Alice" } },
|
||||
planDirB,
|
||||
);
|
||||
expect(a.planHash).toBe(b.planHash);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"no-variables plan hashes identically to itself (backwards-compat baseline)",
|
||||
async () => {
|
||||
// Pin the no-variables path so adding the new field doesn't silently
|
||||
// change the hash for callers who never opt in. Re-runs the
|
||||
// determinism check from the golden block, scoped to the variables
|
||||
// surface so a future refactor here trips a focused failure.
|
||||
const planDirA = join(runRoot, "plan-no-variables-determinism-a");
|
||||
const planDirB = join(runRoot, "plan-no-variables-determinism-b");
|
||||
mkdirSync(planDirA, { recursive: true });
|
||||
mkdirSync(planDirB, { recursive: true });
|
||||
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
||||
const a = await plan(projectDir, config, planDirA);
|
||||
const b = await plan(projectDir, config, planDirB);
|
||||
expect(a.planHash).toBe(b.planHash);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("plan() — webm format (distributed VP9)", () => {
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
'maps `format: "webm"` to libvpx-vp9-software + yuva420p',
|
||||
async () => {
|
||||
// Pins the plan-time encoder choice for webm: libvpx-vp9-software
|
||||
// with yuva420p so the format's alpha-channel contract round-trips
|
||||
// through chunked rendering.
|
||||
const planDir = join(runRoot, "plan-webm-vp9");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "webm" },
|
||||
planDir,
|
||||
);
|
||||
expect(result.format).toBe("webm");
|
||||
|
||||
const encoder = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(encoder.encoder).toBe("libvpx-vp9-software");
|
||||
expect(encoder.pixelFormat).toBe("yuva420p");
|
||||
// Closed-GOP must be on so concat-copy at assemble time works.
|
||||
// gopSize equals the chunkSize so every chunk's first frame is a
|
||||
// keyframe with no alt-ref references reaching back across seams.
|
||||
expect(encoder.closedGop).toBe(true);
|
||||
expect(encoder.gopSize).toBe(encoder.chunkSize);
|
||||
expect(encoder.vp9CpuUsed).toBe(4);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"locks the resolved VP9 cpu-used value into encoder metadata",
|
||||
async () => {
|
||||
const planDir = join(runRoot, "plan-webm-vp9-cpu-used");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
await plan(
|
||||
projectDir,
|
||||
{
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 240,
|
||||
format: "webm",
|
||||
producerConfig: { vp9CpuUsed: 2 },
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
|
||||
const encoder = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(encoder.vp9CpuUsed).toBe(2);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("rejects `codec` with format=webm", async () => {
|
||||
// webm is always libvpx-vp9 — same shape as mov (always ProRes 4444).
|
||||
// A JS caller building config from JSON who passes `codec: "vp8"` or
|
||||
// `codec: "av1"` must hit a typed error, not silently encode VP9.
|
||||
const planDir = join(runRoot, "plan-webm-bad-codec");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
// @ts-expect-error — runtime check is the test's purpose.
|
||||
{ fps: 30, width: 320, height: 240, format: "webm", codec: "vp8" },
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Unit tests for the distributed format banlist.
|
||||
*
|
||||
* `plan()` refuses one configuration up front:
|
||||
* - mp4 + HDR (`hdrMode === "force-hdr"`) — chunked HDR pre-extract +
|
||||
* HDR signaling re-apply on the assembled file is not implemented.
|
||||
*
|
||||
* The banlist must trip BEFORE any other work runs (file server, browser,
|
||||
* ffprobe) — otherwise a banned config can leak a partial planDir on disk.
|
||||
* The HDR case asserts `existsSync(planDir)` is `false` after the throw
|
||||
* to pin the early-exit contract.
|
||||
*
|
||||
* WebM was previously refused here; v0.7+ supports it via closed-GOP
|
||||
* concat-copy. The "accepts webm" tests below pin the contract that
|
||||
* `rejectUnsupportedDistributedFormat` no longer trips on webm.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
FormatNotSupportedInDistributedError,
|
||||
plan,
|
||||
rejectUnsupportedDistributedFormat,
|
||||
type DistributedRenderConfig,
|
||||
} from "./plan.js";
|
||||
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">hi</div>
|
||||
</body></html>`;
|
||||
|
||||
let runRoot: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-format-ban-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("rejectUnsupportedDistributedFormat (pure)", () => {
|
||||
it("accepts the v1-supported formats (mp4 / mov / png-sequence / webm)", () => {
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "mp4" })).not.toThrow();
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "mov" })).not.toThrow();
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "png-sequence" })).not.toThrow();
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "webm" })).not.toThrow();
|
||||
expect(() =>
|
||||
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "auto" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "force-sdr" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
rejectUnsupportedDistributedFormat({ format: "webm", hdrMode: "force-sdr" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
rejectUnsupportedDistributedFormat({
|
||||
format: "mp4",
|
||||
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
|
||||
expect((caught as Error).message).toMatch(/HDR/);
|
||||
});
|
||||
|
||||
it("rejects HDR + webm combination (HDR is the trip, not webm)", () => {
|
||||
// Belt-and-suspenders: even when webm is the format, force-hdr must
|
||||
// still throw — distributed HDR is unimplemented regardless of format.
|
||||
let caught: unknown;
|
||||
try {
|
||||
rejectUnsupportedDistributedFormat({
|
||||
format: "webm",
|
||||
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() banlist (end-to-end)", () => {
|
||||
it("throws on HDR mp4 and does not create the planDir", async () => {
|
||||
const planDir = join(runRoot, "plandir-hdr-bans");
|
||||
// Don't pre-create planDir — plan() shouldn't create it on the throw path.
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{
|
||||
format: "mp4",
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 240,
|
||||
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
|
||||
expect(existsSync(planDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Unit tests for the `PLAN_TOO_LARGE` size cap on `plan()`.
|
||||
*
|
||||
* `plan()` measures the produced planDir before returning, and throws a
|
||||
* non-retryable `PlanTooLargeError` if it exceeds the configured ceiling.
|
||||
* Defaults to {@link PLAN_DIR_SIZE_LIMIT_BYTES} (2 GB); a smaller ceiling
|
||||
* can be passed via `DistributedRenderConfig.planDirSizeLimitBytes` so
|
||||
* tests can exercise the throw path without filling 2 GB of /tmp.
|
||||
*
|
||||
* Two cases:
|
||||
* 1. The standalone `measurePlanDirBytes` helper walks the tree and
|
||||
* sums regular files.
|
||||
* 2. `plan()` throws `PlanTooLargeError` with `code === PLAN_TOO_LARGE`
|
||||
* when the produced planDir exceeds a tiny configured cap.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
measurePlanDirBytes,
|
||||
PLAN_DIR_SIZE_LIMIT_BYTES,
|
||||
PLAN_TOO_LARGE,
|
||||
PlanTooLargeError,
|
||||
plan,
|
||||
} from "./plan.js";
|
||||
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">hi</div>
|
||||
</body></html>`;
|
||||
|
||||
let runRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-size-cap-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("measurePlanDirBytes", () => {
|
||||
it("returns 0 for an empty directory", () => {
|
||||
const dir = mkdtempSync(join(runRoot, "empty-"));
|
||||
expect(measurePlanDirBytes(dir)).toBe(0);
|
||||
});
|
||||
|
||||
it("sums file sizes recursively", () => {
|
||||
const dir = mkdtempSync(join(runRoot, "fixture-"));
|
||||
mkdirSync(join(dir, "nested", "deeper"), { recursive: true });
|
||||
writeFileSync(join(dir, "a.bin"), Buffer.alloc(100));
|
||||
writeFileSync(join(dir, "nested", "b.bin"), Buffer.alloc(250));
|
||||
writeFileSync(join(dir, "nested", "deeper", "c.bin"), Buffer.alloc(50));
|
||||
expect(measurePlanDirBytes(dir)).toBe(400);
|
||||
});
|
||||
|
||||
it("ignores symlinks (not traversed into)", () => {
|
||||
const dir = mkdtempSync(join(runRoot, "symlinks-"));
|
||||
writeFileSync(join(dir, "real.bin"), Buffer.alloc(128));
|
||||
// We don't actually create a symlink here because the planDir
|
||||
// materialization path strips them — but the function should still
|
||||
// gracefully ignore broken entries if any slipped in. Confirm the
|
||||
// baseline is correct (the real file's bytes).
|
||||
expect(measurePlanDirBytes(dir)).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLAN_DIR_SIZE_LIMIT_BYTES constant", () => {
|
||||
it("is the documented 2 GB ceiling", () => {
|
||||
expect(PLAN_DIR_SIZE_LIMIT_BYTES).toBe(2 * 1024 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlanTooLargeError", () => {
|
||||
it("carries the typed PLAN_TOO_LARGE code", () => {
|
||||
const err = new PlanTooLargeError(3 * 1024 * 1024 * 1024, 2 * 1024 * 1024 * 1024);
|
||||
expect(err.code).toBe(PLAN_TOO_LARGE);
|
||||
expect(err.name).toBe("PlanTooLargeError");
|
||||
expect(err.sizeBytes).toBe(3 * 1024 * 1024 * 1024);
|
||||
expect(err.limitBytes).toBe(2 * 1024 * 1024 * 1024);
|
||||
// Message should point callers at the in-process renderer as the
|
||||
// escape hatch.
|
||||
expect(err.message).toMatch(/PLAN_TOO_LARGE/);
|
||||
expect(err.message).toMatch(/in-process/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() PLAN_TOO_LARGE throw path", () => {
|
||||
// Generous timeout — the actual plan() pass on a tiny fixture is ~250ms,
|
||||
// but cold cache + font snapshot read can spike on slower CI hosts.
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"throws PlanTooLargeError when planDir exceeds the configured ceiling",
|
||||
async () => {
|
||||
const projectDir = mkdtempSync(join(runRoot, "project-"));
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
const planDir = mkdtempSync(join(runRoot, "plandir-too-large-"));
|
||||
|
||||
// 1024-byte ceiling — even an empty planDir's meta/{composition,
|
||||
// encoder,chunks}.json + compiled/index.html easily exceeds this.
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 240,
|
||||
format: "mp4",
|
||||
planDirSizeLimitBytes: 1024,
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(PlanTooLargeError);
|
||||
expect((caught as PlanTooLargeError).code).toBe(PLAN_TOO_LARGE);
|
||||
expect((caught as PlanTooLargeError).sizeBytes).toBeGreaterThan(1024);
|
||||
expect((caught as PlanTooLargeError).limitBytes).toBe(1024);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"succeeds when the default ceiling is well above the produced planDir size",
|
||||
async () => {
|
||||
// No `planDirSizeLimitBytes` override → uses 2 GB default. The fixture
|
||||
// produces a planDir well under that, so plan() must complete.
|
||||
const projectDir = mkdtempSync(join(runRoot, "project-ok-"));
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
const planDir = mkdtempSync(join(runRoot, "plandir-ok-"));
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("plan() duration guard", () => {
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"rejects probe durations that would create impossible distributed frame counts",
|
||||
async () => {
|
||||
const projectDir = mkdtempSync(join(runRoot, "project-impossible-duration-"));
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-start="0">
|
||||
<div class="caption">hello</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.root = {
|
||||
duration() { return 10000000000; },
|
||||
pause() {},
|
||||
time() { return 0; },
|
||||
seek() {},
|
||||
totalTime() {},
|
||||
add() {}
|
||||
};
|
||||
</script>
|
||||
</body></html>`,
|
||||
"utf-8",
|
||||
);
|
||||
const planDir = mkdtempSync(join(runRoot, "plandir-impossible-duration-"));
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 240,
|
||||
format: "mp4",
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect(String((caught as Error).message)).toMatch(/duration/i);
|
||||
expect(String((caught as Error).message)).toMatch(/distributed/i);
|
||||
expect(String((caught as Error).message)).toContain("300000000000");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Content-addressing for a project directory, shared by the distributed-render
|
||||
* adapters' `deploySite` verbs.
|
||||
*
|
||||
* Each adapter uploads a project tarball to `…/sites/<siteId>/project.tar.gz`
|
||||
* and short-circuits the upload when the object already exists. `siteId` is a
|
||||
* SHA-256 over the project's files so identical content always maps to the
|
||||
* same key — that contract has to be byte-identical across adapters, which is
|
||||
* exactly why it lives here rather than being copy-pasted per adapter.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, relative } from "node:path";
|
||||
import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "./plan.js";
|
||||
|
||||
/**
|
||||
* SHA-256 over every regular file under `projectDir` (sorted by relative
|
||||
* path) → 16-character hex prefix. The prefix is the `siteId`.
|
||||
*
|
||||
* The hash includes the relative path plus every byte of each file, so a
|
||||
* same-bytes rename still yields a fresh id. We trim to 16 chars because the
|
||||
* full 64 isn't useful in an object key for legibility. Top-level segments in
|
||||
* {@link PLAN_PROJECT_DIR_SKIP_SEGMENTS} (e.g. `node_modules`) are skipped to
|
||||
* match what the plan stage copies.
|
||||
*
|
||||
* Reads are synchronous: project trees are typically tens of MB at most
|
||||
* (HTML/CSS/JS plus a few composition assets), so the simpler shape wins over
|
||||
* a streaming pipeline.
|
||||
*/
|
||||
export function hashProjectDir(projectDir: string): string {
|
||||
const hash = createHash("sha256");
|
||||
const files: string[] = [];
|
||||
function walk(dir: string, isRoot: boolean): void {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
)) {
|
||||
if (isRoot && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(entry.name)) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, false);
|
||||
else if (entry.isFile()) files.push(full);
|
||||
}
|
||||
}
|
||||
walk(projectDir, true);
|
||||
for (const file of files) {
|
||||
const rel = relative(projectDir, file).replaceAll("\\", "/");
|
||||
hash.update(rel);
|
||||
hash.update("\0");
|
||||
hash.update(readFileSync(file));
|
||||
}
|
||||
return hash.digest("hex").slice(0, 16);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Unit tests for the public-export surface of the distributed primitives.
|
||||
*
|
||||
* Two import paths must work for adopters:
|
||||
*
|
||||
* 1. `import { plan, renderChunk, assemble } from "@hyperframes/producer"`
|
||||
* — the canonical package entry. Includes the three activity functions
|
||||
* and their result types.
|
||||
*
|
||||
* 2. `import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed"`
|
||||
* — the focused subpath, suitable for Lambda chunk-runner images that
|
||||
* don't pull in the in-process renderer's transitive deps.
|
||||
*
|
||||
* These tests are surface-only: they assert the symbols exist and have the
|
||||
* expected shapes. The functional behaviour is covered by `plan.test.ts` /
|
||||
* `renderChunk.test.ts` / `assemble.test.ts`.
|
||||
*
|
||||
* We import via the workspace-relative `../../distributed.js` /
|
||||
* `../../index.js` paths rather than `"@hyperframes/producer"` because the
|
||||
* package resolver inside the workspace points back at `src/index.ts` —
|
||||
* either form exercises the same surface.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as distributedSubpath from "../../distributed.js";
|
||||
import * as producerIndex from "../../index.js";
|
||||
|
||||
describe("@hyperframes/producer/distributed (subpath)", () => {
|
||||
it("exports the three activity functions", () => {
|
||||
expect(typeof distributedSubpath.plan).toBe("function");
|
||||
expect(typeof distributedSubpath.renderChunk).toBe("function");
|
||||
expect(typeof distributedSubpath.assemble).toBe("function");
|
||||
});
|
||||
|
||||
it("exports the chunking helpers + constants", () => {
|
||||
expect(typeof distributedSubpath.resolveChunkPlan).toBe("function");
|
||||
expect(typeof distributedSubpath.buildChunkSlices).toBe("function");
|
||||
expect(typeof distributedSubpath.measurePlanDirBytes).toBe("function");
|
||||
expect(distributedSubpath.DEFAULT_CHUNK_SIZE).toBe(240);
|
||||
expect(distributedSubpath.DEFAULT_MAX_PARALLEL_CHUNKS).toBe(16);
|
||||
expect(distributedSubpath.PLAN_DIR_SIZE_LIMIT_BYTES).toBe(2 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("exports the non-retryable error codes + classes", () => {
|
||||
expect(distributedSubpath.PLAN_TOO_LARGE).toBe("PLAN_TOO_LARGE");
|
||||
expect(distributedSubpath.DISTRIBUTED_DURATION_OUT_OF_RANGE).toBe(
|
||||
"DISTRIBUTED_DURATION_OUT_OF_RANGE",
|
||||
);
|
||||
expect(distributedSubpath.MAX_DISTRIBUTED_DURATION_SECONDS).toBe(24 * 60 * 60);
|
||||
expect(distributedSubpath.FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED).toBe(
|
||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||
);
|
||||
expect(distributedSubpath.FFMPEG_VERSION_MISMATCH).toBe("FFMPEG_VERSION_MISMATCH");
|
||||
expect(distributedSubpath.PLAN_HASH_MISMATCH).toBe("PLAN_HASH_MISMATCH");
|
||||
|
||||
expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
|
||||
expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
|
||||
expect(typeof distributedSubpath.PlanValidationError).toBe("function");
|
||||
expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
|
||||
});
|
||||
|
||||
it("exports the input-validation helpers", () => {
|
||||
expect(typeof distributedSubpath.rejectUnsupportedDistributedFormat).toBe("function");
|
||||
expect(typeof distributedSubpath.applyRuntimeEnvSnapshot).toBe("function");
|
||||
expect(typeof distributedSubpath.readWebGlVendorInfoFromCanvas).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@hyperframes/producer (main entry)", () => {
|
||||
it("re-exports the three activity functions", () => {
|
||||
expect(typeof producerIndex.plan).toBe("function");
|
||||
expect(typeof producerIndex.renderChunk).toBe("function");
|
||||
expect(typeof producerIndex.assemble).toBe("function");
|
||||
});
|
||||
|
||||
it("preserves the existing in-process exports (executeRenderJob unchanged)", () => {
|
||||
// The distributed primitives must NOT break the in-process surface;
|
||||
// spot-check the load-bearing exports the in-process callers rely on.
|
||||
expect(typeof producerIndex.executeRenderJob).toBe("function");
|
||||
expect(typeof producerIndex.createRenderJob).toBe("function");
|
||||
expect(typeof producerIndex.createCaptureSession).toBe("function");
|
||||
expect(typeof producerIndex.createFileServer).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Regression guard for HF#1731 / HF#1730 — pins the 0-based `framePaths`
|
||||
* key convention on `rebuildExtractedFramesFromPlanDir`.
|
||||
*
|
||||
* Why a separate file from `renderChunk.test.ts`: that file's top-level
|
||||
* `beforeAll` boots a Chrome smoke probe so the byte-identical-retry
|
||||
* assertions can soft-skip on hosts where chrome-headless-shell can't
|
||||
* initialize. The probe is a 5-15s tax on the whole module even when no
|
||||
* Chrome-dependent test runs. This file is pure-filesystem and stays
|
||||
* Chrome-free so the regression check runs in ~10ms on every PR.
|
||||
*
|
||||
* The bug: the consumer of `framePaths` is
|
||||
* `videoFrameExtractor.ts:getFrameAtTime`, which computes
|
||||
* `Math.floor(localTime * fps + 1e-9)` (0-based) and reads
|
||||
* `framePaths.get(frameIndex)`. The pre-fix code in
|
||||
* `rebuildExtractedFramesFromPlanDir` wrote `framePaths.set(i + 1, …)`
|
||||
* instead of `framePaths.set(i, …)`, so `framePaths.get(0)` returned
|
||||
* `undefined` at every `<video>`'s first-paint frame: the vid silently
|
||||
* dropped out of activePayloads, the injector didn't fire, and
|
||||
* BeginFrame screenshotted an empty composition (Y≈22 black flash for
|
||||
* one frame at each first-paint boundary). Symptom only reproduced in
|
||||
* distributed mode — local single-process renders take a different
|
||||
* code path (`videoFrameExtractor.ts:317`) that was already 0-based.
|
||||
*
|
||||
* This test must FAIL against the previous `i + 1` indexing and PASS
|
||||
* against the fix. Don't soften it into a "key set has expected size"
|
||||
* shape — the bug was specifically that `get(0)` returned undefined,
|
||||
* so assert that directly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { rebuildExtractedFramesFromPlanDir } from "./renderChunk.js";
|
||||
import type { PlanVideosJson } from "./shared.js";
|
||||
|
||||
function makeFramesDir(planDir: string, videoId: string, frameNames: string[]): void {
|
||||
const outputDir = join(planDir, "video-frames", videoId);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
for (const name of frameNames) {
|
||||
// Contents don't matter — the function only lists + sorts the dir
|
||||
// and maps names to absolute paths. A single byte keeps the test
|
||||
// fast and stays well clear of any filesystem reservation quirks.
|
||||
writeFileSync(join(outputDir, name), "x", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// The function never reads inside `metadata`, it just forwards it onto
|
||||
// the returned `ExtractedFrames`. A bare cast spares us the full
|
||||
// `VideoMetadata` shape per test.
|
||||
const VIDEO_METADATA_STUB = {} as PlanVideosJson["extracted"][number]["metadata"];
|
||||
|
||||
describe("rebuildExtractedFramesFromPlanDir", () => {
|
||||
it("indexes framePaths 0-based (regression guard for HF#1731)", () => {
|
||||
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-0based-"));
|
||||
try {
|
||||
const videoId = "vid-0";
|
||||
// Zero-padded monotonic names — same shape `extractVideoFramesRange`
|
||||
// produces (`frame_%05d.jpg`).
|
||||
const frameNames = [
|
||||
"frame_00001.jpg",
|
||||
"frame_00002.jpg",
|
||||
"frame_00003.jpg",
|
||||
"frame_00004.jpg",
|
||||
"frame_00005.jpg",
|
||||
];
|
||||
makeFramesDir(planDir, videoId, frameNames);
|
||||
|
||||
const result = rebuildExtractedFramesFromPlanDir(planDir, [
|
||||
{
|
||||
videoId,
|
||||
srcPath: "/does/not/matter.mp4",
|
||||
framePattern: "frame_%05d.jpg",
|
||||
fps: 30,
|
||||
totalFrames: frameNames.length,
|
||||
metadata: VIDEO_METADATA_STUB,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const extracted = result[0]!;
|
||||
expect(extracted.framePaths.size).toBe(frameNames.length);
|
||||
|
||||
// The load-bearing assertion. `getFrameAtTime` calls
|
||||
// `framePaths.get(0)` for every video's first-paint frame
|
||||
// (localTime === 0 → frameIndex === 0). The pre-fix code's `i + 1`
|
||||
// indexing meant this returned `undefined` and the vid silently
|
||||
// dropped from activePayloads — that's HF#1731.
|
||||
const first = extracted.framePaths.get(0);
|
||||
expect(first).toBeDefined();
|
||||
expect(first).toBe(join(planDir, "video-frames", videoId, "frame_00001.jpg"));
|
||||
|
||||
// Last frame at index N-1 (0-based) must resolve; index N must not.
|
||||
// Together with `get(0)` defined this fully pins the 0-based
|
||||
// contract — the pre-fix shape would have `get(N)` defined and
|
||||
// `get(0)` undefined.
|
||||
const last = extracted.framePaths.get(frameNames.length - 1);
|
||||
expect(last).toBe(join(planDir, "video-frames", videoId, "frame_00005.jpg"));
|
||||
expect(extracted.framePaths.get(frameNames.length)).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(planDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves 0-based indexing across multiple videos in the same planDir", () => {
|
||||
// Multi-video shape — the HF#1731 repro was a back-to-back composition
|
||||
// (v1: 0-4s, v2: 4-8s, v3: 8-12s) and EVERY vid's first-paint frame
|
||||
// was PRISTINE black. The function must produce the 0-based contract
|
||||
// for every video in the manifest, not just the first.
|
||||
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-multi-"));
|
||||
try {
|
||||
makeFramesDir(planDir, "vid-a", ["frame_00001.jpg", "frame_00002.jpg"]);
|
||||
makeFramesDir(planDir, "vid-b", ["frame_00001.jpg", "frame_00002.jpg", "frame_00003.jpg"]);
|
||||
|
||||
const result = rebuildExtractedFramesFromPlanDir(planDir, [
|
||||
{
|
||||
videoId: "vid-a",
|
||||
srcPath: "/a.mp4",
|
||||
framePattern: "frame_%05d.jpg",
|
||||
fps: 30,
|
||||
totalFrames: 2,
|
||||
metadata: VIDEO_METADATA_STUB,
|
||||
},
|
||||
{
|
||||
videoId: "vid-b",
|
||||
srcPath: "/b.mp4",
|
||||
framePattern: "frame_%05d.jpg",
|
||||
fps: 30,
|
||||
totalFrames: 3,
|
||||
metadata: VIDEO_METADATA_STUB,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Every video's `framePaths.get(0)` must resolve — pre-fix, all of
|
||||
// them returned undefined and every vid's first-paint dropped.
|
||||
expect(result[0]!.framePaths.get(0)).toBe(
|
||||
join(planDir, "video-frames", "vid-a", "frame_00001.jpg"),
|
||||
);
|
||||
expect(result[1]!.framePaths.get(0)).toBe(
|
||||
join(planDir, "video-frames", "vid-b", "frame_00001.jpg"),
|
||||
);
|
||||
// Cleanup ownership flag — chunk workers don't own the planDir's
|
||||
// video-frames tree (the controller does); a flip would cause the
|
||||
// injector cleanup to rm bytes another worker may still be reading.
|
||||
expect(result[0]!.ownedByLookup).toBe(false);
|
||||
expect(result[1]!.ownedByLookup).toBe(false);
|
||||
} finally {
|
||||
rmSync(planDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
// fallow-ignore-file code-duplication complexity
|
||||
/**
|
||||
* Unit tests for `services/distributed/renderChunk.ts`.
|
||||
*
|
||||
* The byte-identical-retry contract is the load-bearing
|
||||
* test here: rendering the same `(planDir, chunkIndex)` twice must produce
|
||||
* a byte-identical output file. Without this, Temporal/Step-Functions
|
||||
* retries can't safely overwrite a partial chunk — and the entire
|
||||
* fan-out activity has to fall back to "renderer doesn't retry".
|
||||
*
|
||||
* The test:
|
||||
* 1. Spins a fresh planDir via `plan()` (cheap: the fixture sets
|
||||
* `data-duration` so the probe stage never launches a browser).
|
||||
* 2. Renders chunk 0 into two distinct temp paths via `renderChunk()`.
|
||||
* 3. Asserts file bytes match exactly.
|
||||
*
|
||||
* Skipped (with a clear log message) when Chrome-headless-shell isn't
|
||||
* available on the host — CI runs the real check inside `Dockerfile.test`.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { HOST_CHROME_FAILURE_PATTERNS } from "./__test_utils__/hostChromeFailures.js";
|
||||
import { plan } from "./plan.js";
|
||||
import {
|
||||
CHUNK_INDEX_OUT_OF_RANGE,
|
||||
MISSING_PLAN_ARTIFACT,
|
||||
MISSING_RUNTIME_ENV_SNAPSHOT,
|
||||
PLAN_HASH_MISMATCH,
|
||||
renderChunk,
|
||||
RenderChunkValidationError,
|
||||
resolveLockedVp9CpuUsed,
|
||||
resolvePresetForLockedEncoder,
|
||||
} from "./renderChunk.js";
|
||||
|
||||
// Tiny fixture: 5 frames at 30fps. Captures finish in a few seconds on the
|
||||
// CI host, and 5 frames is enough to stress the chunk-boundary IDR + GOP
|
||||
// encoder args without grinding the test suite.
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>renderChunk fixture</title></head>
|
||||
<body style="margin:0;background:#000;color:#fff;font:32px sans-serif">
|
||||
<div data-composition-id="root" data-no-timeline data-width="160" data-height="120" data-duration="0.16667">
|
||||
<p style="padding:1rem">chunk fixture</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
let runRoot: string;
|
||||
let projectDir: string;
|
||||
let planDir: string;
|
||||
let hasChrome = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-renderchunk-test-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
|
||||
// Smoke-test whether chrome-headless-shell on this host can actually
|
||||
// render a frame. Many dev/CI hosts ship a chrome-headless-shell whose
|
||||
// GL stack can't initialize (`gl_factory.cc` errors out on
|
||||
// `--use-gl=swiftshader`), which makes the BeginFrame capture loop
|
||||
// hang for the full protocolTimeout. Detect that up front and soft-skip
|
||||
// the byte-identical test; the Docker harness is where the determinism
|
||||
// contract is exercised.
|
||||
try {
|
||||
const { createCaptureSession, initializeSession, closeCaptureSession } =
|
||||
await import("@hyperframes/engine");
|
||||
const { createFileServer } = await import("../fileServer.js");
|
||||
const smokeDir = join(runRoot, "smoke");
|
||||
mkdirSync(join(smokeDir, "compiled"), { recursive: true });
|
||||
writeFileSync(join(smokeDir, "compiled", "index.html"), FIXTURE_HTML, "utf-8");
|
||||
const fs = await createFileServer({
|
||||
projectDir: join(smokeDir, "compiled"),
|
||||
compiledDir: join(smokeDir, "compiled"),
|
||||
port: 0,
|
||||
});
|
||||
try {
|
||||
const framesDir = join(smokeDir, "frames");
|
||||
mkdirSync(framesDir, { recursive: true });
|
||||
const session = await createCaptureSession(
|
||||
fs.url,
|
||||
framesDir,
|
||||
{
|
||||
width: 160,
|
||||
height: 120,
|
||||
fps: { num: 30, den: 1 },
|
||||
format: "jpeg",
|
||||
quality: 80,
|
||||
},
|
||||
null,
|
||||
{ browserGpuMode: "software" } as Parameters<typeof createCaptureSession>[4],
|
||||
);
|
||||
try {
|
||||
// Wrap initializeSession in a short timeout — beginFrame failures
|
||||
// hang for protocolTimeout (5 min) by default.
|
||||
const initPromise = initializeSession(session);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("smoke init timed out")), 15_000),
|
||||
);
|
||||
await Promise.race([initPromise, timeoutPromise]);
|
||||
hasChrome = true;
|
||||
} finally {
|
||||
await closeCaptureSession(session).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
fs.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[renderChunk.test] chrome-headless-shell smoke test failed — byte-identical retry test will soft-skip. ",
|
||||
"Diagnostic:",
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, 240),
|
||||
);
|
||||
hasChrome = false;
|
||||
}
|
||||
|
||||
if (!hasChrome) return;
|
||||
|
||||
// Plan once for all chunk tests. The planDir is sufficiently small that
|
||||
// re-creating it per test would just slow things down.
|
||||
//
|
||||
// Format is `png-sequence` (not `mp4`) so the chunk capture path runs in
|
||||
// screenshot mode rather than BeginFrame. Most chrome-headless-shell
|
||||
// builds (including the one this dev box ships) can render in
|
||||
// screenshot mode without GL initialization, while BeginFrame requires
|
||||
// a working SwiftShader + Vulkan stack that some hosts lack. The
|
||||
// byte-identical-retry contract is the same in either capture
|
||||
// mode, so the test still pins the determinism axis — and the Docker
|
||||
// harness exercises both modes against a full chrome-headless-shell
|
||||
// build inside `Dockerfile.test`.
|
||||
planDir = join(runRoot, "plan");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
await plan(projectDir, { fps: 30, width: 160, height: 120, format: "png-sequence" }, planDir);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("renderChunk()", () => {
|
||||
// 60s ceiling absorbs Chrome cold-start + 5-frame capture + ffmpeg encode
|
||||
// on slower CI workers.
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
it(
|
||||
"produces a byte-identical chunk on a second invocation (byte-identical-retry contract)",
|
||||
async () => {
|
||||
if (!hasChrome) {
|
||||
// Soft skip — Docker harness covers the real assertion.
|
||||
console.warn(
|
||||
"[renderChunk.test] skipping byte-identical retry test — chrome-headless-shell not available on this host",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// chrome-headless-shell on some hosts cannot navigate to `chrome://gpu`
|
||||
// (the URL returns an empty HTML document, and Puppeteer surfaces the
|
||||
// network probe as `net::ERR_FAILED`). The byte-identical assertion
|
||||
// needs a real renderChunk pass, which requires `assertSwiftShader`
|
||||
// to succeed. On hosts where that probe is unsupported, we soft-skip
|
||||
// and rely on the Docker harness — the same code path is exercised
|
||||
// there against an image where chrome://gpu works.
|
||||
const outA = join(runRoot, "chunk-a");
|
||||
const outB = join(runRoot, "chunk-b");
|
||||
let a, b;
|
||||
try {
|
||||
a = await renderChunk(planDir, 0, outA);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
"[renderChunk.test] skipping byte-identical retry test — host Chrome stack can't render. ",
|
||||
"Docker harness covers the determinism contract. Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
b = await renderChunk(planDir, 0, outB);
|
||||
|
||||
// png-sequence chunks produce a directory of frames, not a single file.
|
||||
expect(a.outputKind).toBe("frame-dir");
|
||||
expect(b.outputKind).toBe("frame-dir");
|
||||
expect(a.framesEncoded).toBeGreaterThan(0);
|
||||
expect(b.framesEncoded).toBe(a.framesEncoded);
|
||||
|
||||
// The sha256 fingerprint must match (byte-identical-retry contract).
|
||||
// For frame-dir output the fingerprint hashes the sorted list of
|
||||
// `(name, sha256)` pairs, so two byte-identical chunks have the same
|
||||
// fingerprint without us having to compare each PNG separately.
|
||||
expect(a.sha256).toBe(b.sha256);
|
||||
|
||||
// Stage perf split: the timers must be populated and bounded by the
|
||||
// chunk's total wall time (they partition `durationMs` alongside
|
||||
// validation/file-server/hash overhead).
|
||||
expect(a.planHashMs).toBeGreaterThanOrEqual(0);
|
||||
expect(a.sessionBootMs).toBeGreaterThanOrEqual(0);
|
||||
expect(a.captureStageMs).toBeGreaterThan(0);
|
||||
expect(a.encodeStageMs).toBeGreaterThan(0);
|
||||
expect(a.workers).toBeGreaterThanOrEqual(1);
|
||||
expect(a.captureStageMs + a.encodeStageMs).toBeLessThanOrEqual(a.durationMs);
|
||||
const perf = JSON.parse(readFileSync(a.perfPath, "utf-8"));
|
||||
for (const key of [
|
||||
"planHashMs",
|
||||
"sessionBootMs",
|
||||
"captureStageMs",
|
||||
"encodeStageMs",
|
||||
"workers",
|
||||
]) {
|
||||
expect(typeof perf[key]).toBe("number");
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"rejects an out-of-range chunkIndex with CHUNK_INDEX_OUT_OF_RANGE",
|
||||
async () => {
|
||||
if (!hasChrome) return;
|
||||
const out = join(runRoot, "chunk-oob");
|
||||
let caught: unknown;
|
||||
try {
|
||||
// OOB validation runs BEFORE Chrome init, so this works regardless
|
||||
// of chrome://gpu support on the host.
|
||||
await renderChunk(planDir, 999, out);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RenderChunkValidationError);
|
||||
expect((caught as RenderChunkValidationError).code).toBe(CHUNK_INDEX_OUT_OF_RANGE);
|
||||
expect((caught as Error).message).toContain("out of range");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"rejects a planDir missing plan.json with MISSING_PLAN_ARTIFACT",
|
||||
async () => {
|
||||
const emptyDir = join(runRoot, "empty-plan-dir");
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
const out = join(runRoot, "chunk-empty");
|
||||
let caught: unknown;
|
||||
try {
|
||||
await renderChunk(emptyDir, 0, out);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RenderChunkValidationError);
|
||||
expect((caught as RenderChunkValidationError).code).toBe(MISSING_PLAN_ARTIFACT);
|
||||
expect((caught as Error).message).toContain("planDir is missing");
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"rejects a planDir with a tampered planHash (PLAN_HASH_MISMATCH)",
|
||||
async () => {
|
||||
if (!hasChrome) return;
|
||||
// Clone the existing valid planDir, then corrupt its planHash by
|
||||
// editing plan.json so the on-disk fingerprint no longer matches
|
||||
// the stored value.
|
||||
const corruptedDir = mkdtempSync(join(runRoot, "plan-corrupted-"));
|
||||
const { cpSync } = await import("node:fs");
|
||||
cpSync(planDir, corruptedDir, { recursive: true });
|
||||
const planJsonPath = join(corruptedDir, "plan.json");
|
||||
const planJson = JSON.parse(readFileSync(planJsonPath, "utf-8")) as Record<string, unknown>;
|
||||
planJson.planHash = "0".repeat(64);
|
||||
writeFileSync(planJsonPath, JSON.stringify(planJson, null, 2), "utf-8");
|
||||
|
||||
const out = join(runRoot, "chunk-tampered");
|
||||
let caught: unknown;
|
||||
try {
|
||||
await renderChunk(corruptedDir, 0, out);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RenderChunkValidationError);
|
||||
expect((caught as RenderChunkValidationError).code).toBe(PLAN_HASH_MISMATCH);
|
||||
expect((caught as Error).message).toMatch(/fingerprint|planHash/i);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"rejects a planDir whose encoder.json is missing runtimeEnv",
|
||||
async () => {
|
||||
if (!hasChrome) return;
|
||||
const noEnvDir = mkdtempSync(join(runRoot, "plan-no-env-"));
|
||||
const { cpSync } = await import("node:fs");
|
||||
cpSync(planDir, noEnvDir, { recursive: true });
|
||||
const encoderJsonPath = join(noEnvDir, "meta", "encoder.json");
|
||||
const encoder = JSON.parse(readFileSync(encoderJsonPath, "utf-8")) as Record<string, unknown>;
|
||||
delete encoder.runtimeEnv;
|
||||
writeFileSync(encoderJsonPath, JSON.stringify(encoder), "utf-8");
|
||||
|
||||
const out = join(runRoot, "chunk-no-env");
|
||||
let caught: unknown;
|
||||
try {
|
||||
await renderChunk(noEnvDir, 0, out);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RenderChunkValidationError);
|
||||
// Tampering the encoder.json also breaks planHash, so either code
|
||||
// is acceptable — both indicate a planDir the worker correctly
|
||||
// refused to render. The missing-runtimeEnv path fires when the
|
||||
// encoder JSON happens to remain hash-valid (e.g. controller bug),
|
||||
// the hash-mismatch path fires when it doesn't.
|
||||
const code = (caught as RenderChunkValidationError).code;
|
||||
expect([MISSING_RUNTIME_ENV_SNAPSHOT, PLAN_HASH_MISMATCH]).toContain(code);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("renderChunk() — variables threading", () => {
|
||||
// 60s ceiling absorbs Chrome cold-start + 5-frame capture + ffmpeg encode
|
||||
// on slower CI workers.
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
// Fixture whose pixels depend on `window.__hfVariables.color`. Read the
|
||||
// variables on `DOMContentLoaded` and write the color onto a fullscreen
|
||||
// element. Two plans with different `variables.color` MUST produce
|
||||
// different chunk fingerprints — proves the controller's snapshotted
|
||||
// variables reach the chunk worker's page.
|
||||
const VARIABLES_FIXTURE_HTML = `<!doctype html>
|
||||
<html data-composition-variables='{"color":"string"}'>
|
||||
<head><meta charset="utf-8"><title>renderChunk variables fixture</title></head>
|
||||
<body style="margin:0">
|
||||
<div data-composition-id="root" data-no-timeline data-width="160" data-height="120" data-duration="0.16667">
|
||||
<div id="paint" style="width:160px;height:120px;background:#000"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var v = (window.__hfVariables && window.__hfVariables.color) || "#000";
|
||||
var el = document.getElementById("paint");
|
||||
if (el) el.style.background = v;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
it(
|
||||
"chunks rendered with different variables produce different output fingerprints",
|
||||
async () => {
|
||||
if (!hasChrome) {
|
||||
// Soft skip — Docker harness covers the real assertion.
|
||||
console.warn(
|
||||
"[renderChunk.test] skipping variables-threading test — chrome-headless-shell not available on this host",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const variablesProjectDir = join(runRoot, "project-variables");
|
||||
mkdirSync(variablesProjectDir, { recursive: true });
|
||||
writeFileSync(join(variablesProjectDir, "index.html"), VARIABLES_FIXTURE_HTML, "utf-8");
|
||||
|
||||
const planDirRed = join(runRoot, "plan-variables-red");
|
||||
const planDirBlue = join(runRoot, "plan-variables-blue");
|
||||
mkdirSync(planDirRed, { recursive: true });
|
||||
mkdirSync(planDirBlue, { recursive: true });
|
||||
|
||||
const baseConfig = {
|
||||
fps: 30 as const,
|
||||
width: 160,
|
||||
height: 120,
|
||||
format: "png-sequence" as const,
|
||||
};
|
||||
await plan(
|
||||
variablesProjectDir,
|
||||
{ ...baseConfig, variables: { color: "#ff0000" } },
|
||||
planDirRed,
|
||||
);
|
||||
await plan(
|
||||
variablesProjectDir,
|
||||
{ ...baseConfig, variables: { color: "#0000ff" } },
|
||||
planDirBlue,
|
||||
);
|
||||
|
||||
const outRed = join(runRoot, "chunk-variables-red");
|
||||
const outBlue = join(runRoot, "chunk-variables-blue");
|
||||
|
||||
let red, blue;
|
||||
try {
|
||||
red = await renderChunk(planDirRed, 0, outRed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
|
||||
console.warn(
|
||||
"[renderChunk.test] skipping variables-threading test — host Chrome stack can't render. ",
|
||||
"Docker harness covers the contract. Diagnostic:",
|
||||
message.slice(0, 240),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
blue = await renderChunk(planDirBlue, 0, outBlue);
|
||||
|
||||
expect(red.outputKind).toBe("frame-dir");
|
||||
expect(blue.outputKind).toBe("frame-dir");
|
||||
// Different variables.color → different rendered pixels → different
|
||||
// fingerprint. The byte-identical-retry contract from the dedicated
|
||||
// test above is what gives this assertion teeth: if variables
|
||||
// weren't actually reaching the page, both chunks would hash the
|
||||
// same #000 fallback.
|
||||
expect(red.sha256).not.toBe(blue.sha256);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolvePresetForLockedEncoder", () => {
|
||||
// Tiny fast tests for the codec-override helper. No Chrome, no ffmpeg —
|
||||
// exists so a refactor that moves the override (e.g. into
|
||||
// `getEncoderPreset` itself) gets caught here before the heavyweight
|
||||
// Docker fixture is even run.
|
||||
it("flips codec from h264 to h265 when encoder is libx265-software", () => {
|
||||
const base = { preset: "medium", quality: 18, codec: "h264" as const, pixelFormat: "yuv420p" };
|
||||
const out = resolvePresetForLockedEncoder(base, "libx265-software");
|
||||
expect(out.codec).toBe("h265");
|
||||
expect(out.preset).toBe("medium");
|
||||
expect(out.quality).toBe(18);
|
||||
expect(out.pixelFormat).toBe("yuv420p");
|
||||
});
|
||||
|
||||
it("leaves the preset unchanged for libx264-software", () => {
|
||||
const base = { preset: "medium", quality: 18, codec: "h264" as const, pixelFormat: "yuv420p" };
|
||||
const out = resolvePresetForLockedEncoder(base, "libx264-software");
|
||||
expect(out).toBe(base);
|
||||
});
|
||||
|
||||
it("leaves the preset unchanged for prores-software", () => {
|
||||
const base = {
|
||||
preset: "4444",
|
||||
quality: 18,
|
||||
codec: "prores" as const,
|
||||
pixelFormat: "yuva444p10le",
|
||||
};
|
||||
const out = resolvePresetForLockedEncoder(base, "prores-software");
|
||||
expect(out).toBe(base);
|
||||
});
|
||||
|
||||
it("leaves the preset unchanged for png-sequence", () => {
|
||||
const base = { preset: "medium", quality: 18, codec: "h264" as const, pixelFormat: "yuv420p" };
|
||||
const out = resolvePresetForLockedEncoder(base, "png-sequence");
|
||||
expect(out).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLockedVp9CpuUsed", () => {
|
||||
it("uses the locked value for new VP9 planDirs", () => {
|
||||
expect(resolveLockedVp9CpuUsed({ encoder: "libvpx-vp9-software", vp9CpuUsed: 4 })).toBe(4);
|
||||
});
|
||||
|
||||
it("preserves legacy distributed VP9 replay behavior when the field is absent", () => {
|
||||
expect(resolveLockedVp9CpuUsed({ encoder: "libvpx-vp9-software" })).toBe(2);
|
||||
});
|
||||
|
||||
it("returns undefined for non-VP9 planDirs", () => {
|
||||
expect(resolveLockedVp9CpuUsed({ encoder: "libx264-software" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,755 @@
|
||||
/**
|
||||
* Activity B of the distributed render pipeline.
|
||||
*
|
||||
* `renderChunk(planDir, chunkIndex, outputChunkPath)` validates the planDir
|
||||
* against the worker's environment, captures the chunk's frame range, and
|
||||
* encodes a single closed-GOP video chunk (or, for png-sequence, a directory
|
||||
* of PNGs). The output is byte-identical across retries on the same worker
|
||||
* and PSNR-equivalent across workers — that contract is what makes Temporal
|
||||
* activity retries safe.
|
||||
*
|
||||
* Pure function over local paths. No networking. Spins up its own headless
|
||||
* Chrome + file server scoped to the chunk; tears them down before
|
||||
* returning. The caller is responsible for moving `outputChunkPath` to its
|
||||
* orchestration-level storage (S3 / GCS / EFS / …).
|
||||
*
|
||||
* Hard contracts:
|
||||
* - The worker re-applies `meta/encoder.json.runtimeEnv` into
|
||||
* `process.env` BEFORE the file server starts so the served HTML's
|
||||
* `RENDER_MODE_SCRIPT` sees the same env it would have seen on the
|
||||
* controller.
|
||||
* - Browser is launched with `browserGpuMode: "software"` and verified
|
||||
* against `chrome://gpu` via `assertSwiftShader` — a non-SwiftShader
|
||||
* backend trips a non-retryable `BROWSER_GPU_NOT_SOFTWARE`.
|
||||
* - The file server serves with the seeded-random shim
|
||||
* (`buildVirtualTimeShim({ seedRandomFromFrame: true })`) so any
|
||||
* composition that uses `Math.random` / `crypto.getRandomValues`
|
||||
* produces byte-identical pixels per `(planDir, chunkIndex)`.
|
||||
* - No `lastFrameCache` priming: every frame seeks fresh DOM so the
|
||||
* cache is never read, and priming would deadlock the compositor.
|
||||
* - The chunk's encode runs with `lockGopForChunkConcat: true` and
|
||||
* `gopSize === framesInChunk` so concat-copy at assemble time is safe.
|
||||
*
|
||||
* Every determinism toggle above is opt-in — only this primitive enables them.
|
||||
* In-process renders (`executeRenderJob`) leave them off.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import {
|
||||
assertSwiftShader,
|
||||
type BeforeCaptureHook,
|
||||
BROWSER_GPU_NOT_SOFTWARE,
|
||||
calculateOptimalWorkers,
|
||||
type CaptureOptions,
|
||||
type CaptureSession,
|
||||
closeCaptureSession,
|
||||
createCaptureSession,
|
||||
createFrameLookupTable,
|
||||
createVideoFrameInjector,
|
||||
type EngineConfig,
|
||||
type ExtractedFrames,
|
||||
getEncoderPreset,
|
||||
initializeSession,
|
||||
readWebGlVendorInfoFromCanvas,
|
||||
resolveConfig,
|
||||
} from "@hyperframes/engine";
|
||||
import { defaultLogger } from "../../logger.js";
|
||||
import { runEncodeStage } from "../render/stages/encodeStage.js";
|
||||
import { runCaptureStage } from "../render/stages/captureStage.js";
|
||||
import { resolveVideoCaptureBeyondViewport } from "../render/captureBeyondViewport.js";
|
||||
import {
|
||||
type ChunkSliceJson,
|
||||
type LockedRenderConfig,
|
||||
recomputePlanHashFromPlanDir,
|
||||
} from "../render/stages/freezePlan.js";
|
||||
import { sha256Hex } from "../render/stages/planHash.js";
|
||||
import { applyRuntimeEnvSnapshot } from "../render/runtimeEnvSnapshot.js";
|
||||
import {
|
||||
buildVirtualTimeShim,
|
||||
closeFileServerSafely,
|
||||
createFileServer,
|
||||
type FileServerHandle,
|
||||
} from "../fileServer.js";
|
||||
import {
|
||||
buildSyntheticRenderJob,
|
||||
type DistributedFormat,
|
||||
PLAN_VIDEOS_META_RELATIVE_PATH,
|
||||
type PlanVideosJson,
|
||||
readFfmpegVersion,
|
||||
} from "./shared.js";
|
||||
|
||||
/**
|
||||
* Non-retryable error codes raised when the planDir is structurally
|
||||
* malformed, semantically out of range, or fingerprints differently from
|
||||
* what the controller wrote. Each is distinct so adapter retry policies
|
||||
* can route them independently — e.g. `MISSING_PLAN_ARTIFACT` may point
|
||||
* to a partial S3 download that a retry could heal, while
|
||||
* `PLAN_HASH_MISMATCH` strictly indicates cross-version drift that
|
||||
* retries won't fix.
|
||||
*/
|
||||
export const FFMPEG_VERSION_MISMATCH = "FFMPEG_VERSION_MISMATCH";
|
||||
export const PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH";
|
||||
export const MISSING_PLAN_ARTIFACT = "MISSING_PLAN_ARTIFACT";
|
||||
export const CHUNK_INDEX_OUT_OF_RANGE = "CHUNK_INDEX_OUT_OF_RANGE";
|
||||
export const MISSING_RUNTIME_ENV_SNAPSHOT = "MISSING_RUNTIME_ENV_SNAPSHOT";
|
||||
const LEGACY_DISTRIBUTED_VP9_CPU_USED = 2;
|
||||
|
||||
export type RenderChunkValidationCode =
|
||||
| typeof FFMPEG_VERSION_MISMATCH
|
||||
| typeof PLAN_HASH_MISMATCH
|
||||
| typeof MISSING_PLAN_ARTIFACT
|
||||
| typeof CHUNK_INDEX_OUT_OF_RANGE
|
||||
| typeof MISSING_RUNTIME_ENV_SNAPSHOT
|
||||
| typeof BROWSER_GPU_NOT_SOFTWARE;
|
||||
|
||||
/**
|
||||
* Typed non-retryable error raised by `renderChunk` when the planDir is
|
||||
* malformed or the worker's runtime doesn't match the planDir's
|
||||
* controller-side fingerprint. Workflow adapters key retry policies off
|
||||
* `code` — most of these failures will not heal on retry.
|
||||
*/
|
||||
export class RenderChunkValidationError extends Error {
|
||||
readonly code: RenderChunkValidationCode;
|
||||
constructor(code: RenderChunkValidationCode, message: string) {
|
||||
super(message);
|
||||
this.name = "RenderChunkValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link renderChunk}. The `sha256` field is the byte hash of the
|
||||
* primary output (the mp4/mov file, or, for png-sequence, the sorted-frame
|
||||
* fingerprint). Retries on the same `(planDir, chunkIndex)` MUST produce
|
||||
* the same `sha256` — that contract is the byte-identical-retry axis.
|
||||
*/
|
||||
export interface ChunkResult {
|
||||
/** Absolute path the encoded chunk was written to (file or directory). */
|
||||
outputPath: string;
|
||||
/** `"file"` for mp4/mov; `"frame-dir"` for png-sequence. */
|
||||
outputKind: "file" | "frame-dir";
|
||||
framesEncoded: number;
|
||||
sha256: string;
|
||||
durationMs: number;
|
||||
/**
|
||||
* Stage wall-clock split of `durationMs`, for separating per-chunk fixed
|
||||
* overhead from frame-proportional work in fleet cost models:
|
||||
*
|
||||
* - `planHashMs` — full planDir content-hash recomputation (validation).
|
||||
* - `sessionBootMs` — sequential-branch Chrome boot + SwiftShader assert +
|
||||
* composition warmup. Stays 0 when `workers > 1` (each parallel worker
|
||||
* boots inside the capture stage instead).
|
||||
* - `captureStageMs` — the capture stage call; includes per-worker session
|
||||
* boots in the parallel branch.
|
||||
* - `encodeStageMs` — the encode stage call (single ffmpeg invocation, or
|
||||
* the frame-dir arrangement for png-sequence).
|
||||
*
|
||||
* The remainder of `durationMs` is validation + file-server setup + output
|
||||
* hashing + cleanup.
|
||||
*/
|
||||
planHashMs: number;
|
||||
sessionBootMs: number;
|
||||
captureStageMs: number;
|
||||
encodeStageMs: number;
|
||||
/** Capture workers used for this chunk (`calculateOptimalWorkers` result). */
|
||||
workers: number;
|
||||
/**
|
||||
* Path to a sidecar JSON containing per-chunk perf counters. Adapters
|
||||
* upload this alongside the chunk so per-chunk regressions are
|
||||
* inspectable without the workflow having to carry the payload.
|
||||
*/
|
||||
perfPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the engine's in-memory `ExtractedFrames[]` from the on-disk
|
||||
* planDir layout. `<planDir>/video-frames/<videoId>/` holds the numbered
|
||||
* frame files plan() extracted; this lists each dir and rebuilds the
|
||||
* 0-based `framePaths` Map that `FrameLookupTable` / `videoFrameInjector`
|
||||
* both index against — the consumer is
|
||||
* `videoFrameExtractor.ts:getFrameAtTime`, which floors `localTime * fps`
|
||||
* to a 0-based index and reads `framePaths.get(frameIndex)`. Any drift
|
||||
* from that key convention silently drops every `<video>`'s first-paint
|
||||
* frame; see HF#1731 / HF#1730.
|
||||
*
|
||||
* Exported so a unit test can pin the 0-based contract without spinning
|
||||
* up the heavyweight Docker fixture — the bug surfaces only under
|
||||
* distributed mode and only at video first-paint, so this primitive is
|
||||
* the right granularity to guard.
|
||||
*/
|
||||
export function rebuildExtractedFramesFromPlanDir(
|
||||
planDir: string,
|
||||
videos: PlanVideosJson["extracted"],
|
||||
): ExtractedFrames[] {
|
||||
const result: ExtractedFrames[] = [];
|
||||
for (const v of videos) {
|
||||
const outputDir = join(planDir, "video-frames", v.videoId);
|
||||
if (!existsSync(outputDir)) {
|
||||
throw new Error(
|
||||
`[renderChunk] planDir missing extracted video frames for ${JSON.stringify(v.videoId)}: ` +
|
||||
`${outputDir} not present. plan() should have written frames here; the planDir is malformed.`,
|
||||
);
|
||||
}
|
||||
// framePattern looks like `frame_%05d.jpg`; sprintf isn't available at
|
||||
// runtime so list-and-sort the directory. Sorted-by-name matches
|
||||
// sorted-by-frame-index because the extractor writes zero-padded
|
||||
// monotonic indices.
|
||||
const ext = (extname(v.framePattern) || ".jpg").toLowerCase();
|
||||
const frames = readdirSync(outputDir)
|
||||
.filter((name) => name.toLowerCase().endsWith(ext))
|
||||
.sort();
|
||||
const framePaths = new Map<number, string>();
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const frameName = frames[i];
|
||||
if (!frameName) continue;
|
||||
framePaths.set(i, join(outputDir, frameName));
|
||||
}
|
||||
result.push({
|
||||
videoId: v.videoId,
|
||||
srcPath: v.srcPath,
|
||||
outputDir,
|
||||
framePattern: v.framePattern,
|
||||
fps: v.fps,
|
||||
totalFrames: v.totalFrames,
|
||||
metadata: v.metadata,
|
||||
framePaths,
|
||||
// The chunk worker doesn't own the planDir's video-frames/ directory
|
||||
// (the controller does — adapters that fan out chunks across machines
|
||||
// share the planDir as read-only). Mark ownership as false so the
|
||||
// injector's eventual cleanup doesn't rm bytes another worker may
|
||||
// still be reading.
|
||||
ownedByLookup: false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Plan-time JSON manifest written by `freezePlan`. */
|
||||
interface PlanJson {
|
||||
planHash: string;
|
||||
producerVersion: string;
|
||||
ffmpegVersion: string;
|
||||
fontSnapshotSha: string;
|
||||
dimensions: {
|
||||
fpsNum: number;
|
||||
fpsDen: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: DistributedFormat;
|
||||
};
|
||||
chunkCount: number;
|
||||
totalFrames: number;
|
||||
duration: number;
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export the runtime-env apply helper so adapters that import only
|
||||
* this subpath can prime `process.env` before instantiating their own
|
||||
* file server. Returns a `{ restore }` handle — adapters that fan out
|
||||
* multiple chunks per process MUST call `restore()` between chunks.
|
||||
*/
|
||||
export { applyRuntimeEnvSnapshot } from "../render/runtimeEnvSnapshot.js";
|
||||
|
||||
// `readWebGlVendorInfoFromCanvas` lives in `@hyperframes/engine` (it's
|
||||
// used both here and by `parallelCoordinator.executeWorkerTask`). Re-exported
|
||||
// from this subpath so downstream consumers that already import it from
|
||||
// `@hyperframes/producer/distributed` keep working.
|
||||
export { readWebGlVendorInfoFromCanvas } from "@hyperframes/engine";
|
||||
|
||||
/**
|
||||
* Compute a deterministic SHA-256 fingerprint for the chunk's output.
|
||||
*
|
||||
* - file output (mp4/mov): straight hash of the file bytes.
|
||||
* - frame-dir (png-sequence): hash the sorted list of `(name, sha256)`
|
||||
* pairs. Avoids the cost of streaming every frame's contents through
|
||||
* a single sha context while still detecting any byte-level drift in
|
||||
* any individual frame.
|
||||
*
|
||||
* The fingerprint flows into the `ChunkResult.sha256` which adapters
|
||||
* compare across retries to enforce the byte-identical-retry contract.
|
||||
*/
|
||||
function hashChunkOutput(outputPath: string, kind: "file" | "frame-dir"): string {
|
||||
if (kind === "file") return sha256Hex(readFileSync(outputPath));
|
||||
const entries = readdirSync(outputPath)
|
||||
.filter((name) => /\.(png|jpg|jpeg)$/i.test(name))
|
||||
.sort();
|
||||
// Hash the sorted (name, perFileSha) list. Encoded as null-separated
|
||||
// utf-8 to keep concatenation unambiguous if a frame name ever contains
|
||||
// an unusual character.
|
||||
const lines = entries.map(
|
||||
(name) => `${name}\0${sha256Hex(readFileSync(join(outputPath, name)))}`,
|
||||
);
|
||||
return sha256Hex(lines.join("\0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the planDir's locked-encoder choice on top of an
|
||||
* `EncoderPreset` from `getEncoderPreset`. `getEncoderPreset` returns
|
||||
* h265 only on the HDR branch, but distributed mode is SDR-only — for
|
||||
* an `libx265-software` planDir we still need to flip the preset's
|
||||
* codec to h265 so `runEncodeStage` invokes libx265. Exported so a
|
||||
* unit test can pin the override independently of the heavyweight
|
||||
* Docker fixture: a refactor that moves the override (e.g. into
|
||||
* `getEncoderPreset` itself) shouldn't be able to silently regress
|
||||
* the contract without a fast-test signal.
|
||||
*/
|
||||
export function resolvePresetForLockedEncoder<
|
||||
P extends { codec: "h264" | "h265" | "vp9" | "prores" },
|
||||
>(basePreset: P, lockedEncoder: LockedRenderConfig["encoder"]): P {
|
||||
if (lockedEncoder === "libx265-software") {
|
||||
return { ...basePreset, codec: "h265" as const };
|
||||
}
|
||||
return basePreset;
|
||||
}
|
||||
|
||||
export function resolveLockedVp9CpuUsed(
|
||||
lockedEncoder: Pick<LockedRenderConfig, "encoder" | "vp9CpuUsed">,
|
||||
): number | undefined {
|
||||
if (lockedEncoder.encoder !== "libvpx-vp9-software") return undefined;
|
||||
// Pre-vp9CpuUsed WebM planDirs used the old closed-GOP literal. Keep replay
|
||||
// bytes stable for those plans while new planDirs carry their resolved value.
|
||||
return lockedEncoder.vp9CpuUsed ?? LEGACY_DISTRIBUTED_VP9_CPU_USED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity B: render a single chunk of the planDir. The `outputChunkPath`
|
||||
* argument is a file for mp4/mov outputs and a directory for png-sequence
|
||||
* outputs — the caller picks the right shape based on `meta/encoder.json`.
|
||||
* `renderChunk` enforces the same choice via `outputKind` on the result.
|
||||
*/
|
||||
export async function renderChunk(
|
||||
planDir: string,
|
||||
chunkIndex: number,
|
||||
outputChunkPath: string,
|
||||
): Promise<ChunkResult> {
|
||||
const start = Date.now();
|
||||
const log = defaultLogger;
|
||||
|
||||
// ── Read + validate the plan ──
|
||||
const planJsonPath = join(planDir, "plan.json");
|
||||
const encoderJsonPath = join(planDir, "meta", "encoder.json");
|
||||
const chunksJsonPath = join(planDir, "meta", "chunks.json");
|
||||
for (const required of [planJsonPath, encoderJsonPath, chunksJsonPath]) {
|
||||
if (!existsSync(required)) {
|
||||
throw new RenderChunkValidationError(
|
||||
MISSING_PLAN_ARTIFACT,
|
||||
`[renderChunk] planDir is missing required artifact: ${required}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJson;
|
||||
const encoder = JSON.parse(readFileSync(encoderJsonPath, "utf-8")) as LockedRenderConfig;
|
||||
const chunks = JSON.parse(readFileSync(chunksJsonPath, "utf-8")) as ChunkSliceJson[];
|
||||
|
||||
// `meta/videos.json` only exists when the composition has `<video>`
|
||||
// elements; absence means no injector is needed.
|
||||
const videosJsonPath = join(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
|
||||
let planVideos: PlanVideosJson | null = null;
|
||||
if (existsSync(videosJsonPath)) {
|
||||
try {
|
||||
planVideos = JSON.parse(readFileSync(videosJsonPath, "utf-8")) as PlanVideosJson;
|
||||
} catch (err) {
|
||||
throw new RenderChunkValidationError(
|
||||
MISSING_PLAN_ARTIFACT,
|
||||
`[renderChunk] failed to parse ${videosJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunkIndex < 0 || chunkIndex >= chunks.length) {
|
||||
throw new RenderChunkValidationError(
|
||||
CHUNK_INDEX_OUT_OF_RANGE,
|
||||
`[renderChunk] chunkIndex ${chunkIndex} is out of range [0, ${chunks.length})`,
|
||||
);
|
||||
}
|
||||
// The bounds check above guarantees this hits, but TS doesn't narrow
|
||||
// the indexed access — re-check explicitly.
|
||||
const slice = chunks[chunkIndex];
|
||||
if (slice === undefined) {
|
||||
throw new RenderChunkValidationError(CHUNK_INDEX_OUT_OF_RANGE, "[renderChunk] missing slice");
|
||||
}
|
||||
const framesInChunk = slice.endFrame - slice.startFrame;
|
||||
if (framesInChunk <= 0) {
|
||||
throw new RenderChunkValidationError(
|
||||
CHUNK_INDEX_OUT_OF_RANGE,
|
||||
`[renderChunk] chunk ${chunkIndex} has non-positive frame count: ${framesInChunk}`,
|
||||
);
|
||||
}
|
||||
|
||||
const compiledDir = join(planDir, "compiled");
|
||||
if (!existsSync(compiledDir)) {
|
||||
throw new RenderChunkValidationError(
|
||||
MISSING_PLAN_ARTIFACT,
|
||||
`[renderChunk] planDir missing compiled/ directory: ${compiledDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cross-version sanity ──
|
||||
const ffmpegVersion = await readFfmpegVersion();
|
||||
if (ffmpegVersion !== plan.ffmpegVersion) {
|
||||
throw new RenderChunkValidationError(
|
||||
FFMPEG_VERSION_MISMATCH,
|
||||
`[renderChunk] ffmpeg version on this worker does not match planDir. ` +
|
||||
`planDir: ${JSON.stringify(plan.ffmpegVersion)}; worker: ${JSON.stringify(ffmpegVersion)}. ` +
|
||||
`Distributed retries require byte-identical ffmpeg builds across workers. ` +
|
||||
`Re-plan from a worker matching this version, or run all renders on an image with the planDir's ffmpeg.`,
|
||||
);
|
||||
}
|
||||
if (encoder.browserGpuMode !== "software") {
|
||||
throw new RenderChunkValidationError(
|
||||
BROWSER_GPU_NOT_SOFTWARE,
|
||||
`[renderChunk] planDir requires browserGpuMode=software, got ${JSON.stringify(encoder.browserGpuMode)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Re-derive `planHash` from the on-disk bytes and compare to the value
|
||||
// the controller wrote into `plan.json`. Catches corrupted artifacts
|
||||
// (truncated meta files, partial S3 downloads, manual tampering) before
|
||||
// the chunk renders. Distinct from the other validation paths above
|
||||
// because `MISSING_PLAN_ARTIFACT` etc. are structural; this is purely
|
||||
// content-fingerprint drift.
|
||||
const planHashStarted = Date.now();
|
||||
const recomputedPlanHash = recomputePlanHashFromPlanDir(planDir);
|
||||
const planHashMs = Date.now() - planHashStarted;
|
||||
if (recomputedPlanHash !== plan.planHash) {
|
||||
throw new RenderChunkValidationError(
|
||||
PLAN_HASH_MISMATCH,
|
||||
`[renderChunk] planDir content fingerprint does not match plan.json.planHash. ` +
|
||||
`plan.json: ${plan.planHash}; recomputed: ${recomputedPlanHash}. ` +
|
||||
`Likely a corrupted artifact (partial S3 download, manual tampering) or a planDir ` +
|
||||
`produced by an incompatible producer version. Re-plan and re-fan-out.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Distinct from the silent `?? {}` fallback we used before: missing
|
||||
// `runtimeEnv` means the planDir was produced by a controller that
|
||||
// forgot to snapshot, and the chunk's pixels would diverge silently.
|
||||
// Surface it as a typed validation error so the workflow can re-plan.
|
||||
if (!encoder.runtimeEnv || typeof encoder.runtimeEnv !== "object") {
|
||||
throw new RenderChunkValidationError(
|
||||
MISSING_RUNTIME_ENV_SNAPSHOT,
|
||||
"[renderChunk] planDir is missing meta/encoder.json.runtimeEnv snapshot. " +
|
||||
"Re-plan with the current producer.",
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the controller's runtime-env snapshot. Must happen BEFORE the
|
||||
// file server is created — RENDER_MODE_SCRIPT bakes env vars into
|
||||
// served HTML at module load. The `restore()` handle is invoked in
|
||||
// `finally` so multi-chunk workers (Cloud Run Jobs, Temporal activity
|
||||
// worker) don't leak chunk N's env into chunk N+1.
|
||||
const envRestore = applyRuntimeEnvSnapshot(encoder.runtimeEnv);
|
||||
|
||||
try {
|
||||
// Synthesize a RenderJob the existing stages can consume. The chunk's
|
||||
// duration is its own frame count over fps — not the plan's full
|
||||
// duration — so the stages see this chunk as a self-contained render.
|
||||
const job = buildSyntheticRenderJob({
|
||||
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
|
||||
quality: encoder.quality,
|
||||
format: plan.dimensions.format,
|
||||
crf: encoder.crf,
|
||||
bitrate: encoder.bitrate,
|
||||
hdrMode: "force-sdr",
|
||||
entryFile: "index.html",
|
||||
});
|
||||
job.totalFrames = framesInChunk;
|
||||
job.duration = (framesInChunk * plan.dimensions.fpsDen) / plan.dimensions.fpsNum;
|
||||
|
||||
const cfg: EngineConfig = {
|
||||
...resolveConfig(),
|
||||
browserGpuMode: "software",
|
||||
forceScreenshot: encoder.forceScreenshot,
|
||||
};
|
||||
|
||||
// Build the BeforeCaptureHook that injects pre-extracted video frames
|
||||
// into the page once per chunk and reuse — `runCaptureStage` may
|
||||
// invoke `createRenderVideoFrameInjector` multiple times, and
|
||||
// re-listing `planDir/video-frames/` each call would be wasteful.
|
||||
// Compositions with no video elements produce `null`, matching the
|
||||
// in-process renderer's skip path.
|
||||
const videoInjector: BeforeCaptureHook | null =
|
||||
planVideos && planVideos.extracted.length > 0
|
||||
? createVideoFrameInjector(
|
||||
createFrameLookupTable(
|
||||
planVideos.videos,
|
||||
rebuildExtractedFramesFromPlanDir(planDir, planVideos.extracted),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const videoCaptureBeyondViewport = resolveVideoCaptureBeyondViewport(
|
||||
planVideos?.videos.length ?? 0,
|
||||
);
|
||||
|
||||
// ── Per-chunk work + frames directories ──
|
||||
// Suffix workDir with pid + random bytes so concurrent invocations on
|
||||
// the SAME `(planDir, chunkIndex)` (e.g. a scheduler that double-fires
|
||||
// due to heartbeat skew) don't race on the same tmp tree. The output
|
||||
// path itself is still the caller's contract — concurrent writers to
|
||||
// `outputChunkPath` produce undefined bytes, but we don't make it worse
|
||||
// by also deleting their workDirs out from under them.
|
||||
const workDir = `${outputChunkPath}.work.${process.pid}.${randomBytes(4).toString("hex")}`;
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
const framesDir = join(workDir, "captured-frames");
|
||||
mkdirSync(framesDir, { recursive: true });
|
||||
|
||||
// ── File server with the seeded-random shim ──
|
||||
// `Math.random` / `crypto.getRandomValues` are seeded from virtual
|
||||
// time so retries are pixel-identical. Only distributed renders flip this.
|
||||
const fileServer: FileServerHandle = await createFileServer({
|
||||
projectDir: compiledDir,
|
||||
compiledDir,
|
||||
port: 0,
|
||||
preHeadScripts: [buildVirtualTimeShim({ seedRandomFromFrame: true })],
|
||||
// These dimensions are frozen by the controller from the render job, so
|
||||
// chunk runtime seek quantization stays on the same fps grid as capture.
|
||||
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
|
||||
});
|
||||
|
||||
const captureOptions: CaptureOptions = {
|
||||
width: plan.dimensions.width,
|
||||
height: plan.dimensions.height,
|
||||
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
|
||||
format: plan.dimensions.format === "mp4" ? "jpeg" : "png",
|
||||
quality: plan.dimensions.format === "mp4" ? 80 : undefined,
|
||||
deviceScaleFactor: encoder.deviceScaleFactor,
|
||||
// Re-inject the controller's snapshotted variables so the chunk's
|
||||
// first capture sees the same `window.__hfVariables` the in-process
|
||||
// renderer would have seen. Optional — compositions that don't
|
||||
// declare `data-composition-variables` leave this undefined and the
|
||||
// engine skips the `evaluateOnNewDocument` injection.
|
||||
variables: encoder.variables,
|
||||
...(videoCaptureBeyondViewport !== undefined
|
||||
? { captureBeyondViewport: videoCaptureBeyondViewport }
|
||||
: {}),
|
||||
// lock the BeginFrame warmup loop to a fixed iteration count so
|
||||
// `beginFrameTimeTicks` is host-independent. Only chunks ever set this.
|
||||
lockWarmupTicks: true,
|
||||
};
|
||||
|
||||
// Resolve worker count up-front so we can decide whether to bother
|
||||
// pre-warming a probe session at all. The parallel branch
|
||||
// (chunkWorkerCount > 1) closes the probe immediately and creates fresh
|
||||
// per-worker sessions; `executeWorkerTask` runs `assertSwiftShader`
|
||||
// on worker 0 only (gated on `cfg.browserGpuMode === "software"`), so
|
||||
// the safety contract holds without the eager pre-probe and without
|
||||
// every worker concurrently navigating to the GL probe page. See
|
||||
// `heygen-com/hyperframes#955` for the worst-case wall regression that
|
||||
// motivated gating the probe to worker 0.
|
||||
//
|
||||
// Capture-cost calibration based on shader transitions / renderModeHints
|
||||
// is not threaded through to chunks yet; the in-process renderer's
|
||||
// `resolveRenderWorkerCount` wraps this with that reduction, but
|
||||
// `PlanJson` doesn't carry the compiled hints needed to call it
|
||||
// directly. The existing adaptive-retry path reduces workers if
|
||||
// compositor contention surfaces as CDP timeouts.
|
||||
const chunkWorkerCount = calculateOptimalWorkers(framesInChunk, undefined, cfg);
|
||||
|
||||
// ── Browser + warmup ──
|
||||
let session: CaptureSession | null = null;
|
||||
let outputKind: "file" | "frame-dir";
|
||||
let framesEncoded = 0;
|
||||
// Stage wall-clock split for the cost model: per-chunk fixed overhead
|
||||
// (boot, warmup, hash, IO) vs frame-proportional work. Consumed from the
|
||||
// perf sidecar / ChunkResult by the orchestration's telemetry.
|
||||
let sessionBootMs = 0;
|
||||
let captureStageMs = 0;
|
||||
let encodeStageMs = 0;
|
||||
try {
|
||||
if (chunkWorkerCount === 1) {
|
||||
// Sequential branch reuses the probe session for the actual capture.
|
||||
// SwiftShader assertion runs BEFORE initializeSession (which
|
||||
// navigates to the composition); on failure we tear down without
|
||||
// ever touching the composition URL. We pass
|
||||
// `readWebGlVendorInfoFromCanvas` rather than letting
|
||||
// `assertSwiftShader` use its default `chrome://gpu` reader —
|
||||
// `chrome-headless-shell` serves chrome:// pages as empty documents,
|
||||
// which would trip a false-negative even when the GL backend is in
|
||||
// fact SwiftShader. The canvas + WEBGL_debug_renderer_info probe
|
||||
// works on any page (we navigate to about:blank inside the helper).
|
||||
const bootStarted = Date.now();
|
||||
session = await createCaptureSession(fileServer.url, framesDir, captureOptions, null, cfg);
|
||||
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
|
||||
await initializeSession(session);
|
||||
sessionBootMs = Date.now() - bootStarted;
|
||||
// `discardWarmupCapture` is intentionally NOT called: every frame
|
||||
// seeks fresh DOM, so `lastFrameCache` is never read; priming it
|
||||
// would deadlock Chrome's compositor by issuing a second beginFrame
|
||||
// at a `frameTimeTicks` it had just advanced to.
|
||||
}
|
||||
// chunkWorkerCount > 1: skip the probe entirely. Each parallel worker
|
||||
// creates its own session and runs `assertSwiftShader` before its
|
||||
// first frame.
|
||||
|
||||
// In the parallel branch (chunkWorkerCount > 1) this stage also boots
|
||||
// one Chrome session per worker, so captureStageMs includes those
|
||||
// boots; sessionBootMs stays 0 there.
|
||||
const captureStarted = Date.now();
|
||||
await runCaptureStage({
|
||||
fileServer,
|
||||
workDir,
|
||||
framesDir,
|
||||
job,
|
||||
totalFrames: framesInChunk,
|
||||
cfg,
|
||||
forceScreenshot: encoder.forceScreenshot,
|
||||
log,
|
||||
workerCount: chunkWorkerCount,
|
||||
probeSession: session,
|
||||
needsAlpha: plan.dimensions.format !== "mp4",
|
||||
captureAttempts: [],
|
||||
// Distributed chunks run on Linux (beginframe) where dedup never arms;
|
||||
// a throwaway sink satisfies the type without per-chunk dedup reporting.
|
||||
dedupPerfs: [],
|
||||
buildCaptureOptions: () => captureOptions,
|
||||
createRenderVideoFrameInjector: () => videoInjector,
|
||||
abortSignal: undefined,
|
||||
assertNotAborted: () => {},
|
||||
frameRange: { startFrame: slice.startFrame, endFrame: slice.endFrame },
|
||||
});
|
||||
// captureStage closes the session it consumed.
|
||||
captureStageMs = Date.now() - captureStarted;
|
||||
session = null;
|
||||
framesEncoded = framesInChunk;
|
||||
|
||||
// ── Encode the chunk ──
|
||||
const isPngSequence = plan.dimensions.format === "png-sequence";
|
||||
outputKind = isPngSequence ? "frame-dir" : "file";
|
||||
// For mp4 / mov / webm we use the standard preset machinery; the
|
||||
// locked encoder values come from `meta/encoder.json` and the
|
||||
// `lockGopForChunkConcat` toggle is the only Phase-2 flag that flips
|
||||
// on at this site. png-sequence has no encoder, but `runEncodeStage`
|
||||
// still reads `preset.quality` for bookkeeping (it never reaches
|
||||
// ffmpeg on the pngseq branch). Fall back to the mp4 preset shape —
|
||||
// same trick `renderOrchestrator` plays.
|
||||
const presetFormat: "mp4" | "mov" | "webm" = isPngSequence
|
||||
? "mp4"
|
||||
: (plan.dimensions.format as "mp4" | "mov" | "webm");
|
||||
const basePreset = getEncoderPreset(job.config.quality, presetFormat, undefined);
|
||||
const preset = resolvePresetForLockedEncoder(basePreset, encoder.encoder);
|
||||
const effectiveQuality = encoder.crf ?? preset.quality;
|
||||
const effectiveBitrate = encoder.crf != null ? undefined : encoder.bitrate;
|
||||
// For non-pngseq, encodeStage writes to `outputPath` when `isPngSequence`
|
||||
// is false. `videoOnlyPath` is the encoder's direct output (no mux —
|
||||
// mux happens in assemble()).
|
||||
const videoOnlyPath = outputChunkPath;
|
||||
if (isPngSequence) {
|
||||
if (!existsSync(outputChunkPath)) mkdirSync(outputChunkPath, { recursive: true });
|
||||
} else {
|
||||
const outDir = join(outputChunkPath, "..");
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const encodeStarted = Date.now();
|
||||
await runEncodeStage({
|
||||
job,
|
||||
log,
|
||||
outputPath: outputChunkPath,
|
||||
framesDir,
|
||||
videoOnlyPath,
|
||||
width: plan.dimensions.width * encoder.deviceScaleFactor,
|
||||
height: plan.dimensions.height * encoder.deviceScaleFactor,
|
||||
needsAlpha: plan.dimensions.format !== "mp4",
|
||||
// Each chunk produces video only — audio is muxed once at assemble
|
||||
// time. Suppressing `hasAudio` skips the png-sequence audio sidecar
|
||||
// AND the mp4 audio mux.
|
||||
hasAudio: false,
|
||||
isPngSequence,
|
||||
// `DistributedFormat` has no "gif" member — distributed chunks are
|
||||
// always video segments (gif renders in-process only).
|
||||
isGif: false,
|
||||
preset,
|
||||
effectiveQuality,
|
||||
effectiveBitrate,
|
||||
engineConfig: {
|
||||
ffmpegEncodeTimeout: cfg.ffmpegEncodeTimeout,
|
||||
vp9CpuUsed: resolveLockedVp9CpuUsed(encoder) ?? cfg.vp9CpuUsed,
|
||||
},
|
||||
// Distributed chunks emit a single ffmpeg call per chunk; the
|
||||
// in-process per-chunk-within-chunk path would re-split our
|
||||
// already-chunked work.
|
||||
enableChunkedEncode: false,
|
||||
chunkedEncodeSize: framesInChunk,
|
||||
abortSignal: undefined,
|
||||
assertNotAborted: () => {},
|
||||
// GOP === framesInChunk + force-keyframe at frame 0 → the chunk's
|
||||
// first frame is an IDR keyframe and concat-copy at assemble time
|
||||
// round-trips losslessly.
|
||||
lockGopForChunkConcat: !isPngSequence,
|
||||
gopSize: framesInChunk,
|
||||
});
|
||||
encodeStageMs = Date.now() - encodeStarted;
|
||||
} finally {
|
||||
// Cleanest path: captureStage closed the session for us. The defensive
|
||||
// close handles error paths where we threw before delegating.
|
||||
if (session) {
|
||||
try {
|
||||
await closeCaptureSession(session);
|
||||
} catch (err) {
|
||||
log.warn("[renderChunk] error closing capture session in finally", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
closeFileServerSafely(fileServer, "renderChunk", log);
|
||||
// Leave the temp work dir on failure (helps debugging); remove it on
|
||||
// success below.
|
||||
}
|
||||
|
||||
// ── Hash the output + write the perf sidecar ──
|
||||
const sha256 = hashChunkOutput(outputChunkPath, outputKind);
|
||||
const durationMs = Date.now() - start;
|
||||
const perfPath = `${outputChunkPath}.perf.json`;
|
||||
const perfPayload = {
|
||||
planHash: plan.planHash,
|
||||
chunkIndex,
|
||||
startFrame: slice.startFrame,
|
||||
endFrame: slice.endFrame,
|
||||
framesEncoded,
|
||||
durationMs,
|
||||
planHashMs,
|
||||
sessionBootMs,
|
||||
captureStageMs,
|
||||
encodeStageMs,
|
||||
workers: chunkWorkerCount,
|
||||
sha256,
|
||||
outputKind,
|
||||
producerVersion: plan.producerVersion,
|
||||
ffmpegVersion,
|
||||
};
|
||||
writeFileSync(perfPath, `${JSON.stringify(perfPayload, null, 2)}\n`, "utf-8");
|
||||
|
||||
// Clean up only after the hash + perf sidecar landed. Any failure above
|
||||
// leaves the framesDir in place for inspection.
|
||||
try {
|
||||
rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
} catch (err) {
|
||||
log.warn("[renderChunk] failed to remove work dir", {
|
||||
workDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath: outputChunkPath,
|
||||
outputKind,
|
||||
framesEncoded,
|
||||
sha256,
|
||||
durationMs,
|
||||
planHashMs,
|
||||
sessionBootMs,
|
||||
captureStageMs,
|
||||
encodeStageMs,
|
||||
workers: chunkWorkerCount,
|
||||
perfPath,
|
||||
};
|
||||
} finally {
|
||||
// Restore the controller's runtime env even on the error path so the
|
||||
// next chunk on the same process boots from a clean env.
|
||||
envRestore.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Cloud-agnostic validation of a serializable `DistributedRenderConfig`.
|
||||
*
|
||||
* The distributed-render adapters (`@hyperframes/aws-lambda`,
|
||||
* `@hyperframes/gcp-cloud-run`, …) all need to fail fast on shape errors
|
||||
* *before* they start a cloud execution — a caller staring at a runtime
|
||||
* failure minutes into a Step Functions / Cloud Workflows run shouldn't have
|
||||
* to dig through execution history to learn they passed an unsupported
|
||||
* format. The shape validation is identical across adapters, so it lives
|
||||
* here; each adapter layers only its own wire-format size cap (Step
|
||||
* Functions' 256 KiB vs Cloud Workflows' 512 KiB) on top.
|
||||
*
|
||||
* The check is deliberately narrow — it covers the *shape* errors any caller
|
||||
* could have surfaced with `tsc` if they passed a literal, plus the
|
||||
* `force-hdr` rejection (HDR mp4 isn't supported in distributed mode).
|
||||
* Anything deeper (font availability, plan size cap, GPU mode at runtime)
|
||||
* needs the actual planner.
|
||||
*/
|
||||
|
||||
import { VIDEO_FRAME_FORMATS, isVideoFrameFormat } from "@hyperframes/engine";
|
||||
import { type DistributedFormat } from "./shared.js";
|
||||
import { type DistributedRenderConfig } from "./plan.js";
|
||||
|
||||
/**
|
||||
* `DistributedRenderConfig` minus the runtime-only fields (`logger`,
|
||||
* `abortSignal`, `producerConfig`) that can't cross a JSON wire boundary.
|
||||
* The shape adapters serialize into their execution input.
|
||||
*/
|
||||
export type SerializableDistributedRenderConfig = Omit<
|
||||
DistributedRenderConfig,
|
||||
"logger" | "abortSignal" | "producerConfig"
|
||||
>;
|
||||
|
||||
/** Thrown for any client-side `SerializableDistributedRenderConfig` violation. */
|
||||
export class InvalidConfigError extends Error {
|
||||
// Read via Error.prototype.toString; fallow can't see it.
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
override readonly name = "InvalidConfigError";
|
||||
/** Dotted JSON-pointer-ish path to the offending field, e.g. `config.fps`. */
|
||||
readonly field: string;
|
||||
constructor(field: string, message: string) {
|
||||
super(`[validateConfig] ${field}: ${message}`);
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_FPS = [24, 30, 60] as const;
|
||||
const ALLOWED_FORMATS = [
|
||||
"mp4",
|
||||
"mov",
|
||||
"png-sequence",
|
||||
"webm",
|
||||
] as const satisfies readonly DistributedFormat[];
|
||||
const ALLOWED_CODECS = ["h264", "h265"] as const;
|
||||
const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const;
|
||||
const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const;
|
||||
const ALLOWED_HDR_MODES = ["auto", "force-sdr"] as const;
|
||||
|
||||
const MAX_DIMENSION = 7680;
|
||||
const MIN_DIMENSION = 16;
|
||||
const MAX_CHUNK_SIZE = 3600;
|
||||
const MAX_PARALLEL_CHUNKS_CEILING = 256;
|
||||
|
||||
/**
|
||||
* Throw an `InvalidConfigError` if `config` is not a valid
|
||||
* `SerializableDistributedRenderConfig`. Returns the same reference on
|
||||
* success so the call site reads:
|
||||
*
|
||||
* const validated = validateDistributedRenderConfig(input);
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function validateDistributedRenderConfig(
|
||||
config: SerializableDistributedRenderConfig,
|
||||
): SerializableDistributedRenderConfig {
|
||||
if (config === null || typeof config !== "object") {
|
||||
throw new InvalidConfigError("config", "must be an object");
|
||||
}
|
||||
|
||||
if (!ALLOWED_FPS.includes(config.fps as 24 | 30 | 60)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.fps",
|
||||
`must be one of ${ALLOWED_FPS.join(", ")}; got ${String(config.fps)}`,
|
||||
);
|
||||
}
|
||||
|
||||
validateIntDimension("config.width", config.width);
|
||||
validateIntDimension("config.height", config.height);
|
||||
|
||||
if (!ALLOWED_FORMATS.includes(config.format)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.format",
|
||||
`must be one of ${ALLOWED_FORMATS.join(", ")}; got ${String(config.format)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.codec !== undefined) {
|
||||
if (config.format !== "mp4") {
|
||||
throw new InvalidConfigError(
|
||||
"config.codec",
|
||||
`is only valid with format="mp4"; got format=${String(config.format)}`,
|
||||
);
|
||||
}
|
||||
if (!ALLOWED_CODECS.includes(config.codec)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.codec",
|
||||
`must be one of ${ALLOWED_CODECS.join(", ")}; got ${String(config.codec)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.quality !== undefined && !ALLOWED_QUALITIES.includes(config.quality)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.quality",
|
||||
`must be one of ${ALLOWED_QUALITIES.join(", ")}; got ${String(config.quality)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.videoFrameFormat !== undefined && !isVideoFrameFormat(config.videoFrameFormat)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.videoFrameFormat",
|
||||
`must be one of ${VIDEO_FRAME_FORMATS.join(", ")}; got ${String(config.videoFrameFormat)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.crf !== undefined && config.bitrate !== undefined) {
|
||||
throw new InvalidConfigError("config.crf", "is mutually exclusive with config.bitrate");
|
||||
}
|
||||
if (
|
||||
config.crf !== undefined &&
|
||||
(!Number.isInteger(config.crf) || config.crf < 0 || config.crf > 51)
|
||||
) {
|
||||
throw new InvalidConfigError("config.crf", `must be an integer in [0, 51]; got ${config.crf}`);
|
||||
}
|
||||
if (config.bitrate !== undefined && !/^\d+(\.\d+)?[kKmM]?$/.test(config.bitrate)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.bitrate",
|
||||
`must look like "10M" or "5000k"; got ${JSON.stringify(config.bitrate)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.chunkSize !== undefined) {
|
||||
if (!Number.isInteger(config.chunkSize) || config.chunkSize < 1) {
|
||||
throw new InvalidConfigError(
|
||||
"config.chunkSize",
|
||||
`must be a positive integer; got ${config.chunkSize}`,
|
||||
);
|
||||
}
|
||||
if (config.chunkSize > MAX_CHUNK_SIZE) {
|
||||
throw new InvalidConfigError(
|
||||
"config.chunkSize",
|
||||
`must be <= ${MAX_CHUNK_SIZE}; got ${config.chunkSize}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.maxParallelChunks !== undefined) {
|
||||
if (!Number.isInteger(config.maxParallelChunks) || config.maxParallelChunks < 1) {
|
||||
throw new InvalidConfigError(
|
||||
"config.maxParallelChunks",
|
||||
`must be a positive integer; got ${config.maxParallelChunks}`,
|
||||
);
|
||||
}
|
||||
if (config.maxParallelChunks > MAX_PARALLEL_CHUNKS_CEILING) {
|
||||
throw new InvalidConfigError(
|
||||
"config.maxParallelChunks",
|
||||
`must be <= ${MAX_PARALLEL_CHUNKS_CEILING}; got ${config.maxParallelChunks}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.targetChunkFrames !== undefined) {
|
||||
if (!Number.isInteger(config.targetChunkFrames) || config.targetChunkFrames < 1) {
|
||||
throw new InvalidConfigError(
|
||||
"config.targetChunkFrames",
|
||||
`must be a positive integer; got ${config.targetChunkFrames}`,
|
||||
);
|
||||
}
|
||||
if (config.targetChunkFrames > MAX_CHUNK_SIZE) {
|
||||
throw new InvalidConfigError(
|
||||
"config.targetChunkFrames",
|
||||
`must be <= ${MAX_CHUNK_SIZE}; got ${config.targetChunkFrames}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.runtimeCap",
|
||||
`must be one of ${ALLOWED_RUNTIME_CAPS.join(", ")}; got ${String(config.runtimeCap)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.hdrMode !== undefined && !ALLOWED_HDR_MODES.includes(config.hdrMode)) {
|
||||
// `force-hdr` is rejected on top of the producer's plan-stage rejection —
|
||||
// it makes the typical typo (`"force-hdr"` copy-pasted from in-process
|
||||
// config) surface synchronously instead of as a typed failure minutes in.
|
||||
throw new InvalidConfigError(
|
||||
"config.hdrMode",
|
||||
`distributed mode supports only ${ALLOWED_HDR_MODES.join(", ")}; got ${String(config.hdrMode)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.variables !== undefined) {
|
||||
validateVariablesPayload(config.variables);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that `variables` is a plain JSON-safe object — no functions,
|
||||
* Symbols, `undefined` leaves, BigInts, non-finite numbers, or non-plain
|
||||
* objects (Dates, Maps, Sets, class instances). Rejected values would either
|
||||
* round-trip incorrectly through the execution input (`undefined` is silently
|
||||
* dropped by `JSON.stringify`) or throw at the wire boundary (`bigint`), so
|
||||
* we surface the offending path synchronously.
|
||||
*
|
||||
* The check is purely structural — semantic constraints (e.g. "is this
|
||||
* variable declared in `data-composition-variables`?") belong to the CLI
|
||||
* layer where the project's HTML is on disk.
|
||||
*/
|
||||
export function validateVariablesPayload(value: unknown): void {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new InvalidConfigError(
|
||||
"config.variables",
|
||||
`must be a plain JSON object (got ${describeValue(value)})`,
|
||||
);
|
||||
}
|
||||
walkVariables(value, "config.variables", new WeakSet());
|
||||
}
|
||||
|
||||
/** Per-typeof rejection messages for JSON-unsafe leaves. */
|
||||
const LEAF_REJECTIONS: Partial<Record<string, string>> = {
|
||||
undefined:
|
||||
"undefined leaves are silently dropped by JSON.stringify — use null if you mean an absent value",
|
||||
function: "functions are not JSON-serializable",
|
||||
symbol: "Symbols are not JSON-serializable",
|
||||
bigint: "BigInt values throw at JSON.stringify — encode as a string if you need 64-bit integers",
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function walkVariables(value: unknown, path: string, seen: WeakSet<object>): void {
|
||||
const t = typeof value;
|
||||
if (value === null || t === "string" || t === "boolean") return;
|
||||
if (t === "number") {
|
||||
if (!Number.isFinite(value as number)) {
|
||||
throw new InvalidConfigError(
|
||||
path,
|
||||
`non-finite numbers (NaN / Infinity) are not JSON-serializable; got ${String(value)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const leafReject = LEAF_REJECTIONS[t];
|
||||
if (leafReject !== undefined) {
|
||||
throw new InvalidConfigError(path, leafReject);
|
||||
}
|
||||
// t === "object" from here on. Reject circular refs up front — recursing
|
||||
// through a back-edge would stack-overflow with no actionable error.
|
||||
if (seen.has(value as object)) {
|
||||
throw new InvalidConfigError(
|
||||
path,
|
||||
"circular reference detected — JSON.stringify cannot serialize cycles",
|
||||
);
|
||||
}
|
||||
seen.add(value as object);
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
walkVariables(value[i], `${path}[${i}]`, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Reject non-plain objects (Date, Map, Set, class instances) up front.
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) {
|
||||
throw new InvalidConfigError(
|
||||
path,
|
||||
`non-plain objects are not supported (got ${describeValue(value)}); use a plain {…} object`,
|
||||
);
|
||||
}
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
walkVariables((value as Record<string, unknown>)[key], `${path}.${key}`, seen);
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function describeValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (typeof value !== "object") return typeof value;
|
||||
const ctorName = (value as { constructor?: { name?: string } }).constructor?.name ?? "Object";
|
||||
return ctorName === "Object" ? "object" : ctorName;
|
||||
}
|
||||
|
||||
function validateIntDimension(field: string, value: unknown): void {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new InvalidConfigError(field, `must be an integer; got ${String(value)}`);
|
||||
}
|
||||
if (value < MIN_DIMENSION || value > MAX_DIMENSION) {
|
||||
throw new InvalidConfigError(
|
||||
field,
|
||||
`must be in [${MIN_DIMENSION}, ${MAX_DIMENSION}]; got ${value}`,
|
||||
);
|
||||
}
|
||||
if (value % 2 !== 0) {
|
||||
// libx264 / libx265 yuv420p require even dimensions; rejecting now beats a
|
||||
// Plan-stage ffmpeg crash on dimension parity.
|
||||
throw new InvalidConfigError(field, `must be even (yuv420p constraint); got ${value}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Helpers shared between the distributed activity scripts (`plan.ts`,
|
||||
* `renderChunk.ts`, `assemble.ts`). Kept module-local so the public surface
|
||||
* stays just the three activity functions plus their result types.
|
||||
*/
|
||||
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { type Fps } from "@hyperframes/core";
|
||||
import { type VideoElement, type VideoFrameFormat, type VideoMetadata } from "@hyperframes/engine";
|
||||
import { type RenderConfig, type RenderJob, createRenderJob } from "../renderOrchestrator.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../../logger.js";
|
||||
|
||||
/**
|
||||
* Output container formats the distributed pipeline supports end-to-end.
|
||||
* Single source of truth for the format union — `plan()`, `renderChunk()`,
|
||||
* `assemble()`, the aws-lambda handler, and the harness all derive from
|
||||
* this type. Adding a new format starts here.
|
||||
*/
|
||||
export type DistributedFormat = "mp4" | "mov" | "png-sequence" | "webm";
|
||||
|
||||
/**
|
||||
* Filename of the per-video extraction manifest written by `plan()` into
|
||||
* `<planDir>/meta/` and consumed by `renderChunk()` to rebuild the
|
||||
* BeforeCaptureHook that injects pre-extracted frames into the page.
|
||||
* Absence is fine — compositions with no `<video>` elements never
|
||||
* produce the file.
|
||||
*/
|
||||
export const PLAN_VIDEOS_META_RELATIVE_PATH = "meta/videos.json";
|
||||
|
||||
/**
|
||||
* On-disk shape of `<planDir>/meta/videos.json`. The engine's
|
||||
* `ExtractedFrames` shape carries an absolute `outputDir`, a `framePaths`
|
||||
* Map, and potentially an open file descriptor — none of those survive
|
||||
* a serialize → re-deserialize round trip across processes. The
|
||||
* serialized form keeps only what plan-time produced; `renderChunk` re-
|
||||
* derives `outputDir` (always `<planDir>/video-frames/<videoId>`) and
|
||||
* `framePaths` (re-listed from that directory) when reconstructing the
|
||||
* `FrameLookupTable`.
|
||||
*/
|
||||
export interface PlanVideosJson {
|
||||
videos: VideoElement[];
|
||||
extracted: Array<{
|
||||
videoId: string;
|
||||
srcPath: string;
|
||||
framePattern: string;
|
||||
fps: number;
|
||||
totalFrames: number;
|
||||
metadata: VideoMetadata;
|
||||
}>;
|
||||
}
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
/**
|
||||
* Cached first line of `ffmpeg -version` (e.g. `"ffmpeg version 6.1.1"`).
|
||||
* Cached because workers that fan out multiple `renderChunk()` calls in the
|
||||
* same process (Cloud Run Jobs, Temporal activity workers) would otherwise
|
||||
* spawn ffmpeg once per chunk just to read the version — ~20-50ms each.
|
||||
*/
|
||||
let cachedFfmpegVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* Read `ffmpeg -version` first line. The string is opaque — `planHash`
|
||||
* mixes it in verbatim, so any drift across worker hosts trips a
|
||||
* `FFMPEG_VERSION_MISMATCH` rather than producing pixels that subtly
|
||||
* disagree with the plan's baked-in encoder args.
|
||||
*/
|
||||
export async function readFfmpegVersion(): Promise<string> {
|
||||
if (cachedFfmpegVersion !== null) return cachedFfmpegVersion;
|
||||
const { stdout } = await execFile("ffmpeg", ["-version"], { maxBuffer: 1024 * 1024 });
|
||||
const firstLine = stdout.split(/\r?\n/)[0]?.trim() ?? "";
|
||||
if (!firstLine) {
|
||||
throw new Error("ffmpeg -version returned empty output");
|
||||
}
|
||||
cachedFfmpegVersion = firstLine;
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
/** Test-only: clear the cached ffmpeg version so a fresh probe runs. */
|
||||
function _resetFfmpegVersionCacheForTests(): void {
|
||||
cachedFfmpegVersion = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs for {@link buildSyntheticRenderJob}. The two distributed activity
|
||||
* scripts (`plan.ts`, `renderChunk.ts`) reach for slightly different
|
||||
* sources — caller config vs. frozen `LockedRenderConfig` — but the
|
||||
* resulting `RenderJob` shape is identical, so the helper accepts both.
|
||||
*/
|
||||
export interface SyntheticRenderJobInput {
|
||||
fps: Fps;
|
||||
format: RenderConfig["format"];
|
||||
quality: RenderConfig["quality"];
|
||||
crf?: number;
|
||||
bitrate?: string;
|
||||
videoFrameFormat?: VideoFrameFormat;
|
||||
outputResolution?: RenderConfig["outputResolution"];
|
||||
hdrMode: RenderConfig["hdrMode"];
|
||||
entryFile: string;
|
||||
logger?: ProducerLogger;
|
||||
producerConfig?: RenderConfig["producerConfig"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a `RenderJob` from a distributed-render config. The distributed
|
||||
* activities operate without a full `RenderJob` (they're stateless workers),
|
||||
* so we build one to feed the existing stage interfaces.
|
||||
*/
|
||||
export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJob {
|
||||
const renderConfig: RenderConfig = {
|
||||
fps: input.fps,
|
||||
quality: input.quality,
|
||||
format: input.format,
|
||||
crf: input.crf,
|
||||
videoBitrate: input.bitrate,
|
||||
videoFrameFormat: input.videoFrameFormat,
|
||||
outputResolution: input.outputResolution,
|
||||
// Distributed mode hard-pins to software GPU. The plan-time validator
|
||||
// refuses to fan out otherwise.
|
||||
useGpu: false,
|
||||
debug: false,
|
||||
entryFile: input.entryFile,
|
||||
logger: input.logger ?? defaultLogger,
|
||||
hdrMode: input.hdrMode,
|
||||
producerConfig: input.producerConfig,
|
||||
};
|
||||
return createRenderJob(renderConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the producer package version by walking up from the calling
|
||||
* module until a `package.json` whose `name === "@hyperframes/producer"`
|
||||
* is found. Works for both the bundled `dist/index.js` (1 level up) and
|
||||
* the unbundled source tree (4 levels up).
|
||||
*
|
||||
* Cached at module load — the version is fixed for the life of the process,
|
||||
* and reading the package.json over and over wastes per-chunk syscalls.
|
||||
*/
|
||||
let cachedProducerVersion: string | null = null;
|
||||
export function readProducerVersion(): string {
|
||||
if (cachedProducerVersion !== null) return cachedProducerVersion;
|
||||
const startDir = dirname(fileURLToPath(import.meta.url));
|
||||
let current = startDir;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(current, "package.json");
|
||||
if (existsSync(candidate)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
if (pkg.name === "@hyperframes/producer" && typeof pkg.version === "string") {
|
||||
cachedProducerVersion = pkg.version;
|
||||
return pkg.version;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the next ancestor.
|
||||
}
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
cachedProducerVersion = "0.0.0-unknown";
|
||||
return cachedProducerVersion;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Tests for the `seedRandomFromFrame` gate on `buildVirtualTimeShim`.
|
||||
*
|
||||
* 1. Backwards compatibility — `buildVirtualTimeShim({
|
||||
* seedRandomFromFrame: false })` returns a string byte-identical to the
|
||||
* legacy `VIRTUAL_TIME_SHIM`. Existing in-process callers see no
|
||||
* difference.
|
||||
*
|
||||
* 2. Distributed determinism — with `seedRandomFromFrame: true`, the shim's
|
||||
* `seekToTime(ms)` reseeds a Mulberry32 PRNG keyed by the virtual time
|
||||
* and replaces `Math.random` / `crypto.getRandomValues` with that PRNG.
|
||||
* `seekToTime(N)` → N `Math.random()` calls is a deterministic
|
||||
* sequence; reseeking to the same time restarts the sequence.
|
||||
*
|
||||
* The shim is executed inside `node:vm` with a synthetic `window`/`Math` so
|
||||
* tests don't need real Chrome.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Script, createContext, type Context } from "node:vm";
|
||||
import { buildVirtualTimeShim, VIRTUAL_TIME_SHIM } from "./fileServer.js";
|
||||
|
||||
/**
|
||||
* Build a fresh VM context with its own globals (its own Math, its own
|
||||
* crypto, …) and run the shim inside it. Each context's Math is independent,
|
||||
* so we can run two shims back-to-back and have their `Math.random` overrides
|
||||
* not clobber each other.
|
||||
*
|
||||
* The shim is a browser-style IIFE that touches `window.*`. We set the VM's
|
||||
* `window` to its own globalThis so `window.setTimeout = ...` mutates the VM
|
||||
* globals (matching browser semantics) and our test code can read
|
||||
* `window.__HF_VIRTUAL_TIME__` afterward.
|
||||
*/
|
||||
function makeShimContext(): {
|
||||
context: Context;
|
||||
run: <T = unknown>(code: string) => T;
|
||||
} {
|
||||
const context = createContext({});
|
||||
const bootstrap = `
|
||||
globalThis.window = globalThis;
|
||||
globalThis.setTimeout = (cb, ms) => 0;
|
||||
globalThis.clearTimeout = (id) => undefined;
|
||||
globalThis.setInterval = (cb, ms) => 0;
|
||||
globalThis.clearInterval = (id) => undefined;
|
||||
globalThis.performance = { now: () => 0 };
|
||||
globalThis.requestAnimationFrame = undefined;
|
||||
globalThis.cancelAnimationFrame = undefined;
|
||||
// Provide a synthetic crypto.getRandomValues so the shim can detect it
|
||||
// and replace it. Default is a no-op that returns the buffer unchanged.
|
||||
globalThis.crypto = { getRandomValues: (arr) => arr };
|
||||
`;
|
||||
new Script(bootstrap).runInContext(context);
|
||||
const run = <T>(code: string): T => new Script(code).runInContext(context) as T;
|
||||
return { context, run };
|
||||
}
|
||||
|
||||
function runShim(shimSource: string): {
|
||||
run: <T = unknown>(code: string) => T;
|
||||
} {
|
||||
const ctx = makeShimContext();
|
||||
ctx.run(shimSource);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
describe("buildVirtualTimeShim — backwards compatibility", () => {
|
||||
it("default (seedRandomFromFrame: false) is byte-identical to VIRTUAL_TIME_SHIM", () => {
|
||||
// The const that existing call sites import:
|
||||
// renderOrchestrator.ts: preHeadScripts: [VIRTUAL_TIME_SHIM]
|
||||
// probeStage.ts: preHeadScripts: [VIRTUAL_TIME_SHIM]
|
||||
// Must continue to emit the same script.
|
||||
const built = buildVirtualTimeShim({ seedRandomFromFrame: false });
|
||||
expect(built).toBe(VIRTUAL_TIME_SHIM);
|
||||
});
|
||||
|
||||
it("default shim does not mention any seeded-RNG identifiers", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: false });
|
||||
expect(shim).not.toContain("mulberry32");
|
||||
expect(shim).not.toContain("reseedRngFromTime");
|
||||
expect(shim).not.toContain("__seededGetRandomValues");
|
||||
expect(shim).not.toContain("Math.random = ");
|
||||
});
|
||||
|
||||
it("default shim leaves Math.random pointing at the VM's native function", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: false });
|
||||
const { run } = runShim(shim);
|
||||
// toString() of native Math.random is `function random() { [native code] }`
|
||||
const isNative = run<boolean>(`/\\[native code\\]/.test(Math.random.toString())`);
|
||||
expect(isNative).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVirtualTimeShim — seedRandomFromFrame: true", () => {
|
||||
it("emits the seeded-RNG block", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
expect(shim).toContain("mulberry32");
|
||||
expect(shim).toContain("reseedRngFromTime");
|
||||
expect(shim).toContain("__seededGetRandomValues");
|
||||
});
|
||||
|
||||
it("replaces Math.random with a non-native PRNG", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
const { run } = runShim(shim);
|
||||
const isNative = run<boolean>(`/\\[native code\\]/.test(Math.random.toString())`);
|
||||
expect(isNative).toBe(false);
|
||||
});
|
||||
|
||||
it("produces identical Math.random sequences across two fresh VMs at the same time", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
const drawSequence = `(() => {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(1234);
|
||||
const seq = [];
|
||||
for (let i = 0; i < 16; i++) seq.push(Math.random());
|
||||
return seq;
|
||||
})()`;
|
||||
const seqA = runShim(shim).run<number[]>(drawSequence);
|
||||
const seqB = runShim(shim).run<number[]>(drawSequence);
|
||||
expect(seqA).toEqual(seqB);
|
||||
// Sanity: the sequence isn't degenerate.
|
||||
expect(new Set(seqA).size).toBeGreaterThan(8);
|
||||
for (const v of seqA) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-seeking to the same time produces the same Math.random sequence", () => {
|
||||
// The core determinism contract for retries: same (planDir, chunkIndex) →
|
||||
// same frame N → same seekToTime(t_N) → same Math.random outputs.
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
const { run } = runShim(shim);
|
||||
const observed = run<{ first: number[]; second: number[] }>(`(() => {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(42);
|
||||
const first = [Math.random(), Math.random(), Math.random()];
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(999);
|
||||
Math.random(); Math.random();
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(42);
|
||||
const second = [Math.random(), Math.random(), Math.random()];
|
||||
return { first, second };
|
||||
})()`);
|
||||
expect(observed.second).toEqual(observed.first);
|
||||
});
|
||||
|
||||
it("different times produce different Math.random sequences", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
const { run } = runShim(shim);
|
||||
const observed = run<{ t0: number[]; t1: number[] }>(`(() => {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(0);
|
||||
const t0 = [Math.random(), Math.random(), Math.random()];
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(1);
|
||||
const t1 = [Math.random(), Math.random(), Math.random()];
|
||||
return { t0, t1 };
|
||||
})()`);
|
||||
expect(observed.t1).not.toEqual(observed.t0);
|
||||
});
|
||||
|
||||
it("seeded crypto.getRandomValues writes deterministic bytes", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
const drawBytes = `(() => {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(7);
|
||||
const buf = new Uint8Array(64);
|
||||
window.crypto.getRandomValues(buf);
|
||||
return Array.from(buf);
|
||||
})()`;
|
||||
const a = runShim(shim).run<number[]>(drawBytes);
|
||||
const b = runShim(shim).run<number[]>(drawBytes);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.some((v) => v !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("seeded crypto.getRandomValues handles odd byte lengths", () => {
|
||||
const shim = buildVirtualTimeShim({ seedRandomFromFrame: true });
|
||||
const { run } = runShim(shim);
|
||||
const lengths = run<number[]>(`(() => {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(3);
|
||||
const out = [];
|
||||
for (const len of [1, 2, 3, 4, 5, 7, 31, 33]) {
|
||||
const buf = new Uint8Array(len);
|
||||
window.crypto.getRandomValues(buf);
|
||||
out.push(buf.byteLength);
|
||||
}
|
||||
return out;
|
||||
})()`);
|
||||
expect(lengths).toEqual([1, 2, 3, 4, 5, 7, 31, 33]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,955 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import path, { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
closeFileServerSafely,
|
||||
createFileServer,
|
||||
HF_BRIDGE_SCRIPT,
|
||||
HF_EARLY_STUB,
|
||||
injectScriptsAtHeadStart,
|
||||
isPathInside,
|
||||
parseRangeHeader,
|
||||
VIRTUAL_TIME_SHIM,
|
||||
} from "./fileServer.js";
|
||||
|
||||
function captureLogger() {
|
||||
const warnings: { message: string; meta?: Record<string, unknown> }[] = [];
|
||||
return {
|
||||
warnings,
|
||||
log: {
|
||||
error() {},
|
||||
warn(message: string, meta?: Record<string, unknown>) {
|
||||
warnings.push({ message, meta });
|
||||
},
|
||||
info() {},
|
||||
debug() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("closeFileServerSafely", () => {
|
||||
it("swallows and logs a throwing close instead of propagating", () => {
|
||||
const { log, warnings } = captureLogger();
|
||||
const fileServer = {
|
||||
close: () => {
|
||||
// http.Server.close() throws ERR_SERVER_NOT_RUNNING on a second close.
|
||||
throw new Error("Server is not running.");
|
||||
},
|
||||
};
|
||||
expect(() => closeFileServerSafely(fileServer, "plan", log)).not.toThrow();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0].message).toContain("[plan]");
|
||||
expect(warnings[0].meta?.error).toBe("Server is not running.");
|
||||
});
|
||||
|
||||
it("closes once and stays quiet on the happy path", () => {
|
||||
const { log, warnings } = captureLogger();
|
||||
let closed = 0;
|
||||
closeFileServerSafely({ close: () => closed++ }, "renderChunk", log);
|
||||
expect(closed).toBe(1);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function withFileServer(
|
||||
projectDir: string,
|
||||
run: (server: Awaited<ReturnType<typeof createFileServer>>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const server = await createFileServer({
|
||||
projectDir,
|
||||
preHeadScripts: [],
|
||||
headScripts: [],
|
||||
bodyScripts: [],
|
||||
});
|
||||
try {
|
||||
await run(server);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeEmptyIndex(projectDir: string): void {
|
||||
writeFileSync(join(projectDir, "index.html"), "<!doctype html><html></html>");
|
||||
}
|
||||
|
||||
async function expectTextResponse(
|
||||
url: string,
|
||||
options: { contentType?: string; bodyIncludes: string },
|
||||
): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
expect(response.status).toBe(200);
|
||||
if (options.contentType) {
|
||||
expect(response.headers.get("content-type")).toContain(options.contentType);
|
||||
}
|
||||
expect(await response.text()).toContain(options.bodyIncludes);
|
||||
}
|
||||
|
||||
describe("injectScriptsIntoHtml", () => {
|
||||
it("injects the virtual time shim into head content before authored scripts", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><script>window.__order = ["authored-head"];</script></head>
|
||||
<body><script>window.__order.push("authored-body");</script></body>
|
||||
</html>`;
|
||||
|
||||
const injected = injectScriptsAtHeadStart(html, [VIRTUAL_TIME_SHIM]);
|
||||
const injectedShimTag = `<script>${VIRTUAL_TIME_SHIM}</script>`;
|
||||
const authoredHeadTag = `<script>window.__order = ["authored-head"];</script>`;
|
||||
|
||||
expect(injected.indexOf(injectedShimTag)).toBeGreaterThanOrEqual(0);
|
||||
expect(injected.indexOf(injectedShimTag)).toBeLessThan(injected.indexOf(authoredHeadTag));
|
||||
});
|
||||
|
||||
it("supports iframe html by injecting pre-head scripts without body scripts", () => {
|
||||
const html =
|
||||
"<!DOCTYPE html><html><head></head><body><script>window.targetLoaded = true;</script></body></html>";
|
||||
|
||||
const preInjected = injectScriptsAtHeadStart(html, [VIRTUAL_TIME_SHIM]);
|
||||
const final = preInjected;
|
||||
|
||||
expect(final).toContain(VIRTUAL_TIME_SHIM);
|
||||
expect(final).not.toContain("bodyOnly = true");
|
||||
});
|
||||
|
||||
it("propagates virtual time seeks into same-origin iframe documents", () => {
|
||||
expect(HF_BRIDGE_SCRIPT).toContain("function seekSameOriginChildFrames");
|
||||
expect(HF_BRIDGE_SCRIPT).toContain("childWindow.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs)");
|
||||
expect(HF_BRIDGE_SCRIPT).toContain("seekSameOriginChildFrames(window, nextTimeMs)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPathInside", () => {
|
||||
it("returns true when the child equals the parent", () => {
|
||||
expect(isPathInside("/tmp/project", "/tmp/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for direct children", () => {
|
||||
expect(isPathInside("/tmp/project/index.html", "/tmp/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for deeply nested descendants", () => {
|
||||
expect(isPathInside("/tmp/project/a/b/c/file.html", "/tmp/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects siblings with a shared name prefix", () => {
|
||||
// The classic prefix-bug: "/foo" should NOT contain "/foobar/x". A naive
|
||||
// startsWith check without a trailing separator would incorrectly accept
|
||||
// this as nested.
|
||||
expect(isPathInside("/tmp/projectile/a", "/tmp/project")).toBe(false);
|
||||
expect(isPathInside("/tmp/project-other/a", "/tmp/project")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects paths outside the parent entirely", () => {
|
||||
expect(isPathInside("/etc/passwd", "/tmp/project")).toBe(false);
|
||||
expect(isPathInside("/tmp/other/file.html", "/tmp/project")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path-traversal attempts that escape the parent", () => {
|
||||
// path.join("/tmp/project", "../etc/passwd") normalizes to "/tmp/etc/passwd"
|
||||
// — outside the project root. The whole point of isPathInside is to catch
|
||||
// exactly this after the join.
|
||||
expect(isPathInside("/tmp/etc/passwd", "/tmp/project")).toBe(false);
|
||||
expect(isPathInside("/tmp/project/../etc/passwd", "/tmp/project")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts traversal that resolves back inside the parent", () => {
|
||||
expect(isPathInside("/tmp/project/sub/../index.html", "/tmp/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats parents with and without trailing slashes the same", () => {
|
||||
expect(isPathInside("/tmp/project/index.html", "/tmp/project/")).toBe(true);
|
||||
expect(isPathInside("/tmp/project/index.html", "/tmp/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves relative paths against the current working directory", () => {
|
||||
// Both sides resolve against cwd, so a relative file under a relative dir
|
||||
// should be considered nested. We don't assert the absolute path; we just
|
||||
// check the containment relationship holds after resolution.
|
||||
expect(isPathInside("a/b/c.html", "a/b")).toBe(true);
|
||||
expect(isPathInside("a/b/../../c.html", "a/b")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects symlink escapes when realpath enforcement is enabled", () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "hf-file-server-root-"));
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), "hf-file-server-outside-"));
|
||||
const outsideFile = join(outsideDir, "secret.txt");
|
||||
const symlinkPath = join(rootDir, "escaped.txt");
|
||||
|
||||
try {
|
||||
writeFileSync(outsideFile, "secret");
|
||||
symlinkSync(outsideFile, symlinkPath);
|
||||
|
||||
expect(isPathInside(symlinkPath, rootDir)).toBe(true);
|
||||
expect(isPathInside(symlinkPath, rootDir, { resolveSymlinks: true })).toBe(false);
|
||||
} finally {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("with path.win32 (cross-platform pinning tests)", () => {
|
||||
// Pin Windows-path semantics on Linux/macOS CI by injecting the win32
|
||||
// path module. Without this, accidental Unix-only assumptions (e.g. only
|
||||
// splitting on "/") would silently regress for Windows users.
|
||||
const win32 = { pathModule: path.win32 };
|
||||
|
||||
it("returns true when the child equals the parent", () => {
|
||||
expect(isPathInside("C:\\foo", "C:\\foo", win32)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for direct children", () => {
|
||||
expect(isPathInside("C:\\foo\\bar", "C:\\foo", win32)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for deeply nested descendants", () => {
|
||||
expect(isPathInside("C:\\foo\\a\\b\\c.html", "C:\\foo", win32)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects siblings with a shared name prefix", () => {
|
||||
expect(isPathInside("C:\\foobar\\x", "C:\\foo", win32)).toBe(false);
|
||||
expect(isPathInside("C:\\foo-other\\x", "C:\\foo", win32)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path-traversal attempts that escape the parent", () => {
|
||||
expect(isPathInside("C:\\foo\\..\\etc\\passwd", "C:\\foo", win32)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats parents with and without trailing backslashes the same", () => {
|
||||
expect(isPathInside("C:\\foo\\bar", "C:\\foo\\", win32)).toBe(true);
|
||||
expect(isPathInside("C:\\foo\\bar", "C:\\foo", win32)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects paths on a different drive letter", () => {
|
||||
expect(isPathInside("D:\\foo\\bar", "C:\\foo", win32)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRangeHeader", () => {
|
||||
const SIZE = 1000;
|
||||
|
||||
it("returns absent when there is no Range header", () => {
|
||||
expect(parseRangeHeader(undefined, SIZE)).toEqual({ kind: "absent" });
|
||||
expect(parseRangeHeader(null, SIZE)).toEqual({ kind: "absent" });
|
||||
expect(parseRangeHeader("", SIZE)).toEqual({ kind: "absent" });
|
||||
});
|
||||
|
||||
it("parses a closed range bytes=START-END", () => {
|
||||
expect(parseRangeHeader("bytes=0-99", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 0,
|
||||
end: 99,
|
||||
});
|
||||
expect(parseRangeHeader("bytes=100-199", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 100,
|
||||
end: 199,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses an open-ended range bytes=START- as start..EOF", () => {
|
||||
expect(parseRangeHeader("bytes=100-", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 100,
|
||||
end: SIZE - 1,
|
||||
});
|
||||
expect(parseRangeHeader("bytes=0-", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 0,
|
||||
end: SIZE - 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a suffix range bytes=-N as the last N bytes", () => {
|
||||
expect(parseRangeHeader("bytes=-50", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: SIZE - 50,
|
||||
end: SIZE - 1,
|
||||
});
|
||||
// Suffix larger than the file: clamp to the whole file.
|
||||
expect(parseRangeHeader("bytes=-5000", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 0,
|
||||
end: SIZE - 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the end of a closed range to the last valid byte", () => {
|
||||
// bytes=900-9999 on a 1000-byte file -> serve 900..999.
|
||||
expect(parseRangeHeader("bytes=900-9999", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 900,
|
||||
end: SIZE - 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unsatisfiable when start >= size", () => {
|
||||
expect(parseRangeHeader("bytes=1000-2000", SIZE)).toEqual({ kind: "unsatisfiable" });
|
||||
expect(parseRangeHeader("bytes=2000-", SIZE)).toEqual({ kind: "unsatisfiable" });
|
||||
});
|
||||
|
||||
it("returns unsatisfiable when end < start in a closed range", () => {
|
||||
expect(parseRangeHeader("bytes=200-100", SIZE)).toEqual({ kind: "unsatisfiable" });
|
||||
});
|
||||
|
||||
it("returns unsatisfiable for a suffix request on a zero-byte file", () => {
|
||||
expect(parseRangeHeader("bytes=-10", 0)).toEqual({ kind: "unsatisfiable" });
|
||||
});
|
||||
|
||||
it("returns absent for non-bytes units, multi-range, and malformed inputs", () => {
|
||||
expect(parseRangeHeader("items=0-1", SIZE)).toEqual({ kind: "absent" });
|
||||
expect(parseRangeHeader("bytes=0-99,200-299", SIZE)).toEqual({ kind: "absent" });
|
||||
expect(parseRangeHeader("bytes=abc-def", SIZE)).toEqual({ kind: "absent" });
|
||||
expect(parseRangeHeader("bytes=", SIZE)).toEqual({ kind: "absent" });
|
||||
expect(parseRangeHeader("bytes=-", SIZE)).toEqual({ kind: "absent" });
|
||||
});
|
||||
|
||||
it("tolerates surrounding whitespace and case", () => {
|
||||
expect(parseRangeHeader(" Bytes = 0-99 ", SIZE)).toEqual({
|
||||
kind: "satisfiable",
|
||||
start: 0,
|
||||
end: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFileServer", () => {
|
||||
async function expectInjectedRenderFps(
|
||||
fps: Parameters<typeof createFileServer>[0]["fps"],
|
||||
expected: {
|
||||
value: string;
|
||||
source: "render-options" | "default";
|
||||
fallbackReason?: "missing" | "invalid";
|
||||
},
|
||||
): Promise<void> {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-render-fps-"));
|
||||
|
||||
try {
|
||||
writeEmptyIndex(projectDir);
|
||||
const server = await createFileServer({
|
||||
projectDir,
|
||||
preHeadScripts: [],
|
||||
headScripts: [],
|
||||
...(fps ? { fps } : {}),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${server.url}/index.html`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain("window.__HF_EXPORT_RENDER_SEEK_CONFIG");
|
||||
expect(html).toContain(`var __renderFps = ${expected.value}`);
|
||||
expect(html).toContain(`var __renderFpsSource = "${expected.source}"`);
|
||||
if (expected.fallbackReason) {
|
||||
expect(html).toContain(`var __renderFpsFallbackReason = "${expected.fallbackReason}"`);
|
||||
} else {
|
||||
expect(html).toContain("var __renderFpsFallbackReason = null");
|
||||
}
|
||||
expect(html).toContain("fps: __renderFps");
|
||||
expect(html).toContain("fpsSource: __renderFpsSource");
|
||||
expect(html).not.toContain("[hyperframes] render fps defaulted");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("injects the requested render fps into the page render config", async () => {
|
||||
await expectInjectedRenderFps({ num: 60, den: 1 }, { value: "60", source: "render-options" });
|
||||
});
|
||||
|
||||
it("injects fractional render fps without rounding", async () => {
|
||||
await expectInjectedRenderFps(
|
||||
{ num: 24000, den: 1001 },
|
||||
{ value: "23.976023976023978", source: "render-options" },
|
||||
);
|
||||
});
|
||||
|
||||
it("marks missing render fps as an explicit 30fps default", async () => {
|
||||
await expectInjectedRenderFps(undefined, {
|
||||
value: "30",
|
||||
source: "default",
|
||||
fallbackReason: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks invalid render fps as an explicit 30fps default", async () => {
|
||||
await expectInjectedRenderFps(
|
||||
{ num: 60, den: 0 },
|
||||
{
|
||||
value: "30",
|
||||
source: "default",
|
||||
fallbackReason: "invalid",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("serves asset files through project-root symlinked directories", async () => {
|
||||
const workspaceDir = mkdtempSync(join(tmpdir(), "hf-file-server-symlink-assets-"));
|
||||
const adsDir = join(workspaceDir, "Ads");
|
||||
const projectDir = join(adsDir, "annual-upsell-2");
|
||||
const sharedDir = join(adsDir, "shared");
|
||||
|
||||
try {
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(sharedDir, { recursive: true });
|
||||
writeEmptyIndex(projectDir);
|
||||
writeFileSync(
|
||||
join(sharedDir, "brand.css"),
|
||||
".aisplus-glass { backdrop-filter: blur(28px); }",
|
||||
);
|
||||
symlinkSync("../shared", join(projectDir, "shared"));
|
||||
|
||||
await withFileServer(projectDir, async (server) => {
|
||||
await expectTextResponse(`${server.url}/shared/brand.css`, {
|
||||
contentType: "text/css",
|
||||
bodyIncludes: ".aisplus-glass",
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("streams binary file content without buffering through readFileSync", async () => {
|
||||
// Regression test for the video-heavy event-loop block documented at
|
||||
// renderOrchestrator.ts:1277-1306. Pre-fix the file route called
|
||||
// readFileSync on every binary asset, which on 32MB+ videos stalled
|
||||
// the Node event loop long enough to wedge concurrent /health probes.
|
||||
// This test pins three properties of the streaming path:
|
||||
//
|
||||
// 1. Correctness: the served byte sequence matches the file exactly,
|
||||
// across a chunk boundary (we use a 5 MB synthetic asset, well past
|
||||
// Node's default 64KB createReadStream highWaterMark).
|
||||
// 2. Content-Length is reported via statSync so range-aware HTTP
|
||||
// consumers (Chrome's media stack) see the size up front.
|
||||
// 3. Concurrent requests don't serialize behind each other — N
|
||||
// parallel fetches all return identical content. With readFileSync
|
||||
// they'd block the event loop in serial; with the stream they
|
||||
// pipe interleaved chunks.
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-stream-"));
|
||||
try {
|
||||
writeEmptyIndex(projectDir);
|
||||
// 5 MB of deterministic bytes — large enough to span many 64KB read
|
||||
// chunks, small enough to keep the test fast.
|
||||
const size = 5 * 1024 * 1024;
|
||||
const buf = Buffer.alloc(size);
|
||||
for (let i = 0; i < size; i++) buf[i] = i & 0xff;
|
||||
writeFileSync(join(projectDir, "big.bin"), buf);
|
||||
|
||||
await withFileServer(projectDir, async (server) => {
|
||||
// Single-request correctness + content-length.
|
||||
const r = await fetch(`${server.url}/big.bin`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-length")).toBe(String(size));
|
||||
const out = Buffer.from(await r.arrayBuffer());
|
||||
expect(out.length).toBe(size);
|
||||
// Spot-check a few sentinel positions (full equality check is O(5MB)
|
||||
// and unnecessary — if any chunk were misaligned we'd see it here).
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[255]).toBe(255);
|
||||
expect(out[256]).toBe(0);
|
||||
expect(out[size - 1]).toBe((size - 1) & 0xff);
|
||||
|
||||
// Concurrent requests don't corrupt each other.
|
||||
const concurrent = await Promise.all(
|
||||
Array.from({ length: 4 }, () => fetch(`${server.url}/big.bin`)),
|
||||
);
|
||||
for (const resp of concurrent) {
|
||||
expect(resp.status).toBe(200);
|
||||
const body = Buffer.from(await resp.arrayBuffer());
|
||||
expect(body.length).toBe(size);
|
||||
expect(body[0]).toBe(0);
|
||||
expect(body[size - 1]).toBe((size - 1) & 0xff);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("serves Range requests with 206 Partial Content + Accept-Ranges", async () => {
|
||||
// Pins the RFC 7233 implementation for the binary path: Chrome's <video>
|
||||
// element issues `Range: bytes=...` when seeking, and the response must
|
||||
// be 206 with `Content-Range` + a sliced body so the player can resume
|
||||
// partial-load without re-pulling the whole file. Also pins that the
|
||||
// server advertises `Accept-Ranges: bytes` on full-body GETs so clients
|
||||
// know future Range requests are supported.
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-range-"));
|
||||
try {
|
||||
writeEmptyIndex(projectDir);
|
||||
// Use a 4 KB deterministic asset: small enough to keep the test
|
||||
// fast, large enough that suffix / partial responses exercise the
|
||||
// slicing math meaningfully.
|
||||
const size = 4096;
|
||||
const buf = Buffer.alloc(size);
|
||||
for (let i = 0; i < size; i++) buf[i] = i & 0xff;
|
||||
writeFileSync(join(projectDir, "asset.bin"), buf);
|
||||
|
||||
await withFileServer(projectDir, async (server) => {
|
||||
// 1. Full GET advertises Accept-Ranges: bytes.
|
||||
const full = await fetch(`${server.url}/asset.bin`);
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers.get("accept-ranges")).toBe("bytes");
|
||||
expect(full.headers.get("content-length")).toBe(String(size));
|
||||
await full.body?.cancel();
|
||||
|
||||
// 2. Closed range: bytes=0-99 returns the first 100 bytes.
|
||||
const head = await fetch(`${server.url}/asset.bin`, {
|
||||
headers: { Range: "bytes=0-99" },
|
||||
});
|
||||
expect(head.status).toBe(206);
|
||||
expect(head.headers.get("content-range")).toBe(`bytes 0-99/${size}`);
|
||||
expect(head.headers.get("content-length")).toBe("100");
|
||||
expect(head.headers.get("accept-ranges")).toBe("bytes");
|
||||
const headBody = Buffer.from(await head.arrayBuffer());
|
||||
expect(headBody.length).toBe(100);
|
||||
expect(headBody[0]).toBe(0);
|
||||
expect(headBody[99]).toBe(99);
|
||||
|
||||
// 3. Open-ended: bytes=4000- returns the tail.
|
||||
const tail = await fetch(`${server.url}/asset.bin`, {
|
||||
headers: { Range: "bytes=4000-" },
|
||||
});
|
||||
expect(tail.status).toBe(206);
|
||||
expect(tail.headers.get("content-range")).toBe(`bytes 4000-${size - 1}/${size}`);
|
||||
expect(tail.headers.get("content-length")).toBe(String(size - 4000));
|
||||
const tailBody = Buffer.from(await tail.arrayBuffer());
|
||||
expect(tailBody.length).toBe(size - 4000);
|
||||
expect(tailBody[0]).toBe(4000 & 0xff);
|
||||
expect(tailBody[tailBody.length - 1]).toBe((size - 1) & 0xff);
|
||||
|
||||
// 4. Suffix: bytes=-50 returns the last 50 bytes.
|
||||
const suffix = await fetch(`${server.url}/asset.bin`, {
|
||||
headers: { Range: "bytes=-50" },
|
||||
});
|
||||
expect(suffix.status).toBe(206);
|
||||
expect(suffix.headers.get("content-range")).toBe(`bytes ${size - 50}-${size - 1}/${size}`);
|
||||
expect(suffix.headers.get("content-length")).toBe("50");
|
||||
const suffixBody = Buffer.from(await suffix.arrayBuffer());
|
||||
expect(suffixBody.length).toBe(50);
|
||||
expect(suffixBody[0]).toBe((size - 50) & 0xff);
|
||||
expect(suffixBody[49]).toBe((size - 1) & 0xff);
|
||||
|
||||
// 5. Unsatisfiable: bytes=99999-99999 returns 416 with
|
||||
// Content-Range: bytes */<size> per RFC 7233 §4.4.
|
||||
const bad = await fetch(`${server.url}/asset.bin`, {
|
||||
headers: { Range: "bytes=99999-99999" },
|
||||
});
|
||||
expect(bad.status).toBe(416);
|
||||
expect(bad.headers.get("content-range")).toBe(`bytes */${size}`);
|
||||
expect(bad.headers.get("accept-ranges")).toBe("bytes");
|
||||
await bad.body?.cancel();
|
||||
|
||||
// 6. Multi-range falls back to 200 (we don't reassemble
|
||||
// multipart/byteranges for the single-asset use case).
|
||||
const multi = await fetch(`${server.url}/asset.bin`, {
|
||||
headers: { Range: "bytes=0-9,20-29" },
|
||||
});
|
||||
expect(multi.status).toBe(200);
|
||||
expect(multi.headers.get("accept-ranges")).toBe("bytes");
|
||||
await multi.body?.cancel();
|
||||
});
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("decodes percent-encoded reserved characters in URL path segments", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-reserved-chars-"));
|
||||
|
||||
try {
|
||||
const subDir = join(projectDir, "video#1");
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
writeEmptyIndex(projectDir);
|
||||
writeFileSync(join(subDir, "frame.jpg"), "fake-jpg");
|
||||
|
||||
await withFileServer(projectDir, async (server) => {
|
||||
await expectTextResponse(`${server.url}/video%231/frame.jpg`, {
|
||||
bodyIncludes: "fake-jpg",
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => {
|
||||
/**
|
||||
* Simulates the real injection order in a Puppeteer page:
|
||||
* 1. HF_EARLY_STUB (start of <head>, before everything)
|
||||
* 2. authored page scripts that write to window.__hf.transitions
|
||||
* (e.g. @hyperframes/shader-transitions in <body>)
|
||||
* 3. HF_BRIDGE_SCRIPT (end of <body>, upgrades __hf with seek/duration)
|
||||
*
|
||||
* Regression test for the race condition where the bridge used to overwrite
|
||||
* window.__hf with a fresh object, dropping any fields user libraries
|
||||
* (notably `transitions`) had populated during page-script execution.
|
||||
* Without the early stub + patch-not-replace bridge, the engine never
|
||||
* detects shader transitions and HDR compositing falls back to plain DOM.
|
||||
*/
|
||||
it("preserves __hf.transitions written by page scripts through bridge upgrade", () => {
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: { transitions?: unknown[]; seek?: (t: number) => void; duration?: number };
|
||||
__player?: { renderSeek: (t: number) => void; getDuration: () => number };
|
||||
setInterval: typeof setInterval;
|
||||
clearInterval: typeof clearInterval;
|
||||
};
|
||||
document: { querySelector: () => null };
|
||||
} = {
|
||||
window: {
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
},
|
||||
document: { querySelector: () => null },
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
|
||||
const run = (src: string): void => {
|
||||
new Function("window", "document", `with (window) {\n${src}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
);
|
||||
};
|
||||
|
||||
run(HF_EARLY_STUB);
|
||||
expect(sandbox.window.__hf).toBeDefined();
|
||||
expect(sandbox.window.__hf?.transitions).toBeUndefined();
|
||||
|
||||
sandbox.window.__hf!.transitions = [
|
||||
{ time: 5, duration: 0.5, shader: "domain-warp", fromScene: "a", toScene: "b" },
|
||||
];
|
||||
|
||||
sandbox.window.__player = {
|
||||
renderSeek: () => {},
|
||||
getDuration: () => 30,
|
||||
};
|
||||
|
||||
run(HF_BRIDGE_SCRIPT);
|
||||
|
||||
expect(sandbox.window.__hf).toBeDefined();
|
||||
expect(sandbox.window.__hf?.transitions).toEqual([
|
||||
{ time: 5, duration: 0.5, shader: "domain-warp", fromScene: "a", toScene: "b" },
|
||||
]);
|
||||
expect(typeof sandbox.window.__hf?.seek).toBe("function");
|
||||
expect(sandbox.window.__hf?.duration).toBe(0);
|
||||
|
||||
sandbox.window.__renderReady = true;
|
||||
expect(sandbox.window.__hf?.duration).toBe(30);
|
||||
});
|
||||
|
||||
it("forwards suppressEvents from __hf.seek to renderSeek", () => {
|
||||
const renderSeekCalls: Array<[number, { suppressEvents?: boolean } | undefined]> = [];
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: {
|
||||
seek?: (t: number, options?: { suppressEvents?: boolean }) => void;
|
||||
duration?: number;
|
||||
};
|
||||
__player?: {
|
||||
renderSeek: (t: number, options?: { suppressEvents?: boolean }) => void;
|
||||
getDuration: () => number;
|
||||
};
|
||||
setInterval: typeof setInterval;
|
||||
clearInterval: typeof clearInterval;
|
||||
};
|
||||
document: { querySelector: () => null };
|
||||
} = {
|
||||
window: {
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
},
|
||||
document: { querySelector: () => null },
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
sandbox.window.__renderReady = true;
|
||||
sandbox.window.__player = {
|
||||
renderSeek: (time, options) => {
|
||||
renderSeekCalls.push([time, options]);
|
||||
},
|
||||
getDuration: () => 10,
|
||||
};
|
||||
|
||||
new Function("window", "document", `with (window) {\n${HF_BRIDGE_SCRIPT}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
);
|
||||
|
||||
sandbox.window.__hf?.seek?.(5, { suppressEvents: true });
|
||||
|
||||
expect(renderSeekCalls).toEqual([[5, { suppressEvents: true }]]);
|
||||
});
|
||||
|
||||
it("keeps render-time timeline seeks synchronous during large renders", () => {
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: Record<string, unknown>;
|
||||
__hfTimelinesBuilding?: boolean;
|
||||
gsap?: { timeline: () => { totalTime: (time?: number) => number | unknown } };
|
||||
requestAnimationFrame: typeof requestAnimationFrame;
|
||||
setTimeout: typeof setTimeout;
|
||||
};
|
||||
document: Record<string, never>;
|
||||
CustomEvent: typeof CustomEvent;
|
||||
} = {
|
||||
window: {
|
||||
requestAnimationFrame: (() => 1) as typeof requestAnimationFrame,
|
||||
setTimeout: (() => 1) as typeof setTimeout,
|
||||
},
|
||||
document: {},
|
||||
CustomEvent,
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
sandbox.window.CustomEvent = sandbox.CustomEvent;
|
||||
|
||||
new Function("window", "document", "CustomEvent", `with (window) {\n${HF_EARLY_STUB}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
sandbox.CustomEvent,
|
||||
);
|
||||
|
||||
const totalTimeCalls: number[] = [];
|
||||
sandbox.window.gsap = {
|
||||
timeline: () => ({
|
||||
to: () => {},
|
||||
from: () => {},
|
||||
fromTo: () => {},
|
||||
set: () => {},
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
seek: () => {},
|
||||
totalTime: (time?: number) => {
|
||||
if (typeof time === "number") totalTimeCalls.push(time);
|
||||
return totalTimeCalls.at(-1) ?? 0;
|
||||
},
|
||||
time: () => 0,
|
||||
duration: () => 10,
|
||||
add: () => {},
|
||||
getChildren: () => [],
|
||||
paused: () => true,
|
||||
timeScale: () => 1,
|
||||
kill: () => {},
|
||||
}),
|
||||
};
|
||||
|
||||
const timeline = sandbox.window.gsap.timeline();
|
||||
for (let i = 0; i < 5100; i += 1) {
|
||||
timeline.totalTime(i / 30);
|
||||
}
|
||||
|
||||
expect(totalTimeCalls).toHaveLength(5100);
|
||||
expect(sandbox.window.__hfTimelinesBuilding).toBe(false);
|
||||
});
|
||||
|
||||
it("flushes queued construction calls before forwarding timeline children", () => {
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: Record<string, unknown>;
|
||||
__hfTimelinesBuilding?: boolean;
|
||||
gsap?: {
|
||||
timeline: () => { to: (...args: unknown[]) => unknown; getChildren: () => unknown[] };
|
||||
};
|
||||
requestAnimationFrame: typeof requestAnimationFrame;
|
||||
setTimeout: typeof setTimeout;
|
||||
};
|
||||
document: Record<string, never>;
|
||||
CustomEvent: typeof CustomEvent;
|
||||
} = {
|
||||
window: {
|
||||
requestAnimationFrame: (() => 1) as typeof requestAnimationFrame,
|
||||
setTimeout: ((callback: () => void) => {
|
||||
callback();
|
||||
return 1;
|
||||
}) as typeof setTimeout,
|
||||
},
|
||||
document: {},
|
||||
CustomEvent,
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
sandbox.window.CustomEvent = sandbox.CustomEvent;
|
||||
|
||||
new Function("window", "document", "CustomEvent", `with (window) {\n${HF_EARLY_STUB}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
sandbox.CustomEvent,
|
||||
);
|
||||
|
||||
const constructionCalls: unknown[][] = [];
|
||||
const child = { id: "child" };
|
||||
sandbox.window.gsap = {
|
||||
timeline: () => ({
|
||||
to: (...args: unknown[]) => {
|
||||
constructionCalls.push(args);
|
||||
},
|
||||
from: () => {},
|
||||
fromTo: () => {},
|
||||
set: () => {},
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
seek: () => {},
|
||||
totalTime: () => 0,
|
||||
time: () => 0,
|
||||
duration: () => 10,
|
||||
add: () => {},
|
||||
getChildren: () => [child],
|
||||
paused: () => true,
|
||||
timeScale: () => 1,
|
||||
kill: () => {},
|
||||
}),
|
||||
};
|
||||
|
||||
const timeline = sandbox.window.gsap.timeline();
|
||||
timeline.to("#box", { x: 100 });
|
||||
|
||||
expect(constructionCalls).toHaveLength(0);
|
||||
expect(timeline.getChildren()).toEqual([child]);
|
||||
expect(constructionCalls).toHaveLength(1);
|
||||
expect(sandbox.window.__hfTimelinesBuilding).toBe(false);
|
||||
});
|
||||
|
||||
it("proxy is non-thenable — Promise.resolve(proxy) resolves immediately", async () => {
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: Record<string, unknown>;
|
||||
__hfTimelinesBuilding?: boolean;
|
||||
gsap?: { timeline: () => Record<string, unknown> };
|
||||
requestAnimationFrame: typeof requestAnimationFrame;
|
||||
setTimeout: typeof setTimeout;
|
||||
};
|
||||
document: Record<string, never>;
|
||||
CustomEvent: typeof CustomEvent;
|
||||
} = {
|
||||
window: {
|
||||
requestAnimationFrame: (() => 1) as typeof requestAnimationFrame,
|
||||
setTimeout: (() => 1) as typeof setTimeout,
|
||||
},
|
||||
document: {},
|
||||
CustomEvent,
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
sandbox.window.CustomEvent = sandbox.CustomEvent;
|
||||
|
||||
new Function("window", "document", "CustomEvent", `with (window) {\n${HF_EARLY_STUB}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
sandbox.CustomEvent,
|
||||
);
|
||||
|
||||
sandbox.window.gsap = {
|
||||
timeline: () => ({
|
||||
to: () => {},
|
||||
from: () => {},
|
||||
fromTo: () => {},
|
||||
set: () => {},
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
seek: () => {},
|
||||
totalTime: () => 0,
|
||||
time: () => 0,
|
||||
duration: () => 10,
|
||||
add: () => {},
|
||||
getChildren: () => [],
|
||||
paused: () => true,
|
||||
timeScale: () => 1,
|
||||
kill: () => {},
|
||||
then: (_resolve: () => void) => {
|
||||
throw new Error("Real then() was called — proxy is thenable");
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const timeline = sandbox.window.gsap.timeline();
|
||||
const resolved = await Promise.resolve(timeline);
|
||||
expect(resolved).toBe(timeline);
|
||||
});
|
||||
|
||||
it("keeps bridge duration at zero until the runtime publishes render readiness", () => {
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: { seek?: (t: number) => void; duration?: number };
|
||||
__player?: { renderSeek: (t: number) => void; getDuration: () => number };
|
||||
__renderReady?: boolean;
|
||||
__hfTimelinesBuilding?: boolean;
|
||||
setInterval: typeof setInterval;
|
||||
clearInterval: typeof clearInterval;
|
||||
};
|
||||
document: { querySelector: () => { getAttribute: (name: string) => string | null } };
|
||||
} = {
|
||||
window: {
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
},
|
||||
document: {
|
||||
querySelector: () => ({
|
||||
getAttribute: (name: string) => (name === "data-duration" ? "15" : null),
|
||||
}),
|
||||
},
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
sandbox.window.__player = {
|
||||
renderSeek: () => {},
|
||||
getDuration: () => 0,
|
||||
};
|
||||
|
||||
new Function("window", "document", `with (window) {\n${HF_BRIDGE_SCRIPT}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
);
|
||||
|
||||
expect(sandbox.window.__hf?.duration).toBe(0);
|
||||
|
||||
sandbox.window.__renderReady = true;
|
||||
expect(sandbox.window.__hf?.duration).toBe(15);
|
||||
|
||||
sandbox.window.__hfTimelinesBuilding = true;
|
||||
expect(sandbox.window.__hf?.duration).toBe(0);
|
||||
});
|
||||
|
||||
it("derives duration from sub-composition data-start + data-duration when root has none", () => {
|
||||
const sandbox: any = {
|
||||
window: {
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
},
|
||||
document: {
|
||||
querySelector: () => ({
|
||||
getAttribute: () => null,
|
||||
}),
|
||||
querySelectorAll: () => [
|
||||
{
|
||||
getAttribute: (n: string) =>
|
||||
n === "data-start" ? "0" : n === "data-duration" ? "5" : null,
|
||||
},
|
||||
{
|
||||
getAttribute: (n: string) =>
|
||||
n === "data-start" ? "5" : n === "data-duration" ? "8" : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
sandbox.window.__player = {
|
||||
renderSeek: () => {},
|
||||
getDuration: () => 0,
|
||||
};
|
||||
sandbox.window.__renderReady = true;
|
||||
|
||||
new Function("window", "document", `with (window) {\n${HF_BRIDGE_SCRIPT}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
);
|
||||
|
||||
expect(sandbox.window.__hf?.duration).toBe(13);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,892 @@
|
||||
// fallow-ignore-file code-duplication complexity
|
||||
/**
|
||||
* File Server for Render Mode
|
||||
*
|
||||
* Lightweight HTTP server that serves the project directory inside Docker.
|
||||
* Key responsibility: inject the verified Hyperframe runtime + render mode extension
|
||||
* into index.html on-the-fly, so Puppeteer can load the composition with
|
||||
* all relative URLs (compositions, CSS, JS, assets) resolving correctly.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { join, extname, resolve, sep } from "node:path";
|
||||
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
|
||||
import { fpsToNumber, type Fps } from "@hyperframes/core";
|
||||
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
|
||||
import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
|
||||
export { injectScriptsAtHeadStart };
|
||||
|
||||
type PathModuleLike = {
|
||||
resolve: (...segments: string[]) => string;
|
||||
sep: string;
|
||||
};
|
||||
|
||||
type IsPathInsideOptions = {
|
||||
resolveSymlinks?: boolean;
|
||||
/**
|
||||
* Path module used for resolution and separator comparison. Defaults to
|
||||
* `node:path` for the running platform. Tests inject `path.win32` /
|
||||
* `path.posix` to exercise cross-platform behavior on a single OS.
|
||||
*/
|
||||
pathModule?: PathModuleLike;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true iff `child` is the same as, or nested inside, `parent` after
|
||||
* path normalization. Used to reject path-traversal attempts (e.g.
|
||||
* GET `/../etc/passwd`) before opening any file.
|
||||
*
|
||||
* `path.join(root, "..")` normalizes traversal segments and can escape `root`
|
||||
* entirely, so the join return value alone is not a safe guard. Callers must
|
||||
* resolve both sides and compare prefixes with the platform separator
|
||||
* appended to `parent` to avoid `/foo` matching `/foobar`.
|
||||
*
|
||||
* Exported for unit tests; not part of the public package surface.
|
||||
*/
|
||||
export function isPathInside(
|
||||
child: string,
|
||||
parent: string,
|
||||
options: IsPathInsideOptions = {},
|
||||
): boolean {
|
||||
const { resolveSymlinks = false, pathModule } = options;
|
||||
const resolveFn = pathModule?.resolve ?? resolve;
|
||||
const separator = pathModule?.sep ?? sep;
|
||||
const resolvedChild = resolveFn(child);
|
||||
const resolvedParent = resolveFn(parent);
|
||||
const normalizedChild =
|
||||
resolveSymlinks && existsSync(resolvedChild)
|
||||
? realpathSync.native(resolvedChild)
|
||||
: resolvedChild;
|
||||
const normalizedParent =
|
||||
resolveSymlinks && existsSync(resolvedParent)
|
||||
? realpathSync.native(resolvedParent)
|
||||
: resolvedParent;
|
||||
if (normalizedChild === normalizedParent) return true;
|
||||
const parentWithSep = normalizedParent.endsWith(separator)
|
||||
? normalizedParent
|
||||
: normalizedParent + separator;
|
||||
return normalizedChild.startsWith(parentWithSep);
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".cube": "text/plain; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".aac": "audio/aac",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of parsing a `Range:` request header against a known total size.
|
||||
*
|
||||
* - `kind: "satisfiable"`: `start <= end < size`. The response should be 206
|
||||
* with `Content-Range: bytes start-end/size` and the sliced body.
|
||||
* - `kind: "unsatisfiable"`: the header was syntactically valid (`bytes=...`)
|
||||
* but the resolved range falls outside `[0, size)` (e.g. `start >= size`,
|
||||
* `end < start`, or a suffix request on a zero-byte file). Per RFC 7233
|
||||
* the response should be 416 with `Content-Range: bytes (asterisk)/size`.
|
||||
* - `kind: "absent"`: there is no `Range:` header on the request, or it is
|
||||
* syntactically malformed, uses a non-`bytes` unit, or requests multiple
|
||||
* ranges. RFC 7233 allows ignoring such headers and serving the full body
|
||||
* with a 200, which is what callers should do.
|
||||
*/
|
||||
export type RangeRequest =
|
||||
| { kind: "satisfiable"; start: number; end: number }
|
||||
| { kind: "unsatisfiable" }
|
||||
| { kind: "absent" };
|
||||
|
||||
/**
|
||||
* Parse a single-range `Range:` request header per RFC 7233 §2.1.
|
||||
*
|
||||
* Supports the three forms of `bytes=...`:
|
||||
* - `bytes=START-END`: closed range, both bounds inclusive.
|
||||
* - `bytes=START-`: open-ended, serve from START to EOF.
|
||||
* - `bytes=-SUFFIX`: last SUFFIX bytes.
|
||||
*
|
||||
* Multi-range requests (`bytes=0-99,200-299`) are treated as `absent`. The
|
||||
* caller serves the full body with 200. The hyperframes producer's use case
|
||||
* (Chrome `<video>` seeks, range-aware media stack) only ever issues single
|
||||
* ranges, so we don't take on the multipart-byteranges complexity here.
|
||||
*
|
||||
* Exported for unit tests; not part of the public package surface.
|
||||
*/
|
||||
export function parseRangeHeader(header: string | null | undefined, size: number): RangeRequest {
|
||||
if (!header) return { kind: "absent" };
|
||||
const match = /^\s*bytes\s*=\s*(.*?)\s*$/i.exec(header);
|
||||
if (!match) return { kind: "absent" };
|
||||
const specList = match[1];
|
||||
if (!specList || specList.includes(",")) {
|
||||
// Multi-range: bail to full-body 200 rather than reassemble
|
||||
// multipart/byteranges. Single-range is the only shape we serve.
|
||||
return { kind: "absent" };
|
||||
}
|
||||
const dashIdx = specList.indexOf("-");
|
||||
if (dashIdx < 0) return { kind: "absent" };
|
||||
const rawStart = specList.slice(0, dashIdx).trim();
|
||||
const rawEnd = specList.slice(dashIdx + 1).trim();
|
||||
|
||||
// Suffix form: `bytes=-N` returns the last N bytes.
|
||||
if (rawStart === "" && rawEnd !== "") {
|
||||
if (!/^\d+$/.test(rawEnd)) return { kind: "absent" };
|
||||
const suffixLen = Number(rawEnd);
|
||||
if (!Number.isFinite(suffixLen)) return { kind: "absent" };
|
||||
if (size === 0 || suffixLen === 0) return { kind: "unsatisfiable" };
|
||||
const start = Math.max(0, size - suffixLen);
|
||||
return { kind: "satisfiable", start, end: size - 1 };
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(rawStart)) return { kind: "absent" };
|
||||
const start = Number(rawStart);
|
||||
if (!Number.isFinite(start)) return { kind: "absent" };
|
||||
|
||||
// Open-ended form: `bytes=START-` returns from START to EOF.
|
||||
if (rawEnd === "") {
|
||||
if (start >= size) return { kind: "unsatisfiable" };
|
||||
return { kind: "satisfiable", start, end: size - 1 };
|
||||
}
|
||||
|
||||
// Closed form: `bytes=START-END`
|
||||
if (!/^\d+$/.test(rawEnd)) return { kind: "absent" };
|
||||
const requestedEnd = Number(rawEnd);
|
||||
if (!Number.isFinite(requestedEnd)) return { kind: "absent" };
|
||||
if (requestedEnd < start) return { kind: "unsatisfiable" };
|
||||
if (start >= size) return { kind: "unsatisfiable" };
|
||||
// Clamp the end to the last valid byte.
|
||||
const end = Math.min(requestedEnd, size - 1);
|
||||
return { kind: "satisfiable", start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link buildVirtualTimeShim}.
|
||||
*/
|
||||
export interface VirtualTimeShimOptions {
|
||||
/**
|
||||
* When `true`, the shim additionally replaces `Math.random` and
|
||||
* `crypto.getRandomValues` with a Mulberry32-seeded PRNG keyed by the
|
||||
* current frame's virtual time. Compositions that call `Math.random()`
|
||||
* during render then produce byte-identical pixels across machines and
|
||||
* across replays of the same `(planDir, chunkIndex)` pair.
|
||||
*
|
||||
* Default `false`: leaves `Math.random` / `crypto.getRandomValues` native,
|
||||
* preserving the in-process renderer's non-deterministic behavior for
|
||||
* compositions that rely on it.
|
||||
*/
|
||||
seedRandomFromFrame: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the page-side virtual-time shim script.
|
||||
*
|
||||
* The shim freezes `Date.now`, `performance.now`, and the rAF/setTimeout
|
||||
* pipeline so a render seek can deterministically advance the page's
|
||||
* notion of "now". The renderer issues `__HF_VIRTUAL_TIME__.seekToTime(ms)`
|
||||
* before every frame capture; everything timing-related on the page sees
|
||||
* exactly `ms` until the next seek.
|
||||
*
|
||||
* When `options.seedRandomFromFrame` is `true`, the returned script also
|
||||
* installs a seeded `Math.random` / `crypto.getRandomValues` keyed by the
|
||||
* current virtual time — so compositions with stochastic visuals retry
|
||||
* identically. When `false`, the shim emits no random-override code; the
|
||||
* page's native `Math.random` is left alone (the in-process default).
|
||||
*/
|
||||
export function buildVirtualTimeShim(options: VirtualTimeShimOptions): string {
|
||||
const seedRandomFromFrame = options.seedRandomFromFrame === true;
|
||||
// The seeded-RNG block is gated at build time so the unlocked shim is
|
||||
// byte-identical to the pre-flag form. Producer regression baselines
|
||||
// compare on rendered pixels — but the file-server unit tests in
|
||||
// `fileServer.test.ts` also string-match `VIRTUAL_TIME_SHIM`, and we want
|
||||
// those matches to remain stable.
|
||||
const seededRandomBlock = seedRandomFromFrame
|
||||
? String.raw`
|
||||
// Seeded Math.random / crypto.getRandomValues, keyed by virtual time.
|
||||
// Mulberry32 — single uint32 state, deterministic, fast.
|
||||
var rngState = 0;
|
||||
function mulberry32() {
|
||||
rngState |= 0; rngState = (rngState + 0x6D2B79F5) | 0;
|
||||
var t = rngState;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
function reseedRngFromTime(ms) {
|
||||
var ms32 = Math.max(0, Math.floor(Number(ms) || 0)) | 0;
|
||||
// Knuth's multiplicative hash + golden-ratio offset — gives a well-
|
||||
// distributed seed even for frame 0 (otherwise rngState=0 degenerates
|
||||
// the PRNG's first few outputs).
|
||||
rngState = (Math.imul(ms32, -1640531527) + 0x9E3779B9) | 0;
|
||||
}
|
||||
reseedRngFromTime(0);
|
||||
try {
|
||||
Math.random = function() { return mulberry32(); };
|
||||
} catch (e) {}
|
||||
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
||||
try {
|
||||
var __seededGetRandomValues = function(arr) {
|
||||
if (!arr || typeof arr.byteLength !== "number" || !arr.buffer) return arr;
|
||||
var byteLen = arr.byteLength;
|
||||
if (byteLen <= 0) return arr;
|
||||
var view = new DataView(arr.buffer, arr.byteOffset, byteLen);
|
||||
var i = 0;
|
||||
for (; i + 4 <= byteLen; i += 4) {
|
||||
var word = ((mulberry32() * 4294967296) >>> 0);
|
||||
view.setUint32(i, word, true);
|
||||
}
|
||||
for (; i < byteLen; i++) {
|
||||
view.setUint8(i, (mulberry32() * 256) | 0);
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
window.crypto.getRandomValues = __seededGetRandomValues;
|
||||
} catch (e) {}
|
||||
}
|
||||
`
|
||||
: "";
|
||||
// The seekToTime hook reseeds when seeding is on; under seedRandomFromFrame=false
|
||||
// we emit no extra call so the function body is byte-identical to the
|
||||
// unseeded shim.
|
||||
const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
|
||||
return String.raw`(function() {
|
||||
if (window.__HF_VIRTUAL_TIME__) return;
|
||||
|
||||
var virtualNowMs = 0;
|
||||
var rafId = 1;
|
||||
var rafQueue = [];
|
||||
var OriginalDate = Date;
|
||||
var originalSetTimeout = window.setTimeout.bind(window);
|
||||
var originalClearTimeout = window.clearTimeout.bind(window);
|
||||
var originalSetInterval = window.setInterval.bind(window);
|
||||
var originalClearInterval = window.clearInterval.bind(window);
|
||||
var originalRequestAnimationFrame = window.requestAnimationFrame
|
||||
? window.requestAnimationFrame.bind(window)
|
||||
: null;
|
||||
var originalCancelAnimationFrame = window.cancelAnimationFrame
|
||||
? window.cancelAnimationFrame.bind(window)
|
||||
: null;
|
||||
${seededRandomBlock}
|
||||
function flushAnimationFrame() {
|
||||
if (!rafQueue.length) return;
|
||||
var current = rafQueue.slice();
|
||||
rafQueue.length = 0;
|
||||
for (var i = 0; i < current.length; i++) {
|
||||
var entry = current[i];
|
||||
if (entry.cancelled) continue;
|
||||
try {
|
||||
entry.callback(virtualNowMs);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function VirtualDate() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
if (!(this instanceof VirtualDate)) {
|
||||
return OriginalDate.apply(null, args.length ? args : [virtualNowMs]);
|
||||
}
|
||||
var instance = args.length ? new (Function.prototype.bind.apply(OriginalDate, [null].concat(args)))() : new OriginalDate(virtualNowMs);
|
||||
Object.setPrototypeOf(instance, VirtualDate.prototype);
|
||||
return instance;
|
||||
}
|
||||
|
||||
VirtualDate.prototype = OriginalDate.prototype;
|
||||
Object.setPrototypeOf(VirtualDate, OriginalDate);
|
||||
VirtualDate.now = function() { return virtualNowMs; };
|
||||
VirtualDate.parse = OriginalDate.parse.bind(OriginalDate);
|
||||
VirtualDate.UTC = OriginalDate.UTC.bind(OriginalDate);
|
||||
|
||||
try {
|
||||
Object.defineProperty(window, "Date", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: VirtualDate,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
if (window.performance && typeof window.performance.now === "function") {
|
||||
try {
|
||||
Object.defineProperty(window.performance, "now", {
|
||||
configurable: true,
|
||||
value: function() { return virtualNowMs; },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
window.requestAnimationFrame = function(callback) {
|
||||
if (typeof callback !== "function") return 0;
|
||||
var entry = { id: rafId++, callback: callback, cancelled: false };
|
||||
rafQueue.push(entry);
|
||||
return entry.id;
|
||||
};
|
||||
window.cancelAnimationFrame = function(id) {
|
||||
for (var i = 0; i < rafQueue.length; i++) {
|
||||
if (rafQueue[i].id === id) {
|
||||
rafQueue[i].cancelled = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.__HF_VIRTUAL_TIME__ = {
|
||||
originalSetTimeout: originalSetTimeout,
|
||||
originalClearTimeout: originalClearTimeout,
|
||||
originalSetInterval: originalSetInterval,
|
||||
originalClearInterval: originalClearInterval,
|
||||
originalRequestAnimationFrame: originalRequestAnimationFrame,
|
||||
originalCancelAnimationFrame: originalCancelAnimationFrame,
|
||||
seekToTime: function(nextTimeMs) {
|
||||
var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
|
||||
virtualNowMs = safeTimeMs;
|
||||
${seekToTimeReseedCall}flushAnimationFrame();
|
||||
return virtualNowMs;
|
||||
},
|
||||
getTime: function() {
|
||||
return virtualNowMs;
|
||||
},
|
||||
};
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default in-process virtual-time shim — `seedRandomFromFrame: false`.
|
||||
* Existing call sites (`renderOrchestrator`, `probeStage`) import this
|
||||
* constant. Distributed callers build their own with seeding enabled.
|
||||
*/
|
||||
const VIRTUAL_TIME_SHIM = buildVirtualTimeShim({ seedRandomFromFrame: false });
|
||||
|
||||
/**
|
||||
* Render mode extension -- adds renderSeek() for frame-accurate seeking
|
||||
* without media sync (videos are replaced with frame images during render).
|
||||
*/
|
||||
const RENDER_SEEK_MODE =
|
||||
process.env.PRODUCER_RUNTIME_RENDER_SEEK_MODE === "strict-boundary"
|
||||
? "strict-boundary"
|
||||
: "preview-phase";
|
||||
const RENDER_SEEK_DIAGNOSTICS = process.env.PRODUCER_DEBUG_SEEK_DIAGNOSTICS === "true";
|
||||
const RENDER_SEEK_STEP = Math.max(
|
||||
1 / 600,
|
||||
Number(process.env.PRODUCER_RENDER_SEEK_STEP || 1 / 120),
|
||||
);
|
||||
const RENDER_SEEK_OFFSET_FRACTION = Math.max(
|
||||
0,
|
||||
Math.min(0.95, Number(process.env.PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION || 0.5)),
|
||||
);
|
||||
|
||||
function resolveRenderFpsConfig(fps: Fps | undefined): {
|
||||
value: number;
|
||||
source: "render-options" | "default";
|
||||
fallbackReason?: "missing" | "invalid";
|
||||
} {
|
||||
if (!fps) return { value: 30, source: "default", fallbackReason: "missing" };
|
||||
const value = fpsToNumber(fps);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return { value: 30, source: "default", fallbackReason: "invalid" };
|
||||
}
|
||||
return { value, source: "render-options" };
|
||||
}
|
||||
|
||||
function buildRenderModeScript(fps: Fps | undefined): string {
|
||||
const renderFps = resolveRenderFpsConfig(fps);
|
||||
return `(function() {
|
||||
var __realSetTimeout =
|
||||
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetTimeout === "function"
|
||||
? window.__HF_VIRTUAL_TIME__.originalSetTimeout
|
||||
: window.setTimeout.bind(window);
|
||||
var __seekMode = ${JSON.stringify(RENDER_SEEK_MODE)};
|
||||
var __seekDiagnostics = ${RENDER_SEEK_DIAGNOSTICS ? "true" : "false"};
|
||||
var __seekStep = ${RENDER_SEEK_STEP};
|
||||
var __seekOffsetFraction = ${RENDER_SEEK_OFFSET_FRACTION};
|
||||
var __renderFps = ${renderFps.value};
|
||||
var __renderFpsSource = ${JSON.stringify(renderFps.source)};
|
||||
var __renderFpsFallbackReason = ${JSON.stringify(renderFps.fallbackReason ?? null)};
|
||||
window.__HF_EXPORT_RENDER_SEEK_CONFIG = {
|
||||
mode: __seekMode,
|
||||
diagnostics: __seekDiagnostics,
|
||||
step: __seekStep,
|
||||
offsetFraction: __seekOffsetFraction,
|
||||
fps: __renderFps,
|
||||
fpsSource: __renderFpsSource,
|
||||
fpsFallbackReason: __renderFpsFallbackReason || undefined,
|
||||
owner: "runtime",
|
||||
};
|
||||
function installMediaFallbackPlayer() {
|
||||
if (document.querySelector('[data-composition-id]')) return false;
|
||||
var mediaEls = Array.from(document.querySelectorAll('video, audio'));
|
||||
if (!mediaEls.length) return false;
|
||||
|
||||
var isPlaying = false;
|
||||
var currentTime = 0;
|
||||
function fallbackDuration() {
|
||||
var maxDuration = 0;
|
||||
for (var i = 0; i < mediaEls.length; i++) {
|
||||
var d = Number(mediaEls[i].duration);
|
||||
if (isFinite(d) && d > maxDuration) maxDuration = d;
|
||||
}
|
||||
return Math.max(0, maxDuration);
|
||||
}
|
||||
function syncFallbackMedia(time, playing) {
|
||||
for (var i = 0; i < mediaEls.length; i++) {
|
||||
var media = mediaEls[i];
|
||||
var existing = Number(media.currentTime) || 0;
|
||||
if (Math.abs(existing - time) > 0.3) {
|
||||
try { media.currentTime = time; } catch (e) {}
|
||||
}
|
||||
if (playing) {
|
||||
if (media.paused) {
|
||||
media.play().catch(function() {});
|
||||
}
|
||||
} else if (!media.paused) {
|
||||
media.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var basePlayer = window.__player && typeof window.__player === 'object' ? window.__player : {};
|
||||
window.__player = {
|
||||
...basePlayer,
|
||||
_timeline: null,
|
||||
play: function() {
|
||||
isPlaying = true;
|
||||
syncFallbackMedia(currentTime, true);
|
||||
},
|
||||
pause: function() {
|
||||
isPlaying = false;
|
||||
syncFallbackMedia(currentTime, false);
|
||||
},
|
||||
seek: function(time) {
|
||||
var safeTime = Math.max(0, Number(time) || 0);
|
||||
currentTime = safeTime;
|
||||
isPlaying = false;
|
||||
syncFallbackMedia(safeTime, false);
|
||||
},
|
||||
renderSeek: function(time) {
|
||||
var safeTime = Math.max(0, Number(time) || 0);
|
||||
currentTime = safeTime;
|
||||
isPlaying = false;
|
||||
syncFallbackMedia(safeTime, false);
|
||||
},
|
||||
getTime: function() {
|
||||
var primary = mediaEls[0];
|
||||
if (!primary) return currentTime;
|
||||
var t = Number(primary.currentTime);
|
||||
return isFinite(t) ? t : currentTime;
|
||||
},
|
||||
getDuration: function() {
|
||||
return fallbackDuration();
|
||||
},
|
||||
isPlaying: function() {
|
||||
return isPlaying;
|
||||
},
|
||||
};
|
||||
window.__playerReady = true;
|
||||
// Media-fallback player has no timeline to bind, so render-ready is immediate.
|
||||
// init.ts defers __renderReady until the timeline is bound — different runtime.
|
||||
window.__renderReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitForPlayer() {
|
||||
var hasComposition = Boolean(document.querySelector('[data-composition-id]'));
|
||||
if (hasComposition) {
|
||||
if (window.__player && typeof window.__player.renderSeek === "function") {
|
||||
window.__playerReady = true;
|
||||
return;
|
||||
}
|
||||
__realSetTimeout(waitForPlayer, 50);
|
||||
return;
|
||||
}
|
||||
if (installMediaFallbackPlayer()) {
|
||||
return;
|
||||
}
|
||||
__realSetTimeout(waitForPlayer, 50);
|
||||
}
|
||||
waitForPlayer();
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Early stub: ensures `window.__hf` exists *before* any user `<script>` in
|
||||
* `<body>` executes, and batches GSAP timeline construction via
|
||||
* requestAnimationFrame to prevent the main-thread hang described in
|
||||
* https://github.com/heygen-com/hyperframes/issues/1231.
|
||||
*
|
||||
* Source: packages/producer/stubs/hf-early-stub.ts
|
||||
* Generated: packages/producer/src/generated/hf-early-stub-inline.ts
|
||||
* Injected at the very start of `<head>` so it runs before all other scripts.
|
||||
*/
|
||||
const HF_EARLY_STUB = getHfEarlyStub();
|
||||
|
||||
/**
|
||||
* Page-side compositing opt-in flag stub.
|
||||
*
|
||||
* When the engine is launched with `enablePageSideCompositing: true`, the
|
||||
* orchestrator injects this stub into the very top of every served HTML
|
||||
* page. The flag is read by `@hyperframes/shader-transitions`' engine-mode
|
||||
* `init()` to switch from the default opacity-flip mode (which leaves
|
||||
* shader blending to the Node side via the hf#677 layered pipeline) to a
|
||||
* page-side WebGL compositor that runs the shader inside Chrome and
|
||||
* exposes a single opaque RGB frame for the engine to capture.
|
||||
*
|
||||
* Sentinel ONLY — no logic here. The compositor itself ships inside
|
||||
* `@hyperframes/shader-transitions` and is loaded by the composition's
|
||||
* regular script bundle.
|
||||
*
|
||||
* Default OFF: when the flag is not set, behavior is byte-identical to
|
||||
* the existing layered path.
|
||||
*/
|
||||
export const HF_PAGE_SIDE_COMPOSITING_STUB = `(function() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.__HF_PAGE_SIDE_COMPOSITING__ = true;
|
||||
})();`;
|
||||
|
||||
/**
|
||||
* Bridge script: maps window.__player (Hyperframe runtime) → window.__hf (engine protocol).
|
||||
* Injected after RENDER_MODE_SCRIPT so the engine's frameCapture can find window.__hf.
|
||||
*
|
||||
* This script *patches* the existing __hf object rather than replacing it, so
|
||||
* fields written during page-script execution (e.g. transitions metadata from
|
||||
* @hyperframes/shader-transitions) are preserved through to engine query time.
|
||||
*/
|
||||
const HF_BRIDGE_SCRIPT = `(function() {
|
||||
var __realSetInterval =
|
||||
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetInterval === "function"
|
||||
? window.__HF_VIRTUAL_TIME__.originalSetInterval
|
||||
: window.setInterval.bind(window);
|
||||
var __realClearInterval =
|
||||
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalClearInterval === "function"
|
||||
? window.__HF_VIRTUAL_TIME__.originalClearInterval
|
||||
: window.clearInterval.bind(window);
|
||||
function getDeclaredDuration() {
|
||||
var root = document.querySelector('[data-composition-id]');
|
||||
if (!root) return 0;
|
||||
var d = Number(root.getAttribute('data-duration'));
|
||||
if (Number.isFinite(d) && d > 0) return d;
|
||||
var comps = document.querySelectorAll('[data-composition-src]');
|
||||
var maxEnd = 0;
|
||||
for (var i = 0; i < comps.length; i++) {
|
||||
var start = Number(comps[i].getAttribute('data-start')) || 0;
|
||||
var dur = Number(comps[i].getAttribute('data-duration')) || 0;
|
||||
if (dur > 0) maxEnd = Math.max(maxEnd, start + dur);
|
||||
}
|
||||
if (maxEnd > 0) console.warn('[HF Bridge] No root data-duration; derived ' + maxEnd + 's from sub-compositions');
|
||||
return maxEnd;
|
||||
}
|
||||
function seekSameOriginChildFrames(frameWindow, nextTimeMs) {
|
||||
var frames;
|
||||
try {
|
||||
frames = frameWindow.frames;
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
if (!frames || typeof frames.length !== "number") return;
|
||||
for (var i = 0; i < frames.length; i++) {
|
||||
var childWindow = null;
|
||||
try {
|
||||
childWindow = frames[i];
|
||||
if (!childWindow || childWindow === frameWindow) continue;
|
||||
if (
|
||||
childWindow.__HF_VIRTUAL_TIME__ &&
|
||||
typeof childWindow.__HF_VIRTUAL_TIME__.seekToTime === "function"
|
||||
) {
|
||||
childWindow.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
|
||||
}
|
||||
} catch (_error) {
|
||||
continue;
|
||||
}
|
||||
seekSameOriginChildFrames(childWindow, nextTimeMs);
|
||||
}
|
||||
}
|
||||
function bridge() {
|
||||
var p = window.__player;
|
||||
if (!p || typeof p.renderSeek !== "function" || typeof p.getDuration !== "function") {
|
||||
return false;
|
||||
}
|
||||
var hf = window.__hf || {};
|
||||
Object.defineProperty(hf, "duration", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function() {
|
||||
// While the GSAP tween-batching interceptor (HF_EARLY_STUB) is draining
|
||||
// queued tweens via rAF, the real timelines are still empty. Return 0
|
||||
// here so pollHfReady in the engine keeps waiting (its condition is
|
||||
// __hf.duration > 0), preventing the capture pipeline from seeking
|
||||
// empty timelines and producing blank/incorrect frames.
|
||||
if (window.__hfTimelinesBuilding) return 0;
|
||||
if (!window.__renderReady) return 0;
|
||||
var d = p.getDuration();
|
||||
return d > 0 ? d : getDeclaredDuration();
|
||||
},
|
||||
});
|
||||
hf.seek = function(t, options) {
|
||||
p.renderSeek(t, options);
|
||||
var nextTimeMs = (Math.max(0, Number(t) || 0)) * 1000;
|
||||
if (window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.seekToTime === "function") {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
|
||||
}
|
||||
seekSameOriginChildFrames(window, nextTimeMs);
|
||||
};
|
||||
window.__hf = hf;
|
||||
return true;
|
||||
}
|
||||
if (bridge()) return;
|
||||
var iv = __realSetInterval(function() {
|
||||
if (bridge()) __realClearInterval(iv);
|
||||
}, 50);
|
||||
})();`;
|
||||
|
||||
export interface FileServerOptions {
|
||||
projectDir: string;
|
||||
compiledDir?: string;
|
||||
port?: number;
|
||||
/** Scripts injected into <head> of every served HTML file before authored scripts. */
|
||||
preHeadScripts?: string[];
|
||||
/** Scripts injected into <head> of index.html. Default: verified Hyperframe runtime. */
|
||||
headScripts?: string[];
|
||||
/** Scripts injected before </body> of index.html. Default: render mode extension. */
|
||||
bodyScripts?: string[];
|
||||
/** Actual render fps so page-side runtime quantization matches the output container. */
|
||||
fps?: Fps;
|
||||
/** Strip embedded runtime scripts from HTML before injection. Default: true. */
|
||||
stripEmbeddedRuntime?: boolean;
|
||||
}
|
||||
|
||||
export interface FileServerHandle {
|
||||
url: string;
|
||||
port: number;
|
||||
close: () => void;
|
||||
addPreHeadScript: (script: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a file server handle, swallowing and logging any error.
|
||||
*
|
||||
* `FileServerHandle.close` tears down the underlying http.Server, whose
|
||||
* `close()` throws `ERR_SERVER_NOT_RUNNING` if the server is already torn down
|
||||
* (for example a cancellation path that closed it once already). An unguarded
|
||||
* throw inside a cleanup or `finally` block would mask the original render or
|
||||
* plan result, so cleanup callers must go through this instead of calling
|
||||
* `close()` directly.
|
||||
*/
|
||||
export function closeFileServerSafely(
|
||||
fileServer: Pick<FileServerHandle, "close">,
|
||||
label: string,
|
||||
log: ProducerLogger = defaultLogger,
|
||||
): void {
|
||||
try {
|
||||
fileServer.close();
|
||||
} catch (err) {
|
||||
log.warn(`[${label}] file server close failed`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
|
||||
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
|
||||
|
||||
// HF_EARLY_STUB must run before *any* page script so libraries that write
|
||||
// to window.__hf during page-script execution (e.g. shader-transitions
|
||||
// populating __hf.transitions) find it already defined. The full bridge in
|
||||
// bodyScripts later upgrades this stub with `seek` / `duration` once the
|
||||
// Hyperframe runtime's __player is ready, while preserving any fields
|
||||
// already written.
|
||||
const preHeadScripts = [HF_EARLY_STUB, ...(options.preHeadScripts ?? [])];
|
||||
// Default scripts: Hyperframe runtime in <head>, render mode in </body>
|
||||
const headScripts = options.headScripts ?? [getVerifiedHyperframeRuntimeSource()];
|
||||
const bodyScripts = options.bodyScripts ?? [buildRenderModeScript(options.fps), HF_BRIDGE_SCRIPT];
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/*", async (c) => {
|
||||
let requestPath = c.req.path;
|
||||
if (requestPath === "/") requestPath = "/index.html";
|
||||
|
||||
const relativePath = requestPath
|
||||
.replace(/^\//, "")
|
||||
.split("/")
|
||||
.map((seg) => {
|
||||
try {
|
||||
return decodeURIComponent(seg);
|
||||
} catch {
|
||||
return seg;
|
||||
}
|
||||
})
|
||||
.join("/");
|
||||
|
||||
// Resolve against compiledDir first (preferred — overrides project files
|
||||
// for compositions emitted by the build), then projectDir as fallback.
|
||||
// Each candidate is rejected if `..` segments push it outside the
|
||||
// intended root: `path.join` normalizes traversal but does not enforce
|
||||
// containment, so a request like `GET /../etc/passwd` would otherwise
|
||||
// be served straight off the filesystem. Keep this lexical so project
|
||||
// symlinks to sibling asset directories behave like preview mode.
|
||||
let filePath: string | null = null;
|
||||
if (compiledDir) {
|
||||
const candidate = join(compiledDir, relativePath);
|
||||
if (
|
||||
existsSync(candidate) &&
|
||||
isPathInside(candidate, compiledDir) &&
|
||||
statSync(candidate).isFile()
|
||||
) {
|
||||
filePath = candidate;
|
||||
}
|
||||
}
|
||||
if (!filePath) {
|
||||
const candidate = join(projectDir, relativePath);
|
||||
if (
|
||||
existsSync(candidate) &&
|
||||
isPathInside(candidate, projectDir) &&
|
||||
statSync(candidate).isFile()
|
||||
) {
|
||||
filePath = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
if (!/favicon\.ico$/i.test(requestPath)) {
|
||||
console.warn(`[FileServer] 404 Not Found: ${requestPath}`);
|
||||
}
|
||||
return c.text("Not found", 404);
|
||||
}
|
||||
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
if (ext === ".html") {
|
||||
// Use the async read here so we don't block the Node event loop while
|
||||
// reading an HTML file (typically small, but a 200KB+ AI-generated
|
||||
// composition during a concurrent render still costs a ms of stall).
|
||||
// The injection step is sync — it's pure string ops on the buffered
|
||||
// HTML — but the read itself is the only step that touches the disk.
|
||||
const rawHtml = await readFile(filePath, "utf-8");
|
||||
const isIndex = relativePath === "index.html";
|
||||
let html = rawHtml;
|
||||
if (preHeadScripts.length > 0) {
|
||||
html = injectScriptsAtHeadStart(html, preHeadScripts);
|
||||
}
|
||||
html = isIndex
|
||||
? injectScriptsIntoHtml(html, headScripts, bodyScripts, stripEmbeddedRuntime)
|
||||
: html;
|
||||
return c.text(html, 200, { "Content-Type": contentType });
|
||||
}
|
||||
|
||||
// Stream binary file content rather than buffering it with readFileSync.
|
||||
// On video-heavy compositions Chrome requests several 32MB video files
|
||||
// back-to-back through this server; each readFileSync(32MB) blocked the
|
||||
// Node event loop long enough to wedge concurrent /health responses (see
|
||||
// renderOrchestrator.ts:1277-1306 documenting the same regression class).
|
||||
// createReadStream() pipes bounded chunks asynchronously, so the event
|
||||
// loop stays responsive even when several large assets are in flight
|
||||
// simultaneously. Chrome reassembles the chunks transparently.
|
||||
//
|
||||
// We also honor `Range:` requests (RFC 7233) so Chrome's <video> element
|
||||
// can seek into and partial-load large media without re-pulling the whole
|
||||
// file. `Accept-Ranges: bytes` is advertised on every response (including
|
||||
// full-body 200s) so the client knows ranges are supported.
|
||||
const stat = statSync(filePath);
|
||||
const totalSize = stat.size;
|
||||
const rangeHeader = c.req.header("range");
|
||||
const rangeRequest = parseRangeHeader(rangeHeader, totalSize);
|
||||
|
||||
if (rangeRequest.kind === "unsatisfiable") {
|
||||
// 416 Range Not Satisfiable. RFC 7233 §4.4 mandates `Content-Range`
|
||||
// carry the total length as `bytes */<size>` so clients know how to
|
||||
// re-issue a valid range.
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Range": `bytes */${totalSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (rangeRequest.kind === "satisfiable") {
|
||||
const { start, end } = rangeRequest;
|
||||
const length = end - start + 1;
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
const webStream = Readable.toWeb(stream) as unknown as ReadableStream;
|
||||
return new Response(webStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(length),
|
||||
"Content-Range": `bytes ${start}-${end}/${totalSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No Range header (or malformed/multi-range): full-body 200 with
|
||||
// Accept-Ranges advertised so the client knows future Range requests
|
||||
// are supported. Node Readable -> Web ReadableStream so Hono's
|
||||
// Response can consume it. Node 18+ supports Readable.toWeb directly.
|
||||
const stream = createReadStream(filePath);
|
||||
const webStream = Readable.toWeb(stream) as unknown as ReadableStream;
|
||||
return new Response(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(totalSize),
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Track open connections so we can force-destroy them on close.
|
||||
// Without this, server.close() waits for keep-alive connections to
|
||||
// drain, holding the Node.js event loop open indefinitely.
|
||||
const connections = new Set<IncomingMessage["socket"]>();
|
||||
|
||||
// @hono/node-server serve() returns the http.Server directly.
|
||||
// Register the connection tracker before the listen callback fires
|
||||
// to avoid missing early connections.
|
||||
// Bind loopback only (SECURITY F-001, matching the studio/preview servers
|
||||
// in cli/server/portUtils.ts): this is an internal capture transport for
|
||||
// the co-located headless Chrome (the URL above is already localhost), so
|
||||
// it must not listen on 0.0.0.0 where an IDE's port auto-forward surfaces
|
||||
// it as a transient, breakage-prone "preview".
|
||||
const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, (info) => {
|
||||
resolve({
|
||||
url: `http://localhost:${info.port}`,
|
||||
port: info.port,
|
||||
addPreHeadScript: (script: string) => {
|
||||
preHeadScripts.push(script);
|
||||
},
|
||||
close: () => {
|
||||
for (const socket of connections) socket.destroy();
|
||||
connections.clear();
|
||||
server.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
server.on("connection", (socket: IncomingMessage["socket"]) => {
|
||||
connections.add(socket);
|
||||
socket.on("close", () => connections.delete(socket));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { HF_BRIDGE_SCRIPT, HF_EARLY_STUB, VIRTUAL_TIME_SHIM };
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { compressToWoff2, fontToDataUri } from "./fontCompression.js";
|
||||
|
||||
/**
|
||||
* Locate a system TTF font for real compression tests. macOS ships plenty
|
||||
* of .ttf files in /System/Library/Fonts/Supplemental; we pick the first
|
||||
* one found from a short list. Returns null on Linux CI or any environment
|
||||
* without the expected fonts — those tests are skipped gracefully.
|
||||
*/
|
||||
function findSystemTtf(): Buffer | null {
|
||||
const candidates = [
|
||||
"/System/Library/Fonts/Supplemental/Andale Mono.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Courier New.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return readFileSync(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("compressToWoff2", () => {
|
||||
it("compresses a TTF buffer to a smaller woff2 buffer", async () => {
|
||||
const ttf = findSystemTtf();
|
||||
if (!ttf) {
|
||||
console.warn("Skipping: no system TTF font available");
|
||||
return;
|
||||
}
|
||||
const woff2 = await compressToWoff2(ttf);
|
||||
expect(woff2).toBeInstanceOf(Buffer);
|
||||
expect(woff2.length).toBeGreaterThan(0);
|
||||
expect(woff2.length).toBeLessThan(ttf.length);
|
||||
});
|
||||
|
||||
it("throws on invalid input", async () => {
|
||||
const garbage = Buffer.from("this is not a font file");
|
||||
await expect(compressToWoff2(garbage)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fontToDataUri", () => {
|
||||
it("skips compression for woff2 input and returns a data URI", async () => {
|
||||
const raw = Buffer.from("fake-woff2-bytes");
|
||||
const uri = await fontToDataUri(raw, "woff2");
|
||||
const expectedBase64 = raw.toString("base64");
|
||||
expect(uri).toBe(`data:font/woff2;base64,${expectedBase64}`);
|
||||
});
|
||||
|
||||
it("compresses a TTF and returns a woff2 data URI", async () => {
|
||||
const ttf = findSystemTtf();
|
||||
if (!ttf) {
|
||||
console.warn("Skipping: no system TTF font available");
|
||||
return;
|
||||
}
|
||||
const uri = await fontToDataUri(ttf, "ttf");
|
||||
expect(uri).toMatch(/^data:font\/woff2;base64,/);
|
||||
const naiveLength = `data:font/ttf;base64,${ttf.toString("base64")}`.length;
|
||||
expect(uri.length).toBeLessThan(naiveLength);
|
||||
});
|
||||
|
||||
it("falls back to raw ttf on compression failure", async () => {
|
||||
const garbage = Buffer.from("this is not a font file");
|
||||
const uri = await fontToDataUri(garbage, "ttf");
|
||||
expect(uri).toMatch(/^data:font\/ttf;base64,/);
|
||||
});
|
||||
|
||||
it("falls back with correct MIME type for otf", async () => {
|
||||
const garbage = Buffer.from("not a font");
|
||||
const uri = await fontToDataUri(garbage, "otf");
|
||||
expect(uri).toMatch(/^data:font\/otf;base64,/);
|
||||
});
|
||||
|
||||
it("falls back with correct MIME type for ttc", async () => {
|
||||
const garbage = Buffer.from("not a font");
|
||||
const uri = await fontToDataUri(garbage, "ttc");
|
||||
expect(uri).toMatch(/^data:font\/collection;base64,/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// @ts-expect-error -- wawoff2 ships no type declarations; ambient .d.ts only visible to producer's own tsconfig
|
||||
import wawoff2 from "wawoff2";
|
||||
|
||||
const { compress } = wawoff2 as {
|
||||
compress: (input: Buffer | Uint8Array) => Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
export async function compressToWoff2(input: Buffer): Promise<Buffer> {
|
||||
return Buffer.from(await compress(input));
|
||||
}
|
||||
|
||||
const RAW_MIME_TYPES: Record<string, string> = {
|
||||
otf: "font/otf",
|
||||
ttc: "font/collection",
|
||||
};
|
||||
|
||||
function rawMimeType(format: string): string {
|
||||
return RAW_MIME_TYPES[format] ?? "font/ttf";
|
||||
}
|
||||
|
||||
export async function fontToDataUri(input: Buffer, originalFormat: string): Promise<string> {
|
||||
if (originalFormat === "woff2") {
|
||||
return `data:font/woff2;base64,${input.toString("base64")}`;
|
||||
}
|
||||
try {
|
||||
const compressed = await compressToWoff2(input);
|
||||
return `data:font/woff2;base64,${compressed.toString("base64")}`;
|
||||
} catch {
|
||||
console.warn(
|
||||
`[fontCompression] woff2 compression failed for ${originalFormat} font, embedding raw format`,
|
||||
);
|
||||
return `data:${rawMimeType(originalFormat)};base64,${input.toString("base64")}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/services/frameCapture.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
createCaptureSession,
|
||||
initializeSession,
|
||||
closeCaptureSession,
|
||||
captureFrame,
|
||||
captureFrameToBuffer,
|
||||
getCompositionDuration,
|
||||
getCapturePerfSummary,
|
||||
prepareCaptureSessionForReuse,
|
||||
type CaptureOptions,
|
||||
type CaptureResult,
|
||||
type CaptureBufferResult,
|
||||
type CapturePerfSummary,
|
||||
type CaptureSession,
|
||||
type BeforeCaptureHook,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
__resetMaxFrameIndexCacheForTests,
|
||||
clearMaxFrameIndex,
|
||||
getMaxFrameIndex,
|
||||
getMaxFrameIndexCacheSize,
|
||||
MAX_ENTRIES,
|
||||
} from "./frameDirCache.js";
|
||||
|
||||
// Frame-directory max-index cache (Chunk 5B / 9E).
|
||||
//
|
||||
// These tests exercise the *cross-job isolation contract*: the cache MUST be
|
||||
// shared inside a single render job (so we don't re-readdir the same directory
|
||||
// for every frame), but it MUST NOT grow monotonically across renders. The
|
||||
// render orchestrator achieves this by calling `clearMaxFrameIndex` for every
|
||||
// directory it registered, in its outer `finally`. Here we verify that the
|
||||
// primitives that contract relies on actually behave as advertised.
|
||||
|
||||
const FRAME_RE_NOTE =
|
||||
"directory layout matches what we extract via FFmpeg's `frame_%04d.png` pattern";
|
||||
|
||||
function createFrameDir(prefix: string, frameCount: number): string {
|
||||
void FRAME_RE_NOTE;
|
||||
const dir = mkdtempSync(join(tmpdir(), `frame-dir-${prefix}-`));
|
||||
for (let i = 1; i <= frameCount; i++) {
|
||||
const name = `frame_${String(i).padStart(4, "0")}.png`;
|
||||
writeFileSync(join(dir, name), Buffer.from([0]));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createDirWithMixedFiles(prefix: string): {
|
||||
dir: string;
|
||||
expectedMax: number;
|
||||
} {
|
||||
const dir = mkdtempSync(join(tmpdir(), `frame-dir-${prefix}-`));
|
||||
// Real frame files (max index = 7).
|
||||
writeFileSync(join(dir, "frame_0001.png"), Buffer.from([0]));
|
||||
writeFileSync(join(dir, "frame_0007.png"), Buffer.from([0]));
|
||||
// Files that must be ignored: wrong extension, wrong prefix, no zero pad,
|
||||
// double-extension, and a subdirectory.
|
||||
writeFileSync(join(dir, "frame_0099.jpg"), Buffer.from([0]));
|
||||
writeFileSync(join(dir, "Frame_0100.png"), Buffer.from([0])); // case-sensitive
|
||||
writeFileSync(join(dir, "thumb_0050.png"), Buffer.from([0]));
|
||||
writeFileSync(join(dir, "frame_0042.png.bak"), Buffer.from([0]));
|
||||
writeFileSync(join(dir, "frame_.png"), Buffer.from([0])); // empty index group
|
||||
mkdirSync(join(dir, "frame_0500"));
|
||||
return { dir, expectedMax: 7 };
|
||||
}
|
||||
|
||||
describe("frameDirMaxIndexCache", () => {
|
||||
const dirsToClean: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
__resetMaxFrameIndexCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetMaxFrameIndexCacheForTests();
|
||||
while (dirsToClean.length > 0) {
|
||||
const dir = dirsToClean.pop();
|
||||
if (!dir) continue;
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort tmp cleanup — tests still pass if rm fails (e.g. macOS
|
||||
// SIP, root-owned files left over from a crashed prior run).
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the correct max frame index for a populated directory", () => {
|
||||
const dir = createFrameDir("populated", 150);
|
||||
dirsToClean.push(dir);
|
||||
|
||||
expect(getMaxFrameIndex(dir)).toBe(150);
|
||||
});
|
||||
|
||||
it("ignores files that do not match the frame_NNNN.png pattern", () => {
|
||||
const { dir, expectedMax } = createDirWithMixedFiles("mixed");
|
||||
dirsToClean.push(dir);
|
||||
|
||||
expect(getMaxFrameIndex(dir)).toBe(expectedMax);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty directory and caches that result", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "frame-dir-empty-"));
|
||||
dirsToClean.push(dir);
|
||||
|
||||
expect(getMaxFrameIndex(dir)).toBe(0);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(1);
|
||||
// Second call must still be 0 and must not grow the cache.
|
||||
expect(getMaxFrameIndex(dir)).toBe(0);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for a missing directory and caches that result", () => {
|
||||
const missing = join(tmpdir(), `frame-dir-missing-${Date.now()}-${Math.random()}`);
|
||||
|
||||
expect(getMaxFrameIndex(missing)).toBe(0);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(1);
|
||||
});
|
||||
|
||||
it("caches the first read so subsequent readdir-mutations are not observed", () => {
|
||||
const dir = createFrameDir("cached", 10);
|
||||
dirsToClean.push(dir);
|
||||
|
||||
expect(getMaxFrameIndex(dir)).toBe(10);
|
||||
// Add more frames *after* the first read. Because we cache aggressively,
|
||||
// the next call must still return the original max — this is the
|
||||
// intra-job invariant the orchestrator relies on for performance, and is
|
||||
// exactly why we MUST clear entries between jobs.
|
||||
writeFileSync(join(dir, "frame_0500.png"), Buffer.from([0]));
|
||||
writeFileSync(join(dir, "frame_0999.png"), Buffer.from([0]));
|
||||
expect(getMaxFrameIndex(dir)).toBe(10);
|
||||
});
|
||||
|
||||
it("clearMaxFrameIndex forces a re-read on the next call", () => {
|
||||
const dir = createFrameDir("clear-then-reread", 5);
|
||||
dirsToClean.push(dir);
|
||||
|
||||
expect(getMaxFrameIndex(dir)).toBe(5);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(1);
|
||||
|
||||
writeFileSync(join(dir, "frame_0050.png"), Buffer.from([0]));
|
||||
// Without clearing we still get the cached value.
|
||||
expect(getMaxFrameIndex(dir)).toBe(5);
|
||||
|
||||
expect(clearMaxFrameIndex(dir)).toBe(true);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(0);
|
||||
|
||||
// After clearing, the cache reads the directory again and picks up the
|
||||
// newly-added frame.
|
||||
expect(getMaxFrameIndex(dir)).toBe(50);
|
||||
});
|
||||
|
||||
it("clearMaxFrameIndex returns false when the path was not cached", () => {
|
||||
const dir = createFrameDir("never-cached", 3);
|
||||
dirsToClean.push(dir);
|
||||
|
||||
expect(clearMaxFrameIndex(dir)).toBe(false);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(0);
|
||||
});
|
||||
|
||||
it("isolates entries across multiple directories", () => {
|
||||
const dirA = createFrameDir("iso-a", 10);
|
||||
const dirB = createFrameDir("iso-b", 25);
|
||||
const dirC = createFrameDir("iso-c", 100);
|
||||
dirsToClean.push(dirA, dirB, dirC);
|
||||
|
||||
expect(getMaxFrameIndex(dirA)).toBe(10);
|
||||
expect(getMaxFrameIndex(dirB)).toBe(25);
|
||||
expect(getMaxFrameIndex(dirC)).toBe(100);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(3);
|
||||
|
||||
// Clearing one entry must not affect the others.
|
||||
expect(clearMaxFrameIndex(dirB)).toBe(true);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(2);
|
||||
|
||||
expect(getMaxFrameIndex(dirA)).toBe(10);
|
||||
expect(getMaxFrameIndex(dirC)).toBe(100);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
// ── Cross-job isolation (the contract Chunk 5B added) ────────────────────
|
||||
//
|
||||
// The render orchestrator registers one frame directory per HDR video and
|
||||
// is required to clear every entry it added in its outer `finally`. The
|
||||
// following tests model that lifecycle and verify the cache returns to
|
||||
// empty between jobs, which is what guarantees the cache cannot leak
|
||||
// memory across many consecutive renders.
|
||||
|
||||
it("cross-job isolation: cache is empty between jobs when callers honor the contract", () => {
|
||||
// Job 1: register two HDR video frame directories.
|
||||
const job1A = createFrameDir("job1-a", 30);
|
||||
const job1B = createFrameDir("job1-b", 60);
|
||||
dirsToClean.push(job1A, job1B);
|
||||
|
||||
expect(getMaxFrameIndex(job1A)).toBe(30);
|
||||
expect(getMaxFrameIndex(job1B)).toBe(60);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(2);
|
||||
|
||||
// Job 1 cleanup (outer `finally` in renderOrchestrator).
|
||||
clearMaxFrameIndex(job1A);
|
||||
clearMaxFrameIndex(job1B);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(0);
|
||||
|
||||
// Job 2: starts with a clean cache, registers a different directory.
|
||||
const job2 = createFrameDir("job2", 90);
|
||||
dirsToClean.push(job2);
|
||||
|
||||
expect(getMaxFrameIndex(job2)).toBe(90);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(1);
|
||||
|
||||
clearMaxFrameIndex(job2);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(0);
|
||||
});
|
||||
|
||||
it("cross-job isolation: cache does not grow monotonically across many jobs", () => {
|
||||
// Simulate 20 consecutive HDR renders, each registering 3 video frame
|
||||
// directories. If `clearMaxFrameIndex` is called for each one in the
|
||||
// job's cleanup path, the cache size must not exceed the size of a
|
||||
// single job's working set (3) at the steady-state checkpoint, and must
|
||||
// be empty after the final cleanup.
|
||||
for (let job = 0; job < 20; job++) {
|
||||
const dirs = [
|
||||
createFrameDir(`job${job}-a`, 10 + job),
|
||||
createFrameDir(`job${job}-b`, 20 + job),
|
||||
createFrameDir(`job${job}-c`, 30 + job),
|
||||
];
|
||||
dirsToClean.push(...dirs);
|
||||
|
||||
for (const dir of dirs) getMaxFrameIndex(dir);
|
||||
// Steady-state during the job: exactly the working set, never the
|
||||
// accumulated total across all prior jobs.
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(3);
|
||||
|
||||
for (const dir of dirs) clearMaxFrameIndex(dir);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("cross-job isolation: a job that forgets to clear leaks exactly its own entries (regression bound)", () => {
|
||||
// This test documents (and pins) the failure mode the contract guards
|
||||
// against. A buggy job that registers directories without calling
|
||||
// `clearMaxFrameIndex` MUST leak only the entries it owned — not the
|
||||
// entries of unrelated, well-behaved jobs. If this invariant ever
|
||||
// breaks (e.g. because someone adds a global side effect to the
|
||||
// cache), this test will catch it.
|
||||
const wellBehavedDir = createFrameDir("well-behaved", 5);
|
||||
dirsToClean.push(wellBehavedDir);
|
||||
getMaxFrameIndex(wellBehavedDir);
|
||||
clearMaxFrameIndex(wellBehavedDir);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(0);
|
||||
|
||||
const leakyA = createFrameDir("leaky-a", 11);
|
||||
const leakyB = createFrameDir("leaky-b", 22);
|
||||
dirsToClean.push(leakyA, leakyB);
|
||||
getMaxFrameIndex(leakyA);
|
||||
getMaxFrameIndex(leakyB);
|
||||
// Buggy job exits without calling clearMaxFrameIndex. The cache leaks
|
||||
// exactly the two entries the leaky job added — no more, no fewer.
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
// ── Bounded-size LRU cap (defense in depth, PR #381) ─────────────────────
|
||||
//
|
||||
// The render orchestrator's `finally` is the primary mechanism that keeps
|
||||
// the cache from leaking across jobs. The MAX_ENTRIES cap exists for the
|
||||
// hypothetical future code path that forgets to call clearMaxFrameIndex
|
||||
// — instead of unbounded growth, the cache self-limits with LRU eviction.
|
||||
//
|
||||
// These tests use synthetic non-existent paths because getMaxFrameIndex
|
||||
// gracefully records 0 for missing directories, which exercises the same
|
||||
// insertion + eviction code path as a populated readdir without paying
|
||||
// 1000 mkdtempSync calls per test.
|
||||
|
||||
it("bounded LRU: cache size never exceeds MAX_ENTRIES under sustained insert pressure", () => {
|
||||
// Exceed the cap by 50% to make sure eviction runs many times, not just
|
||||
// once. Each insert past the cap MUST evict exactly one prior entry so
|
||||
// the size sits at MAX_ENTRIES forever.
|
||||
const overflow = Math.floor(MAX_ENTRIES * 1.5);
|
||||
for (let i = 0; i < overflow; i++) {
|
||||
getMaxFrameIndex(`/tmp/frame-cap-test-${i}`);
|
||||
}
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(MAX_ENTRIES);
|
||||
});
|
||||
|
||||
it("bounded LRU: oldest entries are evicted first, newest are retained", () => {
|
||||
// Fill the cache exactly to capacity, then insert N more. The first N
|
||||
// entries (the oldest) must have been evicted; the last MAX_ENTRIES
|
||||
// entries (the newest) must still be cached.
|
||||
for (let i = 0; i < MAX_ENTRIES; i++) {
|
||||
getMaxFrameIndex(`/tmp/frame-evict-test-${i}`);
|
||||
}
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(MAX_ENTRIES);
|
||||
|
||||
const overflowCount = 10;
|
||||
for (let i = MAX_ENTRIES; i < MAX_ENTRIES + overflowCount; i++) {
|
||||
getMaxFrameIndex(`/tmp/frame-evict-test-${i}`);
|
||||
}
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(MAX_ENTRIES);
|
||||
|
||||
// Indices [0, overflowCount) were the oldest → evicted.
|
||||
for (let i = 0; i < overflowCount; i++) {
|
||||
expect(clearMaxFrameIndex(`/tmp/frame-evict-test-${i}`)).toBe(false);
|
||||
}
|
||||
// Indices [overflowCount, MAX_ENTRIES + overflowCount) survive.
|
||||
expect(clearMaxFrameIndex(`/tmp/frame-evict-test-${overflowCount}`)).toBe(true);
|
||||
expect(clearMaxFrameIndex(`/tmp/frame-evict-test-${MAX_ENTRIES + overflowCount - 1}`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounded LRU: re-accessing an entry bumps its recency and protects it from eviction", () => {
|
||||
// Without LRU bookkeeping, the first inserted entry would be the next
|
||||
// one evicted. The delete-and-reinsert dance in getMaxFrameIndex is what
|
||||
// keeps frequently-touched entries alive — verify by re-accessing the
|
||||
// first entry, then triggering an eviction, and confirming the second
|
||||
// entry was the one that got dropped instead.
|
||||
for (let i = 0; i < MAX_ENTRIES; i++) {
|
||||
getMaxFrameIndex(`/tmp/frame-lru-test-${i}`);
|
||||
}
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(MAX_ENTRIES);
|
||||
|
||||
getMaxFrameIndex(`/tmp/frame-lru-test-0`);
|
||||
|
||||
getMaxFrameIndex(`/tmp/frame-lru-test-${MAX_ENTRIES}`);
|
||||
expect(getMaxFrameIndexCacheSize()).toBe(MAX_ENTRIES);
|
||||
|
||||
expect(clearMaxFrameIndex(`/tmp/frame-lru-test-0`)).toBe(true);
|
||||
expect(clearMaxFrameIndex(`/tmp/frame-lru-test-1`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Frame Directory Max-Index Cache
|
||||
*
|
||||
* Module-scoped cache of the maximum 1-based frame index present in each
|
||||
* pre-extracted frame directory (e.g. `frame_0001.png … frame_0150.png` → 150).
|
||||
* The directory is read once on first access and the max is computed by parsing
|
||||
* filenames.
|
||||
*
|
||||
* Used by the render orchestrator to bounds-check `videoFrameIndex` against
|
||||
* the directory size before calling `existsSync` per frame, which avoids
|
||||
* redundant filesystem syscalls when the requested time falls past the last
|
||||
* extracted frame (e.g. a clip shorter than the composition's effective video
|
||||
* range).
|
||||
*
|
||||
* The cache is module-scoped on purpose: it must be shared across the many
|
||||
* frame-capture call sites within a single render job. To prevent it from
|
||||
* growing monotonically across jobs (Chunk 5B), callers MUST invoke
|
||||
* `clearMaxFrameIndex(frameDir)` for every directory they registered, in their
|
||||
* cleanup path. The render orchestrator does this in its outer `finally`.
|
||||
*
|
||||
* As defense in depth (PR #381 review feedback), the cache also enforces a
|
||||
* hard MAX_ENTRIES cap with LRU eviction. The orchestrator's `finally`
|
||||
* remains the primary boundedness mechanism; the LRU cap exists so that a
|
||||
* future code path which forgets to call `clearMaxFrameIndex` cannot leak
|
||||
* memory without bound — it self-limits to a working set ~100× larger than a
|
||||
* single job needs.
|
||||
*
|
||||
* Lives in its own module (rather than as a private to renderOrchestrator.ts)
|
||||
* so the cross-job isolation contract can be unit-tested directly.
|
||||
*/
|
||||
|
||||
import { readdirSync } from "fs";
|
||||
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
const FRAME_FILENAME_RE = /^frame_(\d+)\.png$/;
|
||||
|
||||
/**
|
||||
* Hard upper bound on cached entries. Sized at ~100× the working set of a
|
||||
* single render job (which typically registers <10 frame directories, one
|
||||
* per HDR video source) so that well-behaved callers never trip the cap.
|
||||
*
|
||||
* Worst-case resident size: 1000 × (~64-byte path string + ~24-byte map
|
||||
* entry) ≈ 88 KB. Cheap insurance.
|
||||
*
|
||||
* Exported for observability and tests. Production code MUST NOT branch on
|
||||
* this value to gate behavior — the cap is intentionally invisible to
|
||||
* callers that follow the clearMaxFrameIndex contract.
|
||||
*/
|
||||
export const MAX_ENTRIES = 1000;
|
||||
|
||||
/**
|
||||
* Returns the maximum 1-based frame index found in `frameDir`, computed by
|
||||
* parsing `frame_NNNN.png` filenames. Subsequent calls with the same path
|
||||
* return the cached value without touching the filesystem. Returns 0 if the
|
||||
* directory is missing, unreadable, or contains no frame files.
|
||||
*
|
||||
* On every access (hit or miss), the entry is bumped to most-recently-used so
|
||||
* that the LRU-eviction path under cache pressure removes the entry that has
|
||||
* been idle longest, not whichever one happened to be inserted earliest.
|
||||
*/
|
||||
export function getMaxFrameIndex(frameDir: string): number {
|
||||
const cached = cache.get(frameDir);
|
||||
if (cached !== undefined) {
|
||||
// Bump recency: Map preserves insertion order for iteration, so
|
||||
// delete-then-set moves the entry to the end (most-recently-used). This
|
||||
// turns the simple Map into an O(1) approximate LRU without pulling in
|
||||
// a doubly-linked list.
|
||||
cache.delete(frameDir);
|
||||
cache.set(frameDir, cached);
|
||||
return cached;
|
||||
}
|
||||
let max = 0;
|
||||
try {
|
||||
for (const name of readdirSync(frameDir)) {
|
||||
const m = FRAME_FILENAME_RE.exec(name);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
} catch {
|
||||
// Directory missing or unreadable → max stays 0; downstream existsSync
|
||||
// check will still produce the right "no frame" outcome.
|
||||
}
|
||||
// Evict the oldest entry before inserting when at cap. Map.keys().next()
|
||||
// returns the first inserted key, which after our delete-and-reinsert
|
||||
// dance on hits is also the least-recently-used entry.
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(frameDir, max);
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the cached max-index for a single directory. Called by the render
|
||||
* orchestrator in its cleanup path so that subsequent jobs do not inherit
|
||||
* stale entries (or worse, hold references to torn-down workDir paths).
|
||||
*
|
||||
* Returns `true` if an entry was removed, `false` if the path was not cached.
|
||||
*/
|
||||
export function clearMaxFrameIndex(frameDir: string): boolean {
|
||||
return cache.delete(frameDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current number of cached entries. Intended for tests and
|
||||
* diagnostic logging only — production code should not branch on this value.
|
||||
*/
|
||||
export function getMaxFrameIndexCacheSize(): number {
|
||||
return cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every cached entry. Intended exclusively for tests that need to
|
||||
* reset module state between cases. Production code MUST use
|
||||
* `clearMaxFrameIndex` for the directories it owns.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function __resetMaxFrameIndexCacheForTests(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ElementStackingInfo } from "@hyperframes/engine";
|
||||
import { selectDomLayerShowIds } from "./hdrCompositor.js";
|
||||
|
||||
function makeEl(id: string, overrides?: Partial<ElementStackingInfo>): ElementStackingInfo {
|
||||
return {
|
||||
id,
|
||||
zIndex: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
layoutWidth: 1920,
|
||||
layoutHeight: 1080,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
renderFrameVisible: false,
|
||||
isHdr: false,
|
||||
transform: "none",
|
||||
borderRadius: [0, 0, 0, 0],
|
||||
objectFit: "fill",
|
||||
objectPosition: "50% 50%",
|
||||
clipRect: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectDomLayerShowIds", () => {
|
||||
it("does not re-show hidden DOM elements while preserving visible injected video frames", () => {
|
||||
expect(
|
||||
selectDomLayerShowIds(
|
||||
["visible-overlay", "hidden-later-scene", "hidden-sdr-video"],
|
||||
[
|
||||
makeEl("visible-overlay"),
|
||||
makeEl("hidden-later-scene", { visible: false }),
|
||||
makeEl("hidden-sdr-video", {
|
||||
visible: false,
|
||||
renderFrameVisible: true,
|
||||
}),
|
||||
],
|
||||
),
|
||||
).toEqual(["visible-overlay", "hidden-sdr-video"]);
|
||||
});
|
||||
|
||||
it("does not re-show opacity-zero scene members or their injected frames", () => {
|
||||
expect(
|
||||
selectDomLayerShowIds(
|
||||
["active-overlay", "inactive-scene-video", "inactive-scene-label"],
|
||||
[
|
||||
makeEl("active-overlay"),
|
||||
makeEl("inactive-scene-video", {
|
||||
opacity: 0,
|
||||
visible: false,
|
||||
renderFrameVisible: true,
|
||||
}),
|
||||
makeEl("inactive-scene-label", {
|
||||
opacity: 0,
|
||||
visible: true,
|
||||
}),
|
||||
],
|
||||
),
|
||||
).toEqual(["active-overlay"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* HDR Compositor — pixel-level compositing primitives for the HDR
|
||||
* layered render path.
|
||||
*
|
||||
* Extracted from `renderOrchestrator.ts` so the ~600 LOC of HDR-specific
|
||||
* buffer manipulation, video/image blit logic, and per-frame compositor
|
||||
* live in a focused module that can be tested and evolved independently.
|
||||
*
|
||||
* Consumers: `captureHdrStage.ts`, `captureHdrSequentialLoop.ts`,
|
||||
* `captureHdrHybridLoop.ts`, `captureHdrFrameShared.ts`,
|
||||
* `captureHdrResources.ts`.
|
||||
*/
|
||||
|
||||
import { readSync, closeSync } from "fs";
|
||||
import { join } from "path";
|
||||
import {
|
||||
type CaptureSession,
|
||||
type BeforeCaptureHook,
|
||||
type HdrTransfer,
|
||||
type ElementStackingInfo,
|
||||
type HfTransitionMeta,
|
||||
captureAlphaPng,
|
||||
applyDomLayerMask,
|
||||
removeDomLayerMask,
|
||||
decodePng,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
groupIntoLayers,
|
||||
blitRgb48leAffine,
|
||||
parseTransformMatrix,
|
||||
convertTransfer,
|
||||
} from "@hyperframes/engine";
|
||||
import type { ProducerLogger } from "../logger.js";
|
||||
import { type HdrImageTransferCache } from "./hdrImageTransferCache.js";
|
||||
import { writeFileExclusiveSync } from "./render/shared.js";
|
||||
import { type HdrPerfCollector, timeHdrPhase, timeHdrPhaseAsync } from "./render/hdrPerf.js";
|
||||
|
||||
// ─── Diagnostic helpers ────────────────────────────────────────────────────
|
||||
|
||||
// Diagnostic helpers used by the HDR layered compositor when KEEP_TEMP=1
|
||||
// is set. They are pure (capture no state), so we keep them at module scope
|
||||
// to avoid re-creating closures per frame and to make them callable from
|
||||
// any future composite path that needs to log non-zero pixel counts.
|
||||
function countNonZeroAlpha(rgba: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let p = 3; p < rgba.length; p += 4) {
|
||||
if (rgba[p] !== 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countNonZeroRgb48(buf: Uint8Array): number {
|
||||
let n = 0;
|
||||
for (let p = 0; p < buf.length; p += 6) {
|
||||
if (
|
||||
buf[p] !== 0 ||
|
||||
buf[p + 1] !== 0 ||
|
||||
buf[p + 2] !== 0 ||
|
||||
buf[p + 3] !== 0 ||
|
||||
buf[p + 4] !== 0 ||
|
||||
buf[p + 5] !== 0
|
||||
)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
const TRANSFORM_IDENTITY_EPSILON = 0.001;
|
||||
const OPAQUE_ALPHA_THRESHOLD = 0.999;
|
||||
const RGB48_BYTES_PER_PIXEL = 6;
|
||||
|
||||
type AffineMatrix = [number, number, number, number, number, number];
|
||||
|
||||
function isAffineMatrix(m: number[]): m is AffineMatrix {
|
||||
return m.length === 6;
|
||||
}
|
||||
|
||||
function resolveBlitOpacity(opacity: number): number | undefined {
|
||||
return opacity < OPAQUE_ALPHA_THRESHOLD ? opacity : undefined;
|
||||
}
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Metadata for a shader transition between two scenes, extracted from
|
||||
* `window.__hf.transitions`. Re-exported from the engine so the producer
|
||||
* shares the contract with composition runtime code.
|
||||
*/
|
||||
export type HdrTransitionMeta = HfTransitionMeta;
|
||||
|
||||
/** Pre-computed frame range for an active transition. */
|
||||
export interface TransitionRange extends HdrTransitionMeta {
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
}
|
||||
|
||||
// ─── Video frame source ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crop an rgb48le buffer to a sub-region. Returns a new Buffer containing
|
||||
* only the cropped pixels.
|
||||
*/
|
||||
function cropRgb48le(
|
||||
src: Buffer,
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
cropX: number,
|
||||
cropY: number,
|
||||
cropW: number,
|
||||
cropH: number,
|
||||
): Buffer {
|
||||
const dst = Buffer.alloc(cropW * cropH * RGB48_BYTES_PER_PIXEL);
|
||||
for (let row = 0; row < cropH; row++) {
|
||||
const srcRow = cropY + row;
|
||||
if (srcRow < 0 || srcRow >= srcH) continue;
|
||||
const srcOff = (srcRow * srcW + cropX) * RGB48_BYTES_PER_PIXEL;
|
||||
const dstOff = row * cropW * RGB48_BYTES_PER_PIXEL;
|
||||
const copyLen = Math.min(cropW, srcW - cropX) * RGB48_BYTES_PER_PIXEL;
|
||||
if (copyLen > 0) src.copy(dst, dstOff, srcOff, srcOff + copyLen);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit a single HDR video layer onto an rgb48le canvas.
|
||||
*
|
||||
* Shared between the normal-frame compositing path (compositeToBuffer)
|
||||
* and the transition dual-scene compositing loop to avoid duplicating
|
||||
* the frame lookup, raw read, transfer, transform, and blit logic.
|
||||
*/
|
||||
export interface HdrVideoFrameSource {
|
||||
dir: string;
|
||||
rawPath: string;
|
||||
fd: number;
|
||||
width: number;
|
||||
height: number;
|
||||
frameSize: number;
|
||||
frameCount: number;
|
||||
scratch: Buffer;
|
||||
}
|
||||
|
||||
export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void {
|
||||
try {
|
||||
closeSync(source.fd);
|
||||
} catch (err) {
|
||||
log?.warn("Failed to close HDR raw frame file", {
|
||||
rawPath: source.rawPath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function blitHdrVideoLayer(
|
||||
canvas: Buffer,
|
||||
el: ElementStackingInfo,
|
||||
time: number,
|
||||
fps: number,
|
||||
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>,
|
||||
hdrStartTimes: Map<string, number>,
|
||||
width: number,
|
||||
height: number,
|
||||
log?: ProducerLogger,
|
||||
sourceTransfer?: HdrTransfer,
|
||||
targetTransfer?: HdrTransfer,
|
||||
hdrPerf?: HdrPerfCollector,
|
||||
): void {
|
||||
const frameSource = hdrVideoFrameSources.get(el.id);
|
||||
const startTime = hdrStartTimes.get(el.id);
|
||||
if (!frameSource || startTime === undefined || el.opacity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frame index within the video. Clamp to the extracted raw frame count so
|
||||
// a composition that outlives the source clip freezes on the last frame,
|
||||
// matching Chrome's <video> behavior.
|
||||
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
|
||||
if (videoFrameIndex < 1) return;
|
||||
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
|
||||
if (effectiveIndex < 1) return;
|
||||
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
|
||||
|
||||
try {
|
||||
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;
|
||||
const bytesRead = timeHdrPhase(hdrPerf, "hdrVideoReadDecodeMs", () =>
|
||||
readSync(frameSource.fd, frameSource.scratch, 0, frameSource.frameSize, frameOffset),
|
||||
);
|
||||
if (bytesRead !== frameSource.frameSize) return;
|
||||
const hdrRgb = frameSource.scratch;
|
||||
const srcW = frameSource.width;
|
||||
const srcH = frameSource.height;
|
||||
|
||||
// Convert between HDR transfer functions if source doesn't match output
|
||||
if (sourceTransfer && targetTransfer && sourceTransfer !== targetTransfer) {
|
||||
timeHdrPhase(hdrPerf, "hdrVideoTransferMs", () =>
|
||||
convertTransfer(hdrRgb, sourceTransfer, targetTransfer),
|
||||
);
|
||||
}
|
||||
|
||||
const rawMatrix = parseTransformMatrix(el.transform);
|
||||
const matrix = rawMatrix && isAffineMatrix(rawMatrix) ? rawMatrix : null;
|
||||
|
||||
const br = el.borderRadius;
|
||||
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
|
||||
const borderRadiusParam = hasBorderRadius ? br : undefined;
|
||||
|
||||
let blitX = el.x;
|
||||
let blitY = el.y;
|
||||
let blitSrcX = 0;
|
||||
let blitSrcY = 0;
|
||||
let blitW = srcW;
|
||||
let blitH = srcH;
|
||||
let clipped = false;
|
||||
|
||||
if (el.clipRect) {
|
||||
const cr = el.clipRect;
|
||||
const cx1 = Math.max(blitX, cr.x);
|
||||
const cy1 = Math.max(blitY, cr.y);
|
||||
const cx2 = Math.min(blitX + blitW, cr.x + cr.width);
|
||||
const cy2 = Math.min(blitY + blitH, cr.y + cr.height);
|
||||
if (cx2 <= cx1 || cy2 <= cy1) return;
|
||||
blitSrcX = cx1 - blitX;
|
||||
blitSrcY = cy1 - blitY;
|
||||
blitW = cx2 - cx1;
|
||||
blitH = cy2 - cy1;
|
||||
blitX = cx1;
|
||||
blitY = cy1;
|
||||
clipped = true;
|
||||
}
|
||||
|
||||
const isTranslationOnly = !!(
|
||||
matrix &&
|
||||
Math.abs(matrix[0] - 1) < TRANSFORM_IDENTITY_EPSILON &&
|
||||
Math.abs(matrix[1]) < TRANSFORM_IDENTITY_EPSILON &&
|
||||
Math.abs(matrix[2]) < TRANSFORM_IDENTITY_EPSILON &&
|
||||
Math.abs(matrix[3] - 1) < TRANSFORM_IDENTITY_EPSILON
|
||||
);
|
||||
|
||||
timeHdrPhase(hdrPerf, "hdrVideoBlitMs", () => {
|
||||
if (matrix && !isTranslationOnly) {
|
||||
if (clipped && log) {
|
||||
log.debug(
|
||||
`HDR clip rect on affine-transformed element ${el.id} — clip not applied (affine scissor not yet supported)`,
|
||||
);
|
||||
}
|
||||
blitRgb48leAffine(
|
||||
canvas,
|
||||
hdrRgb,
|
||||
matrix,
|
||||
srcW,
|
||||
srcH,
|
||||
width,
|
||||
height,
|
||||
resolveBlitOpacity(el.opacity),
|
||||
borderRadiusParam,
|
||||
);
|
||||
} else if (clipped) {
|
||||
const croppedBuf = cropRgb48le(hdrRgb, srcW, srcH, blitSrcX, blitSrcY, blitW, blitH);
|
||||
blitRgb48leRegion(
|
||||
canvas,
|
||||
croppedBuf,
|
||||
blitX,
|
||||
blitY,
|
||||
blitW,
|
||||
blitH,
|
||||
width,
|
||||
height,
|
||||
resolveBlitOpacity(el.opacity),
|
||||
borderRadiusParam,
|
||||
);
|
||||
} else {
|
||||
blitRgb48leRegion(
|
||||
canvas,
|
||||
hdrRgb,
|
||||
el.x,
|
||||
el.y,
|
||||
srcW,
|
||||
srcH,
|
||||
width,
|
||||
height,
|
||||
resolveBlitOpacity(el.opacity),
|
||||
borderRadiusParam,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (log) {
|
||||
log.debug(`HDR blit failed for ${el.id}`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Image buffer ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-decoded HDR image buffer with its native pixel dimensions.
|
||||
*
|
||||
* Static images decode exactly once at setup time and are blitted on every
|
||||
* visible frame, unlike video frames which are read fresh per timestamp.
|
||||
*/
|
||||
export interface HdrImageBuffer {
|
||||
data: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit a single HDR image layer onto an rgb48le canvas.
|
||||
*
|
||||
* Image-equivalent of `blitHdrVideoLayer` — the buffer is pre-decoded and
|
||||
* static, so there's no time-based frame lookup or per-frame PNG read.
|
||||
*/
|
||||
export function blitHdrImageLayer(
|
||||
canvas: Buffer,
|
||||
el: ElementStackingInfo,
|
||||
hdrImageBuffers: Map<string, HdrImageBuffer>,
|
||||
hdrImageTransferCache: HdrImageTransferCache,
|
||||
width: number,
|
||||
height: number,
|
||||
log?: ProducerLogger,
|
||||
sourceTransfer?: HdrTransfer,
|
||||
targetTransfer?: HdrTransfer,
|
||||
hdrPerf?: HdrPerfCollector,
|
||||
): void {
|
||||
const buf = hdrImageBuffers.get(el.id);
|
||||
if (!buf || el.opacity <= 0) {
|
||||
return;
|
||||
}
|
||||
if (el.clipRect && log) {
|
||||
log.debug(`HDR clip rect on image element ${el.id} — clip not yet supported for images`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (hdrPerf) hdrPerf.hdrImageLayerBlits += 1;
|
||||
// The cache returns `buf.data` unchanged when no conversion is needed,
|
||||
// and otherwise returns a per-(imageId, targetTransfer) buffer that was
|
||||
// converted exactly once and reused across every subsequent frame.
|
||||
const hdrRgb = timeHdrPhase(hdrPerf, "hdrImageTransferMs", () =>
|
||||
sourceTransfer && targetTransfer
|
||||
? hdrImageTransferCache.getConverted(el.id, sourceTransfer, targetTransfer, buf.data)
|
||||
: buf.data,
|
||||
);
|
||||
|
||||
const rawMatrix = parseTransformMatrix(el.transform);
|
||||
const matrix = rawMatrix && isAffineMatrix(rawMatrix) ? rawMatrix : null;
|
||||
|
||||
const br = el.borderRadius;
|
||||
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
|
||||
const borderRadiusParam = hasBorderRadius ? br : undefined;
|
||||
|
||||
timeHdrPhase(hdrPerf, "hdrImageBlitMs", () => {
|
||||
if (matrix) {
|
||||
blitRgb48leAffine(
|
||||
canvas,
|
||||
hdrRgb,
|
||||
matrix,
|
||||
buf.width,
|
||||
buf.height,
|
||||
width,
|
||||
height,
|
||||
resolveBlitOpacity(el.opacity),
|
||||
borderRadiusParam,
|
||||
);
|
||||
} else {
|
||||
blitRgb48leRegion(
|
||||
canvas,
|
||||
hdrRgb,
|
||||
el.x,
|
||||
el.y,
|
||||
buf.width,
|
||||
buf.height,
|
||||
width,
|
||||
height,
|
||||
resolveBlitOpacity(el.opacity),
|
||||
borderRadiusParam,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (log) {
|
||||
log.debug(`HDR image blit failed for ${el.id}`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Composite transfer + strategy ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dependencies passed to `compositeHdrFrame`.
|
||||
*
|
||||
* Every field except the per-frame arguments is captured once when the HDR
|
||||
* render path opens its `try { ... }` block and reused across every frame —
|
||||
* extracting them into an explicit struct lets the helper live at module
|
||||
* scope (no closure-over-renderJob) and keeps the per-call signature small.
|
||||
*/
|
||||
export type CompositeTransfer = HdrTransfer | "srgb";
|
||||
|
||||
export function shouldUseLayeredComposite(options: {
|
||||
hasHdrContent: boolean;
|
||||
hasShaderTransitions: boolean;
|
||||
isPngSequence: boolean;
|
||||
}): boolean {
|
||||
return options.hasHdrContent || (options.hasShaderTransitions && !options.isPngSequence);
|
||||
}
|
||||
|
||||
export function resolveCompositeTransfer(
|
||||
hasHdrContent: boolean,
|
||||
effectiveHdr: { transfer: HdrTransfer } | undefined,
|
||||
): CompositeTransfer {
|
||||
return hasHdrContent && effectiveHdr ? effectiveHdr.transfer : "srgb";
|
||||
}
|
||||
|
||||
export function selectDomLayerShowIds(
|
||||
layerElementIds: string[],
|
||||
fullStacking: ElementStackingInfo[],
|
||||
): string[] {
|
||||
const byId = new Map(fullStacking.map((el) => [el.id, el]));
|
||||
return layerElementIds.filter((id) => {
|
||||
const el = byId.get(id);
|
||||
return !!el && el.opacity > 0 && (el.visible || el.renderFrameVisible === true);
|
||||
});
|
||||
}
|
||||
|
||||
export interface HdrCompositeContext {
|
||||
log: ProducerLogger;
|
||||
domSession: CaptureSession;
|
||||
beforeCaptureHook: BeforeCaptureHook | null;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
compositeTransfer: CompositeTransfer;
|
||||
nativeHdrImageIds: Set<string>;
|
||||
hdrImageBuffers: Map<string, HdrImageBuffer>;
|
||||
hdrImageTransferCache: HdrImageTransferCache;
|
||||
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>;
|
||||
hdrVideoStartTimes: Map<string, number>;
|
||||
imageTransfers: Map<string, HdrTransfer>;
|
||||
videoTransfers: Map<string, HdrTransfer>;
|
||||
debugDumpEnabled: boolean;
|
||||
debugDumpDir: string | null;
|
||||
hdrPerf?: HdrPerfCollector;
|
||||
}
|
||||
|
||||
// ─── Per-frame compositor ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Composite a single HDR frame into a pre-allocated `rgb48le` canvas.
|
||||
*
|
||||
* Bottom-to-top z-order: HDR layers are blitted directly from cached image
|
||||
* buffers / extracted video frames; DOM layers are screenshotted with a
|
||||
* mass-hide mask (so each layer paints only its own elements) and then
|
||||
* blended into the canvas via `blitRgba8OverRgb48le` in the active HDR
|
||||
* transfer space.
|
||||
*
|
||||
* The `elementFilter` parameter exists so the transition path can composite
|
||||
* each scene independently; pass `undefined` for whole-stack rendering.
|
||||
*
|
||||
* @param ctx - Long-lived dependencies (logger, browser session, dimensions,
|
||||
* HDR layer maps). Captured once per render — see
|
||||
* {@link HdrCompositeContext}.
|
||||
* @param canvas - Pre-allocated `width * height * 6` byte buffer. Caller must
|
||||
* zero-fill before every frame (this helper does not).
|
||||
* @param time - Seek time in seconds.
|
||||
* @param fullStacking - Stacking info for ALL elements at this time. Even when
|
||||
* filtering, every other element id is needed to build
|
||||
* the DOM-layer hide-list.
|
||||
* @param elementFilter - When set, only elements whose id is in the set are
|
||||
* composited.
|
||||
* @param debugFrameIndex - Frame index used to label per-layer diagnostic
|
||||
* dumps. Pass `-1` to disable per-layer dumps even
|
||||
* when `KEEP_TEMP=1` (e.g. for warmup frames).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function compositeHdrFrame(
|
||||
ctx: HdrCompositeContext,
|
||||
canvas: Buffer,
|
||||
time: number,
|
||||
fullStacking: ElementStackingInfo[],
|
||||
elementFilter?: Set<string>,
|
||||
debugFrameIndex: number = -1,
|
||||
): Promise<void> {
|
||||
const {
|
||||
log,
|
||||
domSession,
|
||||
beforeCaptureHook,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
compositeTransfer,
|
||||
nativeHdrImageIds,
|
||||
hdrImageBuffers,
|
||||
hdrImageTransferCache,
|
||||
hdrVideoFrameSources,
|
||||
hdrVideoStartTimes,
|
||||
imageTransfers,
|
||||
videoTransfers,
|
||||
debugDumpEnabled,
|
||||
debugDumpDir,
|
||||
hdrPerf,
|
||||
} = ctx;
|
||||
|
||||
const filteredStacking = elementFilter
|
||||
? fullStacking.filter((e) => elementFilter.has(e.id))
|
||||
: fullStacking;
|
||||
|
||||
// Zero-opacity elements stay in the stacking for correct hide-list
|
||||
// generation (their <img> replacements must be hidden from sibling
|
||||
// screenshots). The actual blit is skipped in the compositing loop below.
|
||||
const layers = groupIntoLayers(filteredStacking);
|
||||
const allElementIds = fullStacking.map((e) => e.id);
|
||||
|
||||
const shouldLog = debugDumpEnabled && debugFrameIndex >= 0;
|
||||
if (shouldLog) {
|
||||
log.info("[diag] compositeToBuffer plan", {
|
||||
frame: debugFrameIndex,
|
||||
time: time.toFixed(3),
|
||||
filterSize: elementFilter?.size,
|
||||
fullStackingCount: fullStacking.length,
|
||||
filteredCount: filteredStacking.length,
|
||||
layerCount: layers.length,
|
||||
layers: layers.map((l) =>
|
||||
l.type === "hdr"
|
||||
? {
|
||||
type: "hdr",
|
||||
id: l.element.id,
|
||||
z: l.element.zIndex,
|
||||
visible: l.element.visible,
|
||||
opacity: l.element.opacity,
|
||||
bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`,
|
||||
}
|
||||
: { type: "dom", ids: l.elementIds },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [layerIdx, layer] of layers.entries()) {
|
||||
if (layer.type === "hdr") {
|
||||
// Skip zero-opacity HDR elements — their parent scene may have faded out.
|
||||
if (layer.element.opacity <= 0) continue;
|
||||
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
|
||||
const isHdrImage = nativeHdrImageIds.has(layer.element.id);
|
||||
const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer;
|
||||
if (isHdrImage) {
|
||||
blitHdrImageLayer(
|
||||
canvas,
|
||||
layer.element,
|
||||
hdrImageBuffers,
|
||||
hdrImageTransferCache,
|
||||
width,
|
||||
height,
|
||||
log,
|
||||
imageTransfers.get(layer.element.id),
|
||||
hdrTargetTransfer,
|
||||
hdrPerf,
|
||||
);
|
||||
} else {
|
||||
blitHdrVideoLayer(
|
||||
canvas,
|
||||
layer.element,
|
||||
time,
|
||||
fps,
|
||||
hdrVideoFrameSources,
|
||||
hdrVideoStartTimes,
|
||||
width,
|
||||
height,
|
||||
log,
|
||||
videoTransfers.get(layer.element.id),
|
||||
hdrTargetTransfer,
|
||||
hdrPerf,
|
||||
);
|
||||
}
|
||||
if (shouldLog) {
|
||||
const after = countNonZeroRgb48(canvas);
|
||||
if (isHdrImage) {
|
||||
const buf = hdrImageBuffers.get(layer.element.id);
|
||||
log.info("[diag] hdr layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
id: layer.element.id,
|
||||
kind: "image",
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
bufferDecoded: !!buf,
|
||||
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
|
||||
});
|
||||
} else {
|
||||
const frameSource = hdrVideoFrameSources.get(layer.element.id);
|
||||
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
|
||||
const localTime = time - startTime;
|
||||
const frameNum = Math.floor(localTime * fps) + 1;
|
||||
log.info("[diag] hdr layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
id: layer.element.id,
|
||||
kind: "video",
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
startTime,
|
||||
localTime: localTime.toFixed(3),
|
||||
hdrFrameNum: frameNum,
|
||||
rawPath: frameSource?.rawPath ?? null,
|
||||
frameCount: frameSource?.frameCount ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DOM layer: capture only elements in this layer.
|
||||
//
|
||||
// Each layer gets a fresh seek + inject cycle to guarantee correct
|
||||
// visibility state — avoids fragile interactions between the frame
|
||||
// injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek.
|
||||
//
|
||||
// The mask:
|
||||
// - mass-hides every body descendant via stylesheet
|
||||
// - re-shows the layer's currently paintable elements (and their
|
||||
// descendants and injected `__render_frame_*` siblings) so
|
||||
// deep-nested content stays visible even though intermediate
|
||||
// ancestors are hidden
|
||||
// - inline-hides every other data-start element so they don't
|
||||
// paint when they happen to be descendants of a layer element
|
||||
// (most importantly: HDR videos and other-layer SDR videos
|
||||
// that live inside `#root` when capturing the root DOM layer)
|
||||
//
|
||||
// Without the mask, every DOM screenshot captures the full page
|
||||
// (root background, sibling scenes' static content, the painted
|
||||
// border/box-shadow of cards, etc.) and the resulting opaque
|
||||
// pixels overwrite previously composited HDR content beneath.
|
||||
const layerIds = new Set(layer.elementIds);
|
||||
const showIds = selectDomLayerShowIds(layer.elementIds, fullStacking);
|
||||
if (showIds.length === 0) {
|
||||
if (shouldLog) {
|
||||
log.info("[diag] dom layer skipped, all elements not paintable", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
layerIds: layer.elementIds,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
|
||||
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
|
||||
|
||||
// 1. Seek GSAP to restore all animated properties from clean state
|
||||
await timeHdrPhaseAsync(hdrPerf, "domLayerSeekMs", () =>
|
||||
domSession.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time),
|
||||
);
|
||||
|
||||
// 2. Run frame injector to set correct SDR video visibility
|
||||
if (beforeCaptureHook) {
|
||||
await timeHdrPhaseAsync(hdrPerf, "domLayerInjectMs", () =>
|
||||
beforeCaptureHook(domSession.page, time),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
|
||||
await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () =>
|
||||
applyDomLayerMask(domSession.page, showIds, hideIds),
|
||||
);
|
||||
|
||||
// 4. Screenshot
|
||||
const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () =>
|
||||
captureAlphaPng(domSession.page, width, height),
|
||||
);
|
||||
|
||||
// 5. Tear down the mask
|
||||
await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () =>
|
||||
removeDomLayerMask(domSession.page, hideIds),
|
||||
);
|
||||
|
||||
try {
|
||||
const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng));
|
||||
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
|
||||
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
|
||||
timeHdrPhase(hdrPerf, "domBlitMs", () =>
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height, compositeTransfer),
|
||||
);
|
||||
if (shouldLog && debugDumpDir) {
|
||||
const after = countNonZeroRgb48(canvas);
|
||||
const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`;
|
||||
const dumpPath = join(debugDumpDir, dumpName);
|
||||
writeFileExclusiveSync(dumpPath, domPng);
|
||||
log.info("[diag] dom layer blit", {
|
||||
frame: debugFrameIndex,
|
||||
layerIdx,
|
||||
layerIds: layer.elementIds,
|
||||
showIds,
|
||||
hideCount: hideIds.length,
|
||||
pngBytes: domPng.length,
|
||||
alphaPixels,
|
||||
pixelsAdded: after - before,
|
||||
totalNonZero: after,
|
||||
dumpPath,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay", {
|
||||
layerIds: layer.elementIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLog && debugDumpDir) {
|
||||
const finalNonZero = countNonZeroRgb48(canvas);
|
||||
log.info("[diag] compositeToBuffer end", {
|
||||
frame: debugFrameIndex,
|
||||
finalNonZeroPixels: finalNonZero,
|
||||
totalPixels: width * height,
|
||||
coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { convertTransfer } from "@hyperframes/engine";
|
||||
import { createHdrImageTransferCache } from "./hdrImageTransferCache.ts";
|
||||
|
||||
/**
|
||||
* Build a deterministic rgb48le buffer for `pixelCount` pixels.
|
||||
* Each pixel is 3 channels × 2 bytes = 6 bytes. Values vary per pixel/channel
|
||||
* so the LUT-based `convertTransfer` produces bytes that differ from the
|
||||
* source.
|
||||
*/
|
||||
function makeSourceBuffer(pixelCount: number, seed = 0): Buffer {
|
||||
const buf = Buffer.alloc(pixelCount * 6);
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const off = i * 6;
|
||||
// Spread values across the 16-bit range so HLG↔PQ LUT lookups land on
|
||||
// mid-curve entries that are guaranteed to differ from the input.
|
||||
buf.writeUInt16LE((seed + i * 257) & 0xff_ff, off);
|
||||
buf.writeUInt16LE((seed + i * 521 + 1) & 0xff_ff, off + 2);
|
||||
buf.writeUInt16LE((seed + i * 1031 + 2) & 0xff_ff, off + 4);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function expectedConverted(source: Buffer, from: "hlg" | "pq", to: "hlg" | "pq"): Buffer {
|
||||
const copy = Buffer.from(source);
|
||||
convertTransfer(copy, from, to);
|
||||
return copy;
|
||||
}
|
||||
|
||||
describe("hdrImageTransferCache", () => {
|
||||
test("returns source buffer unchanged when sourceTransfer === targetTransfer", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
|
||||
const result = cache.getConverted("img1", "pq", "pq", source);
|
||||
|
||||
expect(result).toBe(source);
|
||||
expect(cache.size()).toBe(0);
|
||||
expect(cache.bytesUsed()).toBe(0);
|
||||
});
|
||||
|
||||
test("first miss converts and caches", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
const expected = expectedConverted(source, "hlg", "pq");
|
||||
|
||||
const result = cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(result).not.toBe(source);
|
||||
expect(Buffer.compare(result, expected)).toBe(0);
|
||||
expect(cache.size()).toBe(1);
|
||||
expect(cache.bytesUsed()).toBe(source.byteLength);
|
||||
});
|
||||
|
||||
test("second hit returns cached buffer reference", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
|
||||
const first = cache.getConverted("img1", "hlg", "pq", source);
|
||||
const second = cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(cache.size()).toBe(1);
|
||||
});
|
||||
|
||||
test("does not re-run convertTransfer on cache hit", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
|
||||
const first = cache.getConverted("img1", "hlg", "pq", source);
|
||||
const snapshot = Buffer.from(first);
|
||||
cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(Buffer.compare(first, snapshot)).toBe(0);
|
||||
});
|
||||
|
||||
test("different target transfers for same imageId are cached independently", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
|
||||
const toPq = cache.getConverted("img1", "hlg", "pq", source);
|
||||
const toHlg = cache.getConverted("img1", "pq", "hlg", source);
|
||||
|
||||
expect(toPq).not.toBe(toHlg);
|
||||
expect(Buffer.compare(toPq, expectedConverted(source, "hlg", "pq"))).toBe(0);
|
||||
expect(Buffer.compare(toHlg, expectedConverted(source, "pq", "hlg"))).toBe(0);
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
test("different imageIds are cached independently", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const a = makeSourceBuffer(4, 100);
|
||||
const b = makeSourceBuffer(4, 200);
|
||||
|
||||
const convA = cache.getConverted("a", "hlg", "pq", a);
|
||||
const convB = cache.getConverted("b", "hlg", "pq", b);
|
||||
|
||||
expect(convA).not.toBe(convB);
|
||||
expect(Buffer.compare(convA, expectedConverted(a, "hlg", "pq"))).toBe(0);
|
||||
expect(Buffer.compare(convB, expectedConverted(b, "hlg", "pq"))).toBe(0);
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
// ── Byte-budget eviction ──────────────────────────────────────────────
|
||||
|
||||
test("evicts LRU entries when byte budget exceeded", () => {
|
||||
// Each buffer = 100 pixels × 6 bytes = 600 bytes.
|
||||
// Budget = 1200 → fits 2 entries.
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 1200 });
|
||||
const a = makeSourceBuffer(100, 1);
|
||||
const b = makeSourceBuffer(100, 2);
|
||||
const c = makeSourceBuffer(100, 3);
|
||||
|
||||
const convA1 = cache.getConverted("a", "hlg", "pq", a);
|
||||
cache.getConverted("b", "hlg", "pq", b);
|
||||
expect(cache.size()).toBe(2);
|
||||
expect(cache.bytesUsed()).toBe(1200);
|
||||
|
||||
// Inserting c should evict a (LRU).
|
||||
cache.getConverted("c", "hlg", "pq", c);
|
||||
expect(cache.size()).toBe(2);
|
||||
expect(cache.bytesUsed()).toBe(1200);
|
||||
|
||||
// a was evicted — re-requesting produces a fresh conversion.
|
||||
const convA2 = cache.getConverted("a", "hlg", "pq", a);
|
||||
expect(convA2).not.toBe(convA1);
|
||||
expect(Buffer.compare(convA2, expectedConverted(a, "hlg", "pq"))).toBe(0);
|
||||
});
|
||||
|
||||
test("large buffer evicts multiple smaller entries", () => {
|
||||
// 3 small entries (200 bytes each = 600 total), budget = 800.
|
||||
// Then one 600-byte entry should evict 2 of the 3 smalls.
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 800 });
|
||||
const small = makeSourceBuffer(33, 1); // 33*6=198 bytes
|
||||
const small2 = makeSourceBuffer(33, 2);
|
||||
const small3 = makeSourceBuffer(33, 3);
|
||||
const big = makeSourceBuffer(100, 4); // 600 bytes
|
||||
|
||||
cache.getConverted("s1", "hlg", "pq", small);
|
||||
cache.getConverted("s2", "hlg", "pq", small2);
|
||||
cache.getConverted("s3", "hlg", "pq", small3);
|
||||
expect(cache.size()).toBe(3);
|
||||
|
||||
// big (600) + existing (594) > 800 → evict until room.
|
||||
cache.getConverted("big", "hlg", "pq", big);
|
||||
expect(cache.bytesUsed()).toBeLessThanOrEqual(800);
|
||||
expect(cache.size()).toBeLessThan(4);
|
||||
});
|
||||
|
||||
test("access promotes entry to most-recently-used under byte budget", () => {
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 1200 });
|
||||
const a = makeSourceBuffer(100, 1); // 600 bytes
|
||||
const b = makeSourceBuffer(100, 2);
|
||||
const c = makeSourceBuffer(100, 3);
|
||||
|
||||
const convA1 = cache.getConverted("a", "hlg", "pq", a);
|
||||
cache.getConverted("b", "hlg", "pq", b);
|
||||
|
||||
// Promote a to MRU.
|
||||
const convA2 = cache.getConverted("a", "hlg", "pq", a);
|
||||
expect(convA2).toBe(convA1);
|
||||
|
||||
// Insert c — b is now LRU and should be evicted, not a.
|
||||
cache.getConverted("c", "hlg", "pq", c);
|
||||
|
||||
// a should still be cached (was promoted).
|
||||
const convA3 = cache.getConverted("a", "hlg", "pq", a);
|
||||
expect(convA3).toBe(convA1);
|
||||
|
||||
// b was evicted — fresh conversion.
|
||||
const convB2 = cache.getConverted("b", "hlg", "pq", b);
|
||||
expect(Buffer.compare(convB2, expectedConverted(b, "hlg", "pq"))).toBe(0);
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
test("maxBytes: 0 disables caching but still returns correct converted bytes", () => {
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 0 });
|
||||
const source = makeSourceBuffer(4);
|
||||
const expected = expectedConverted(source, "hlg", "pq");
|
||||
|
||||
const first = cache.getConverted("img1", "hlg", "pq", source);
|
||||
const second = cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(Buffer.compare(first, expected)).toBe(0);
|
||||
expect(Buffer.compare(second, expected)).toBe(0);
|
||||
expect(cache.size()).toBe(0);
|
||||
expect(cache.bytesUsed()).toBe(0);
|
||||
});
|
||||
|
||||
test("bytesUsed tracks cumulative size accurately", () => {
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 10000 });
|
||||
const a = makeSourceBuffer(50, 1); // 300 bytes
|
||||
const b = makeSourceBuffer(100, 2); // 600 bytes
|
||||
|
||||
cache.getConverted("a", "hlg", "pq", a);
|
||||
expect(cache.bytesUsed()).toBe(300);
|
||||
|
||||
cache.getConverted("b", "hlg", "pq", b);
|
||||
expect(cache.bytesUsed()).toBe(900);
|
||||
});
|
||||
|
||||
test("bytesUsed decreases on eviction", () => {
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 600 });
|
||||
const a = makeSourceBuffer(50, 1); // 300 bytes
|
||||
const b = makeSourceBuffer(50, 2); // 300 bytes
|
||||
const c = makeSourceBuffer(50, 3); // 300 bytes
|
||||
|
||||
cache.getConverted("a", "hlg", "pq", a);
|
||||
cache.getConverted("b", "hlg", "pq", b);
|
||||
expect(cache.bytesUsed()).toBe(600);
|
||||
|
||||
cache.getConverted("c", "hlg", "pq", c);
|
||||
expect(cache.bytesUsed()).toBe(600);
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
test("single buffer larger than budget still works (cache-through)", () => {
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 100 });
|
||||
const big = makeSourceBuffer(100, 1); // 600 bytes > 100 budget
|
||||
const expected = expectedConverted(big, "hlg", "pq");
|
||||
|
||||
const result = cache.getConverted("big", "hlg", "pq", big);
|
||||
|
||||
expect(Buffer.compare(result, expected)).toBe(0);
|
||||
// Too large to cache — behaves like passthrough.
|
||||
expect(cache.size()).toBe(0);
|
||||
expect(cache.bytesUsed()).toBe(0);
|
||||
});
|
||||
|
||||
// ── Source-buffer-immutability ────────────────────────────────────────
|
||||
|
||||
test("cached buffer is independent from the source buffer", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
const sourceSnapshot = Buffer.from(source);
|
||||
|
||||
const cached = cache.getConverted("img1", "hlg", "pq", source);
|
||||
source.fill(0);
|
||||
|
||||
expect(cache.getConverted("img1", "hlg", "pq", source)).toBe(cached);
|
||||
expect(Buffer.compare(cached, expectedConverted(sourceSnapshot, "hlg", "pq"))).toBe(0);
|
||||
});
|
||||
|
||||
test("does not mutate the source buffer on a convert+cache miss", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
const sourceSnapshot = Buffer.from(source);
|
||||
|
||||
cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(Buffer.compare(source, sourceSnapshot)).toBe(0);
|
||||
});
|
||||
|
||||
test("does not mutate the source buffer on a convert+cache miss with maxBytes=0 passthrough", () => {
|
||||
const cache = createHdrImageTransferCache({ maxBytes: 0 });
|
||||
const source = makeSourceBuffer(4);
|
||||
const sourceSnapshot = Buffer.from(source);
|
||||
|
||||
const result = cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(Buffer.compare(source, sourceSnapshot)).toBe(0);
|
||||
expect(result).not.toBe(source);
|
||||
expect(Buffer.compare(result, expectedConverted(sourceSnapshot, "hlg", "pq"))).toBe(0);
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
|
||||
test("does not mutate the source buffer on a cache hit", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
const source = makeSourceBuffer(4);
|
||||
const sourceSnapshot = Buffer.from(source);
|
||||
|
||||
cache.getConverted("img1", "hlg", "pq", source);
|
||||
cache.getConverted("img1", "hlg", "pq", source);
|
||||
|
||||
expect(Buffer.compare(source, sourceSnapshot)).toBe(0);
|
||||
});
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────
|
||||
|
||||
test("rejects invalid maxBytes", () => {
|
||||
expect(() => createHdrImageTransferCache({ maxBytes: -1 })).toThrow();
|
||||
expect(() => createHdrImageTransferCache({ maxBytes: 1.5 })).toThrow();
|
||||
expect(() => createHdrImageTransferCache({ maxBytes: Number.NaN })).toThrow();
|
||||
});
|
||||
|
||||
test("default maxBytes accommodates typical 1080p compositions", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
// 1080p rgb48le = 1920*1080*6 = ~12.4MB per entry.
|
||||
const px1080p = 1920 * 1080;
|
||||
const source = makeSourceBuffer(px1080p);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
cache.getConverted(`img${i}`, "hlg", "pq", source);
|
||||
}
|
||||
// Default 200MB budget → fits ~16 entries at 1080p.
|
||||
expect(cache.size()).toBe(16);
|
||||
|
||||
const first = cache.getConverted("img0", "hlg", "pq", source);
|
||||
expect(Buffer.compare(first, expectedConverted(source, "hlg", "pq"))).toBe(0);
|
||||
});
|
||||
|
||||
test("default maxBytes limits 4K entries to safe count", () => {
|
||||
const cache = createHdrImageTransferCache();
|
||||
// 4K rgb48le = 3840*2160*6 = ~49.8MB per entry.
|
||||
const px4k = 3840 * 2160;
|
||||
const source = makeSourceBuffer(px4k);
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
cache.getConverted(`img${i}`, "hlg", "pq", source);
|
||||
}
|
||||
// 200MB / ~50MB = ~4 entries max. 8 inserts should cap at 4.
|
||||
expect(cache.size()).toBeLessThanOrEqual(4);
|
||||
expect(cache.bytesUsed()).toBeLessThanOrEqual(200 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type HdrTransfer, convertTransfer } from "@hyperframes/engine";
|
||||
|
||||
export interface HdrImageTransferCache {
|
||||
getConverted(
|
||||
imageId: string,
|
||||
sourceTransfer: HdrTransfer,
|
||||
targetTransfer: HdrTransfer,
|
||||
source: Buffer,
|
||||
): Buffer;
|
||||
|
||||
size(): number;
|
||||
|
||||
bytesUsed(): number;
|
||||
}
|
||||
|
||||
export interface HdrImageTransferCacheOptions {
|
||||
/**
|
||||
* Maximum bytes of converted buffers to retain before evicting the
|
||||
* least-recently-used entries. Defaults to 200 MB. At 1080p (~12 MB/entry)
|
||||
* that fits ~16 entries; at 4K (~50 MB/entry) it naturally caps at ~4.
|
||||
* Set to `0` to disable caching entirely (every call allocates fresh).
|
||||
*/
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
export function createHdrImageTransferCache(
|
||||
options: HdrImageTransferCacheOptions = {},
|
||||
): HdrImageTransferCache {
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 0) {
|
||||
throw new Error(
|
||||
`createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const entries = new Map<string, Buffer>();
|
||||
let totalBytes = 0;
|
||||
|
||||
function makeKey(imageId: string, targetTransfer: HdrTransfer): string {
|
||||
return `${imageId}|${targetTransfer}`;
|
||||
}
|
||||
|
||||
function evictUntilRoom(needed: number): void {
|
||||
while (totalBytes + needed > maxBytes && entries.size > 0) {
|
||||
const lruKey = entries.keys().next().value;
|
||||
if (lruKey === undefined) break;
|
||||
const evicted = entries.get(lruKey);
|
||||
if (evicted) totalBytes -= evicted.byteLength;
|
||||
entries.delete(lruKey);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getConverted(imageId, sourceTransfer, targetTransfer, source) {
|
||||
if (sourceTransfer === targetTransfer) {
|
||||
return source;
|
||||
}
|
||||
|
||||
if (maxBytes === 0) {
|
||||
const fresh = Buffer.from(source);
|
||||
convertTransfer(fresh, sourceTransfer, targetTransfer);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
const key = makeKey(imageId, targetTransfer);
|
||||
const existing = entries.get(key);
|
||||
if (existing) {
|
||||
entries.delete(key);
|
||||
entries.set(key, existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const converted = Buffer.from(source);
|
||||
convertTransfer(converted, sourceTransfer, targetTransfer);
|
||||
|
||||
if (converted.byteLength > maxBytes) {
|
||||
return converted;
|
||||
}
|
||||
|
||||
evictUntilRoom(converted.byteLength);
|
||||
entries.set(key, converted);
|
||||
totalBytes += converted.byteLength;
|
||||
return converted;
|
||||
},
|
||||
|
||||
size() {
|
||||
return entries.size;
|
||||
},
|
||||
|
||||
bytesUsed() {
|
||||
return totalBytes;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tests for the worker_thread /health endpoint.
|
||||
*
|
||||
* The pin: the listener answers from a worker_thread, so it stays responsive
|
||||
* even if the main thread is blocked on a sync task. We verify:
|
||||
*
|
||||
* 1. Boot: startHealthWorker resolves once the worker is listening, and the
|
||||
* endpoint returns 200 + the expected JSON body.
|
||||
* 2. Liveness across main-thread block: while the main thread is blocked on
|
||||
* a long synchronous loop, /health still answers within a tight budget.
|
||||
* This is the property k8s probes need: probe responsiveness is
|
||||
* decoupled from main-thread event-loop latency.
|
||||
* 3. Shutdown: the handle's shutdown() actually frees the port (subsequent
|
||||
* boot on the same port succeeds).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { startHealthWorker, type HealthWorkerHandle } from "./healthWorker.js";
|
||||
|
||||
// Use a fixed test port well above the producer's default port range.
|
||||
const TEST_PORT = 19848;
|
||||
|
||||
// Resolve the worker entry from this test file so vitest (tsx-driven) loads
|
||||
// the .ts source rather than looking for a non-existent dist/ artifact.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const WORKER_ENTRY = resolve(HERE, "healthWorkerThread.ts");
|
||||
|
||||
async function fetchHealth(
|
||||
port: number,
|
||||
timeoutMs = 1_000,
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolveFetch, rejectFetch) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path: "/health",
|
||||
method: "GET",
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => resolveFetch({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
req.on("error", rejectFetch);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("healthWorker", () => {
|
||||
const handles: HealthWorkerHandle[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (handles.length > 0) {
|
||||
const h = handles.pop()!;
|
||||
await h.shutdown().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("boots and serves /health from the worker thread", async () => {
|
||||
const handle = await startHealthWorker({
|
||||
port: TEST_PORT,
|
||||
workerEntry: WORKER_ENTRY,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
handles.push(handle);
|
||||
expect(handle.port).toBe(TEST_PORT);
|
||||
|
||||
const res = await fetchHealth(TEST_PORT);
|
||||
expect(res.status).toBe(200);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.status).toBe("ok");
|
||||
expect(json.thread).toBe("worker");
|
||||
expect(typeof json.uptime).toBe("number");
|
||||
expect(typeof json.timestamp).toBe("string");
|
||||
});
|
||||
|
||||
it("stays responsive while the main thread is blocked on a sync task", async () => {
|
||||
const handle = await startHealthWorker({
|
||||
port: TEST_PORT,
|
||||
workerEntry: WORKER_ENTRY,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
handles.push(handle);
|
||||
|
||||
// Kick off a request, then block the main thread synchronously for 500ms.
|
||||
// If /health lived on the main thread, the response wouldn't arrive
|
||||
// until after the block finishes. Because it lives on a worker_thread,
|
||||
// the worker's socket accept loop has already started responding.
|
||||
const fetchPromise = fetchHealth(TEST_PORT, 2_000);
|
||||
const blockStart = Date.now();
|
||||
// Busy-spin without yielding to the event loop. 500ms is well over the
|
||||
// 5s prod probe timeout's "0.1x" budget; a 1s budget on the fetch is
|
||||
// generous.
|
||||
// eslint-disable-next-line no-empty
|
||||
while (Date.now() - blockStart < 500) {}
|
||||
const res = await fetchPromise;
|
||||
expect(res.status).toBe(200);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("releases the port on shutdown", async () => {
|
||||
const first = await startHealthWorker({
|
||||
port: TEST_PORT,
|
||||
workerEntry: WORKER_ENTRY,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
await first.shutdown();
|
||||
|
||||
// Booting again on the same port should succeed.
|
||||
const second = await startHealthWorker({
|
||||
port: TEST_PORT,
|
||||
workerEntry: WORKER_ENTRY,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
handles.push(second);
|
||||
const res = await fetchHealth(TEST_PORT);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Worker-thread health endpoint.
|
||||
*
|
||||
* Runs an HTTP server (`/health`, returns 200 OK with uptime+timestamp JSON)
|
||||
* on a *separate* Node worker_thread so probe responses don't depend on the
|
||||
* main event loop. The main thread can be deep in a long-running synchronous
|
||||
* task (Chrome teardown, large file I/O, GC pause, the post-Miguel guard
|
||||
* "impossible duration" math, etc.) and this endpoint still answers within
|
||||
* milliseconds because it lives in a different V8 isolate with its own
|
||||
* event loop.
|
||||
*
|
||||
* Why this matters
|
||||
* ----------------
|
||||
*
|
||||
* The producer sidecar's k8s `livenessProbe` / `readinessProbe` hit `/health`
|
||||
* on the Hono server in the main thread. Any synchronous stall longer than
|
||||
* the probe `timeoutSeconds` (5s in prod prior to the companion change)
|
||||
* triggers a SIGKILL even when the process is still alive — just busy.
|
||||
*
|
||||
* Today's incident (2026-06-26): an infinite GSAP timeline caused the
|
||||
* distributed planner to try to enumerate ~300_000_000_000 frames, and
|
||||
* the sidecar got killed mid-arithmetic. Miguel's upstream `plan()`
|
||||
* duration guard kills that input class at the source. This module is
|
||||
* defense-in-depth: future wedge classes (sync I/O on video-heavy comps,
|
||||
* runaway loops, GC pauses) shouldn't kill an otherwise-alive pod either.
|
||||
*
|
||||
* Contract
|
||||
* --------
|
||||
*
|
||||
* - The worker thread binds an HTTP listener on
|
||||
* `PRODUCER_HEALTH_PORT` (default 9848) for `/health` only.
|
||||
* - Liveness in the worker thread = "the worker_thread itself is responsive",
|
||||
* which is a strict subset of process liveness. If the entire Node process
|
||||
* is dead the OS tears down both threads' sockets simultaneously, so the
|
||||
* worker_thread's listener stops answering and k8s correctly kills the pod.
|
||||
* - Listening on `0.0.0.0` is intentional: this is the probe entry point and
|
||||
* the sidecar already exposes other ports for in-pod traffic only. The
|
||||
* endpoint returns the same shape the main-thread `/health` always returned
|
||||
* so existing observability keeps working.
|
||||
*
|
||||
* The main thread still serves `/health` on the main port (9847) for
|
||||
* backwards compatibility; k8s probe config in `heygen-com/app` can migrate
|
||||
* to the worker port at its own pace.
|
||||
*/
|
||||
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export interface HealthWorkerOptions {
|
||||
/** Port the worker_thread health endpoint listens on. Default 9848 / env. */
|
||||
port?: number;
|
||||
/**
|
||||
* Optional logger; falls back to console. Note: the worker thread itself
|
||||
* cannot use the parent's logger directly (separate isolate), so it logs
|
||||
* via `console`. The handle returned here uses the provided logger for
|
||||
* lifecycle events on the *main* thread.
|
||||
*/
|
||||
logger?: {
|
||||
info?: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
warn?: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
error?: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
/**
|
||||
* Override worker entry module. Falls back to a co-located
|
||||
* `healthWorkerThread.js` (post-build) or `.ts` (dev/test).
|
||||
*/
|
||||
workerEntry?: string;
|
||||
}
|
||||
|
||||
export interface HealthWorkerHandle {
|
||||
/** Port the listener is bound to (resolved). */
|
||||
port: number;
|
||||
/** Stop the listener + terminate the worker thread. Idempotent. */
|
||||
shutdown: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_HEALTH_PORT = 9848;
|
||||
|
||||
/**
|
||||
* Spawn the health worker_thread. Returns once the worker reports its
|
||||
* listener is up (or rejects if the worker fails to start).
|
||||
*/
|
||||
export async function startHealthWorker(
|
||||
options: HealthWorkerOptions = {},
|
||||
): Promise<HealthWorkerHandle> {
|
||||
const log = options.logger ?? defaultLogger();
|
||||
const port =
|
||||
options.port ?? parseInt(process.env.PRODUCER_HEALTH_PORT ?? String(DEFAULT_HEALTH_PORT), 10);
|
||||
|
||||
const entry = options.workerEntry ?? resolveWorkerEntry();
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
"[healthWorker] could not resolve worker entry. " +
|
||||
"Pass options.workerEntry or ensure healthWorkerThread.{js,ts} is co-located.",
|
||||
);
|
||||
}
|
||||
|
||||
const worker = new Worker(entry, {
|
||||
workerData: { port },
|
||||
// Keep stdio inherited so the worker's console logs land in the pod logs
|
||||
// alongside the main thread's.
|
||||
stdout: false,
|
||||
stderr: false,
|
||||
});
|
||||
|
||||
// Wait for the worker to report "listening" before resolving, so callers
|
||||
// can be sure the probe endpoint is actually up.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onMessage = (msg: { type: string; error?: string }) => {
|
||||
if (msg?.type === "listening") {
|
||||
worker.off("error", onError);
|
||||
worker.off("message", onMessage);
|
||||
resolve();
|
||||
} else if (msg?.type === "listen-error") {
|
||||
worker.off("error", onError);
|
||||
worker.off("message", onMessage);
|
||||
reject(new Error(`[healthWorker] failed to bind port ${port}: ${msg.error}`));
|
||||
}
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
worker.off("error", onError);
|
||||
worker.off("message", onMessage);
|
||||
reject(err);
|
||||
};
|
||||
worker.on("message", onMessage);
|
||||
worker.on("error", onError);
|
||||
});
|
||||
|
||||
log.info?.(`[healthWorker] /health listening on worker thread, port ${port}`);
|
||||
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
shutdownPromise = (async () => {
|
||||
try {
|
||||
worker.postMessage({ type: "shutdown" });
|
||||
// Give the worker a beat to close its server cleanly. terminate()
|
||||
// is the hard backstop.
|
||||
await Promise.race([
|
||||
new Promise<void>((res) => worker.once("exit", () => res())),
|
||||
new Promise<void>((res) => setTimeout(res, 2_000)),
|
||||
]);
|
||||
} finally {
|
||||
await worker.terminate().catch(() => {});
|
||||
}
|
||||
})();
|
||||
return shutdownPromise;
|
||||
};
|
||||
|
||||
// If the worker crashes unexpectedly, log loudly. We don't auto-respawn
|
||||
// here — the k8s probe will catch a dead listener and the pod will be
|
||||
// restarted, which is the right behavior for a truly-broken process.
|
||||
worker.on("error", (err: Error) => {
|
||||
log.error?.(`[healthWorker] worker thread error: ${err.message}`);
|
||||
});
|
||||
worker.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
log.warn?.(`[healthWorker] worker thread exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { port, shutdown };
|
||||
}
|
||||
|
||||
function defaultLogger() {
|
||||
return {
|
||||
info: (msg: string) => console.log(msg),
|
||||
warn: (msg: string) => console.warn(msg),
|
||||
error: (msg: string) => console.error(msg),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the co-located worker entry file. Prefer the compiled `.js`
|
||||
* (production) and fall back to the `.ts` source (dev / vitest via tsx).
|
||||
*/
|
||||
function resolveWorkerEntry(): string | undefined {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [join(here, "healthWorkerThread.js"), join(here, "healthWorkerThread.ts")];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Worker-thread entry for the off-main-thread /health endpoint.
|
||||
*
|
||||
* Runs a minimal `node:http` server bound to `workerData.port` that answers
|
||||
* `GET /health` with `{ status, uptime, timestamp, thread: "worker" }`.
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* - On startup, binds the port and posts `{ type: "listening" }` to the
|
||||
* parent. If `listen()` errors (port in use, permission denied), posts
|
||||
* `{ type: "listen-error", error }` and exits non-zero.
|
||||
* - On `{ type: "shutdown" }` from the parent, closes the server and exits 0.
|
||||
* - All other requests (path or method other than `GET /health`) get 404.
|
||||
*
|
||||
* The HTTP layer is intentionally raw — no framework, zero deps beyond the
|
||||
* Node stdlib — so the worker's surface is small and its event loop has
|
||||
* nothing to block on except its own socket accept loop.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
|
||||
const startTime = Date.now();
|
||||
const port = Number(workerData?.port ?? 9848);
|
||||
|
||||
if (!parentPort) {
|
||||
// Defensive — this module is only meaningful inside a worker_thread.
|
||||
throw new Error("[healthWorkerThread] must run inside a worker_thread");
|
||||
}
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method === "GET" && (req.url === "/health" || req.url?.startsWith("/health?"))) {
|
||||
const body = JSON.stringify({
|
||||
status: "ok",
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
timestamp: new Date().toISOString(),
|
||||
thread: "worker",
|
||||
});
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
// Disable timeouts — `/health` is a fast endpoint, but if the kernel is
|
||||
// slow to flush at process-tear-down time we'd rather respond than time out.
|
||||
server.keepAliveTimeout = 0;
|
||||
server.requestTimeout = 0;
|
||||
server.headersTimeout = 0;
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
parentPort?.postMessage({
|
||||
type: "listen-error",
|
||||
error: `${err.code ?? "unknown"}: ${err.message}`,
|
||||
});
|
||||
// Give the parent a beat to receive the error, then close our message
|
||||
// channel so the worker thread exits naturally. process.exit() inside a
|
||||
// worker has had inconsistent semantics across Node versions; closing
|
||||
// parentPort + letting the event loop drain is the documented clean path
|
||||
// and lets the parent's `exit` listener fire with the natural code.
|
||||
setTimeout(() => parentPort?.close(), 50);
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
parentPort?.postMessage({ type: "listening", port });
|
||||
});
|
||||
|
||||
parentPort.on("message", (msg: { type?: string }) => {
|
||||
if (msg?.type === "shutdown") {
|
||||
// Close the server and the parent-port message channel. The parent
|
||||
// owns the authoritative shutdown timeout (see healthWorker.ts —
|
||||
// Promise.race against a 2s clock then worker.terminate()), so we do
|
||||
// NOT need a redundant force-exit timer here: if server.close() hangs
|
||||
// on a lingering keep-alive socket, the parent's terminate() lands
|
||||
// first and kills the worker. Single source of truth for the deadline.
|
||||
server.close(() => parentPort?.close());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
|
||||
function u16(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function ascii(value: string): number[] {
|
||||
return Array.from(value).map((char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function frame(delayCentiseconds: number): number[] {
|
||||
return [
|
||||
0x21,
|
||||
0xf9,
|
||||
0x04,
|
||||
0x00,
|
||||
...u16(delayCentiseconds),
|
||||
0x00,
|
||||
0x00,
|
||||
0x2c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x02,
|
||||
0x4c,
|
||||
0x01,
|
||||
0x00,
|
||||
];
|
||||
}
|
||||
|
||||
function gif(frames: number[], loopCount?: number): Uint8Array {
|
||||
const loop =
|
||||
loopCount === undefined
|
||||
? []
|
||||
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
|
||||
return Uint8Array.from([
|
||||
...ascii("GIF89a"),
|
||||
...u16(1),
|
||||
...u16(1),
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
...loop,
|
||||
...frames,
|
||||
0x3b,
|
||||
]);
|
||||
}
|
||||
|
||||
function preparedGifCachePath(
|
||||
cacheDir: string,
|
||||
bytes: Uint8Array,
|
||||
loopIterations: number,
|
||||
padSeconds: number,
|
||||
): string {
|
||||
const hash = createHash("sha256")
|
||||
.update("hfgif-v1")
|
||||
.update("\0")
|
||||
.update(String(loopIterations))
|
||||
.update("\0")
|
||||
.update(String(padSeconds))
|
||||
.update("\0")
|
||||
.update(bytes)
|
||||
.digest("hex");
|
||||
return join(cacheDir, `hfgif-v1-${hash.slice(0, 24)}.webm`);
|
||||
}
|
||||
|
||||
describe("compileForRender animated GIF inputs", () => {
|
||||
it("rewrites animated GIF images to prepared synced videos", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-compiler-gif-"));
|
||||
const cacheDir = join(projectDir, "gif-cache");
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const bytes = gif([...frame(5), ...frame(15)], 0);
|
||||
writeFileSync(join(projectDir, "reaction.gif"), bytes);
|
||||
// 0.2s source in a 2s clip window: prep bakes 10 loop iterations (pad 0).
|
||||
writeFileSync(preparedGifCachePath(cacheDir, bytes, 10, 0), "webm");
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="3">
|
||||
<img id="reaction" class="clip sticker" data-start="1" data-duration="2" src="reaction.gif" />
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.root = { duration: function() { return 3; } };
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const compiled = await compileForRender(
|
||||
projectDir,
|
||||
join(projectDir, "index.html"),
|
||||
projectDir,
|
||||
{
|
||||
animatedGifCacheDir: cacheDir,
|
||||
},
|
||||
);
|
||||
const { document } = parseHTML(compiled.html);
|
||||
const video = document.querySelector("video#reaction");
|
||||
const src = video?.getAttribute("src") ?? "";
|
||||
|
||||
expect(document.querySelector("img#reaction")).toBeNull();
|
||||
expect(video?.getAttribute("class")).toBe("clip sticker");
|
||||
expect(video?.hasAttribute("loop")).toBe(true);
|
||||
expect(video?.getAttribute("data-hf-prepared-gif")).toBe("true");
|
||||
expect(video?.getAttribute("data-end")).toBe("3");
|
||||
expect(src).toMatch(/^_animated_gif\/hfgif-v1-/);
|
||||
expect(compiled.videos.some((entry) => entry.id === "reaction")).toBe(true);
|
||||
expect(compiled.images.some((entry) => entry.id === "reaction")).toBe(false);
|
||||
expect(compiled.externalAssets.has(src)).toBe(true);
|
||||
expect(existsSync(join(projectDir, src))).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";
|
||||
|
||||
export interface PreparedHyperframeLintInput {
|
||||
entryFile: string;
|
||||
html: string;
|
||||
source: "projectDir" | "files" | "html";
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function pickEntryFile(files: Record<string, string>, preferredEntryFile?: string): string | null {
|
||||
const candidates: string[] = [];
|
||||
if (preferredEntryFile) {
|
||||
candidates.push(preferredEntryFile);
|
||||
}
|
||||
candidates.push("index.html", "src/index.html");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const value = files[candidate];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fileName, content] of Object.entries(files)) {
|
||||
if (fileName.toLowerCase().endsWith(".html") && content.length > 0) {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readProjectEntryFile(
|
||||
projectDir: string,
|
||||
preferredEntryFile?: string,
|
||||
): PreparedHyperframeLintInput | { error: string } {
|
||||
const absProjectDir = resolve(projectDir);
|
||||
if (!existsSync(absProjectDir) || !statSync(absProjectDir).isDirectory()) {
|
||||
return { error: `Project directory not found: ${absProjectDir}` };
|
||||
}
|
||||
|
||||
const entryCandidates = [preferredEntryFile, "index.html", "src/index.html"].filter(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
||||
);
|
||||
|
||||
for (const entryFile of entryCandidates) {
|
||||
const absoluteEntryPath = resolve(absProjectDir, entryFile);
|
||||
if (!absoluteEntryPath.startsWith(absProjectDir)) {
|
||||
return { error: `Entry file must stay inside project directory: ${entryFile}` };
|
||||
}
|
||||
if (existsSync(absoluteEntryPath) && statSync(absoluteEntryPath).isFile()) {
|
||||
return {
|
||||
entryFile,
|
||||
html: readFileSync(absoluteEntryPath, "utf-8"),
|
||||
source: "projectDir",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: `No HTML entry file found in project directory: ${join(absProjectDir, preferredEntryFile || "index.html")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareHyperframeLintBody(
|
||||
body: Record<string, unknown>,
|
||||
): { prepared: PreparedHyperframeLintInput } | { error: string } {
|
||||
const requestedEntryFile =
|
||||
typeof body.entryFile === "string" && body.entryFile.trim().length > 0
|
||||
? body.entryFile.trim()
|
||||
: undefined;
|
||||
|
||||
if (typeof body.projectDir === "string" && body.projectDir.trim().length > 0) {
|
||||
const prepared = readProjectEntryFile(body.projectDir, requestedEntryFile);
|
||||
if ("error" in prepared) {
|
||||
return prepared;
|
||||
}
|
||||
return { prepared };
|
||||
}
|
||||
|
||||
if (isStringRecord(body.files)) {
|
||||
const entryFile = pickEntryFile(body.files, requestedEntryFile);
|
||||
if (!entryFile) {
|
||||
return { error: "No HTML entry file found in files payload" };
|
||||
}
|
||||
return {
|
||||
prepared: {
|
||||
entryFile,
|
||||
html: body.files[entryFile] ?? "",
|
||||
source: "files",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof body.html === "string") {
|
||||
return {
|
||||
prepared: {
|
||||
entryFile: requestedEntryFile || "index.html",
|
||||
html: body.html,
|
||||
source: "html",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { error: "Missing lint source: provide projectDir, files, or html" };
|
||||
}
|
||||
|
||||
export async function runHyperframeLint(
|
||||
prepared: PreparedHyperframeLintInput,
|
||||
): Promise<HyperframeLintResult> {
|
||||
return lintHyperframeHtml(prepared.html, { filePath: prepared.entryFile });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const THIS_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const SIBLING_PATH = resolve(THIS_DIR, "hyperframe.manifest.json");
|
||||
const MONOREPO_PATH = resolve(THIS_DIR, "../../../core/dist/hyperframe.manifest.json");
|
||||
|
||||
describe("resolveHyperframeManifestPath", () => {
|
||||
const originalEnv = process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = originalEnv;
|
||||
} else {
|
||||
delete process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns env var when PRODUCER_HYPERFRAME_MANIFEST_PATH is set", async () => {
|
||||
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = "/custom/path/manifest.json";
|
||||
const { resolveHyperframeManifestPath } = await import("./hyperframeRuntimeLoader.js");
|
||||
expect(resolveHyperframeManifestPath()).toBe("/custom/path/manifest.json");
|
||||
});
|
||||
|
||||
it("sibling path resolves to same directory as the module file", () => {
|
||||
// Key invariant: after build, dist/hyperframe.manifest.json sits next to
|
||||
// dist/index.js. In source, SIBLING_MANIFEST_PATH is next to this file.
|
||||
// This verifies the path construction is correct.
|
||||
expect(SIBLING_PATH).toBe(resolve(THIS_DIR, "hyperframe.manifest.json"));
|
||||
expect(SIBLING_PATH).toContain("producer/src/services/hyperframe.manifest.json");
|
||||
});
|
||||
|
||||
it("includes sibling path as first candidate in resolution order", async () => {
|
||||
// Import the actual source and verify the sibling path is found when it
|
||||
// exists. In the monorepo, the monorepo-relative path also exists, so we
|
||||
// verify the sibling would win by checking its position in candidates.
|
||||
//
|
||||
// We can't easily mock existsSync in ESM, but we CAN verify the
|
||||
// structural invariant: the function checks SIBLING first by reading the
|
||||
// source and confirming the candidate array order.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(resolve(THIS_DIR, "hyperframeRuntimeLoader.ts"), "utf8");
|
||||
|
||||
// The candidates array must list SIBLING_MANIFEST_PATH before the others
|
||||
const candidatesMatch = source.match(/const candidates = \[([\s\S]*?)\];/);
|
||||
expect(candidatesMatch).not.toBeNull();
|
||||
const candidatesBody = candidatesMatch![1];
|
||||
|
||||
const siblingIdx = candidatesBody.indexOf("SIBLING_MANIFEST_PATH");
|
||||
const cwdIdx = candidatesBody.indexOf("CWD_RELATIVE_MANIFEST_PATHS");
|
||||
const moduleIdx = candidatesBody.indexOf("MODULE_RELATIVE_MANIFEST_PATH");
|
||||
|
||||
expect(siblingIdx).toBeGreaterThan(-1);
|
||||
expect(siblingIdx).toBeLessThan(cwdIdx);
|
||||
expect(cwdIdx).toBeLessThan(moduleIdx);
|
||||
});
|
||||
|
||||
it("finds manifest via monorepo-relative path in dev (integration check)", async () => {
|
||||
// In the monorepo, the core/dist manifest should exist from the build.
|
||||
// This acts as a smoke test that the resolution works in the dev env.
|
||||
if (!existsSync(MONOREPO_PATH)) {
|
||||
// Skip if core hasn't been built — this is expected in CI before build
|
||||
return;
|
||||
}
|
||||
const { resolveHyperframeManifestPath } = await import("./hyperframeRuntimeLoader.js");
|
||||
const result = resolveHyperframeManifestPath();
|
||||
expect(existsSync(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PRODUCER_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const SIBLING_MANIFEST_PATH = resolve(PRODUCER_DIR, "hyperframe.manifest.json");
|
||||
const MODULE_RELATIVE_MANIFEST_PATH = resolve(
|
||||
PRODUCER_DIR,
|
||||
"../../../core/dist/hyperframe.manifest.json",
|
||||
);
|
||||
const CWD_RELATIVE_MANIFEST_PATHS = [
|
||||
// When bundled to a single file (dist/public-server.js), the manifest
|
||||
// is copied as a sibling by build.mjs
|
||||
resolve(PRODUCER_DIR, "hyperframe.manifest.json"),
|
||||
resolve(process.cwd(), "packages/core/dist/hyperframe.manifest.json"),
|
||||
resolve(process.cwd(), "../core/dist/hyperframe.manifest.json"),
|
||||
resolve(process.cwd(), "core/dist/hyperframe.manifest.json"),
|
||||
];
|
||||
|
||||
type HyperframeRuntimeManifest = {
|
||||
sha256?: string;
|
||||
artifacts?: {
|
||||
iife?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolvedHyperframeRuntime = {
|
||||
manifestPath: string;
|
||||
runtimePath: string;
|
||||
expectedSha256: string;
|
||||
actualSha256: string;
|
||||
runtimeSource: string;
|
||||
};
|
||||
|
||||
export function resolveHyperframeManifestPath(): string {
|
||||
if (process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
|
||||
return process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH;
|
||||
}
|
||||
const candidates = [
|
||||
SIBLING_MANIFEST_PATH,
|
||||
...CWD_RELATIVE_MANIFEST_PATHS,
|
||||
MODULE_RELATIVE_MANIFEST_PATH,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return MODULE_RELATIVE_MANIFEST_PATH;
|
||||
}
|
||||
|
||||
export function getVerifiedHyperframeRuntimeSource(): string {
|
||||
return resolveVerifiedHyperframeRuntime().runtimeSource;
|
||||
}
|
||||
|
||||
export function resolveVerifiedHyperframeRuntime(): ResolvedHyperframeRuntime {
|
||||
const manifestPath = resolveHyperframeManifestPath();
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(
|
||||
`[HyperframeRuntimeLoader] Missing manifest at ${manifestPath}. Build core runtime artifacts before rendering.`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifestRaw = readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(manifestRaw) as HyperframeRuntimeManifest;
|
||||
const runtimeFileName = manifest.artifacts?.iife;
|
||||
if (!runtimeFileName || !manifest.sha256) {
|
||||
throw new Error(
|
||||
`[HyperframeRuntimeLoader] Invalid manifest at ${manifestPath}; missing iife artifact or sha256.`,
|
||||
);
|
||||
}
|
||||
|
||||
const runtimePath = resolve(dirname(manifestPath), runtimeFileName);
|
||||
if (!existsSync(runtimePath)) {
|
||||
throw new Error(`[HyperframeRuntimeLoader] Missing runtime artifact at ${runtimePath}.`);
|
||||
}
|
||||
|
||||
const runtimeSource = readFileSync(runtimePath, "utf8");
|
||||
const runtimeSha = createHash("sha256").update(runtimeSource, "utf8").digest("hex");
|
||||
if (runtimeSha !== manifest.sha256) {
|
||||
throw new Error(
|
||||
`[HyperframeRuntimeLoader] Runtime checksum mismatch. expected=${manifest.sha256} actual=${runtimeSha}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
manifestPath,
|
||||
runtimePath,
|
||||
expectedSha256: manifest.sha256,
|
||||
actualSha256: runtimeSha,
|
||||
runtimeSource,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/services/parallelCoordinator.ts for implementation.
|
||||
*/
|
||||
export {
|
||||
calculateOptimalWorkers,
|
||||
distributeFrames,
|
||||
executeParallelCapture,
|
||||
mergeWorkerFrames,
|
||||
getSystemResources,
|
||||
type WorkerTask,
|
||||
type WorkerResult,
|
||||
type ParallelProgress,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Tests for the `audioPadTrim` helper that backs the distributed
|
||||
* `assemble()` step's audio duration correction.
|
||||
*
|
||||
* The helper is split into:
|
||||
* - `buildPadTrimAudioArgs(...)` — pure function, builds the ffmpeg argv
|
||||
* and labels the operation. Fully unit-testable.
|
||||
* - `padOrTrimAudioToVideoFrameCount(...)` — wrapper that probes the
|
||||
* video for frame count + fps, probes the audio for current duration,
|
||||
* and runs ffmpeg with the args from the pure helper. Tests inject
|
||||
* stubs for the probes and the ffmpeg runner.
|
||||
*
|
||||
* No real ffmpeg/ffprobe runs in these tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildPadTrimAudioArgs,
|
||||
buildPadTrimAudioPlan,
|
||||
padOrTrimAudioToVideoFrameCount,
|
||||
type AudioProbeInfo,
|
||||
type PadTrimAudioInput,
|
||||
type ProbeVideoFrameInfo,
|
||||
} from "./audioPadTrim.js";
|
||||
|
||||
describe("buildPadTrimAudioArgs", () => {
|
||||
it("emits a concat-copy pad plan when audio is shorter than target", () => {
|
||||
const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0, {
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
});
|
||||
expect(plan.operation).toBe("pad");
|
||||
expect(plan.steps).toHaveLength(2);
|
||||
|
||||
const silenceArgs = plan.steps[0]!.args;
|
||||
expect(plan.steps[0]!.kind).toBe("pad-silence");
|
||||
expect(silenceArgs).not.toContain("/tmp/in.aac");
|
||||
expect(silenceArgs[silenceArgs.indexOf("-i") + 1]).toBe(
|
||||
"anullsrc=channel_layout=stereo:sample_rate=48000",
|
||||
);
|
||||
expect(silenceArgs[silenceArgs.indexOf("-t") + 1]).toBe("1.000000");
|
||||
expect(silenceArgs[silenceArgs.indexOf("-c:a") + 1]).toBe("aac");
|
||||
|
||||
const concatArgs = plan.steps[1]!.args;
|
||||
expect(plan.steps[1]!.kind).toBe("pad-concat");
|
||||
expect(concatArgs).toContain("concat");
|
||||
expect(concatArgs[concatArgs.indexOf("-i") + 1]).toBe("pipe:0");
|
||||
expect(concatArgs[concatArgs.indexOf("-c:a") + 1]).toBe("copy");
|
||||
expect(concatArgs[concatArgs.length - 1]).toBe("/tmp/out.aac");
|
||||
expect(plan.steps[1]!.stdin).toContain("file 'file:///tmp/in.aac'");
|
||||
expect(plan.steps[1]!.stdin).toContain("file 'file:///tmp/out.aac.pad-silence.aac'");
|
||||
expect(plan.cleanupPaths).toEqual(["/tmp/out.aac.pad-silence.aac"]);
|
||||
|
||||
const reencodedSourceStep = plan.steps.find(
|
||||
(step) =>
|
||||
step.args.includes("/tmp/in.aac") && step.args[step.args.indexOf("-c:a") + 1] === "aac",
|
||||
);
|
||||
expect(reencodedSourceStep).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the legacy args helper on the first pad materialization step", () => {
|
||||
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0);
|
||||
expect(operation).toBe("pad");
|
||||
expect(args).not.toContain("/tmp/in.aac");
|
||||
expect(args[args.indexOf("-t") + 1]).toBe("1.000000");
|
||||
});
|
||||
|
||||
it("emits -t when audio is longer than target", () => {
|
||||
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 6.123, 5.0);
|
||||
expect(operation).toBe("trim");
|
||||
const tIdx = args.indexOf("-t");
|
||||
expect(tIdx).toBeGreaterThan(-1);
|
||||
expect(args[tIdx + 1]).toBe("5.000000");
|
||||
// Trim preserves AAC stream copy.
|
||||
const codecIdx = args.indexOf("-c:a");
|
||||
expect(args[codecIdx + 1]).toBe("copy");
|
||||
});
|
||||
|
||||
it("emits a plain copy when source duration matches target within ~1ms", () => {
|
||||
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 5.0, 5.0);
|
||||
expect(operation).toBe("copy");
|
||||
expect(args.indexOf("-af")).toBe(-1);
|
||||
expect(args.indexOf("-t")).toBe(-1);
|
||||
const codecIdx = args.indexOf("-c:a");
|
||||
expect(args[codecIdx + 1]).toBe("copy");
|
||||
});
|
||||
|
||||
it("emits 6-decimal-place pad duration (no scientific notation)", () => {
|
||||
// 1.23ms — just over the AUDIO_DURATION_TOLERANCE_SECONDS=1ms threshold,
|
||||
// so we exercise the pad path with a tiny duration that would round to
|
||||
// exponent notation if we used `toString()` instead of `toFixed(6)`.
|
||||
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 0.0, 0.00123);
|
||||
expect(operation).toBe("pad");
|
||||
const tIdx = args.indexOf("-t");
|
||||
expect(args[tIdx + 1]).toBe("0.001230");
|
||||
});
|
||||
|
||||
it("flags ~1ms drift as a copy (below the tolerance threshold)", () => {
|
||||
const close = buildPadTrimAudioArgs("/tmp/a.aac", "/tmp/o.aac", 5.0, 5.0005);
|
||||
expect(close.operation).toBe("copy");
|
||||
});
|
||||
|
||||
it("flags >1ms drift in either direction as pad/trim", () => {
|
||||
const padNeeded = buildPadTrimAudioArgs("/tmp/a.aac", "/tmp/o.aac", 5.0, 5.002);
|
||||
expect(padNeeded.operation).toBe("pad");
|
||||
const trimNeeded = buildPadTrimAudioArgs("/tmp/a.aac", "/tmp/o.aac", 5.002, 5.0);
|
||||
expect(trimNeeded.operation).toBe("trim");
|
||||
});
|
||||
});
|
||||
|
||||
describe("padOrTrimAudioToVideoFrameCount", () => {
|
||||
// Build a minimal harness that stubs out the three injectables.
|
||||
function harness(opts: {
|
||||
video: ProbeVideoFrameInfo | "throw";
|
||||
audio: AudioProbeInfo | "throw";
|
||||
ffmpeg?: (args: string[]) => Promise<{ success: boolean; error?: string }>;
|
||||
}): { input: PadTrimAudioInput; captured: { args: string[][] } } {
|
||||
const captured = { args: [] as string[][] };
|
||||
const input: PadTrimAudioInput = {
|
||||
videoPath: "/tmp/v.mp4",
|
||||
audioPath: "/tmp/a.aac",
|
||||
outputPath: "/tmp/o.aac",
|
||||
probeVideoFrameInfo: async () => {
|
||||
if (opts.video === "throw") throw new Error("video probe boom");
|
||||
return opts.video;
|
||||
},
|
||||
probeAudioInfo: async () => {
|
||||
if (opts.audio === "throw") throw new Error("audio probe boom");
|
||||
return opts.audio;
|
||||
},
|
||||
runFfmpeg:
|
||||
opts.ffmpeg ??
|
||||
(async (args) => {
|
||||
captured.args.push(args);
|
||||
return { success: true };
|
||||
}),
|
||||
};
|
||||
return { input, captured };
|
||||
}
|
||||
|
||||
it("pads a video of N=180 frames at 30/1 fps with shorter audio", async () => {
|
||||
const { input, captured } = harness({
|
||||
video: { frameCount: 180, fpsNum: 30, fpsDen: 1 },
|
||||
audio: { durationSeconds: 5.5 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.operation).toBe("pad");
|
||||
expect(result.targetDurationSeconds).toBe(6);
|
||||
expect(result.sourceDurationSeconds).toBe(5.5);
|
||||
expect(captured.args).toHaveLength(2);
|
||||
const tIdx = captured.args[0]!.indexOf("-t");
|
||||
expect(captured.args[0]![tIdx + 1]).toBe("0.500000");
|
||||
expect(captured.args[0]).not.toContain("/tmp/a.aac");
|
||||
expect(captured.args[1]![captured.args[1]!.indexOf("-c:a") + 1]).toBe("copy");
|
||||
});
|
||||
|
||||
it("trims a video of N=120 frames at 30/1 fps with longer audio", async () => {
|
||||
const { input, captured } = harness({
|
||||
video: { frameCount: 120, fpsNum: 30, fpsDen: 1 },
|
||||
audio: { durationSeconds: 4.5 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.operation).toBe("trim");
|
||||
expect(result.targetDurationSeconds).toBe(4);
|
||||
expect(captured.args).toHaveLength(1);
|
||||
const tIdx = captured.args[0]!.indexOf("-t");
|
||||
expect(captured.args[0]![tIdx + 1]).toBe("4.000000");
|
||||
});
|
||||
|
||||
it("emits a copy when audio duration already equals frameCount/fps", async () => {
|
||||
const { input, captured } = harness({
|
||||
video: { frameCount: 90, fpsNum: 30, fpsDen: 1 },
|
||||
audio: { durationSeconds: 3.0 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.operation).toBe("copy");
|
||||
expect(result.targetDurationSeconds).toBe(3);
|
||||
expect(captured.args[0]!.includes("-af")).toBe(false);
|
||||
expect(captured.args[0]!.includes("-t")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles NTSC fps (30000/1001) exactly", async () => {
|
||||
// 120 frames at 30000/1001 ≈ 4.004s. Audio 4.0s → pad 0.004s.
|
||||
const { input, captured } = harness({
|
||||
video: { frameCount: 120, fpsNum: 30000, fpsDen: 1001 },
|
||||
audio: { durationSeconds: 4.0 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.operation).toBe("pad");
|
||||
expect(result.targetDurationSeconds).toBeCloseTo((120 * 1001) / 30000, 9);
|
||||
const tIdx = captured.args[0]!.indexOf("-t");
|
||||
expect(captured.args[0]![tIdx + 1]).toMatch(/^0\.004\d+$/);
|
||||
});
|
||||
|
||||
it("propagates video probe failure as success=false", async () => {
|
||||
const { input } = harness({
|
||||
video: "throw",
|
||||
audio: { durationSeconds: 4.0 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("failed to probe video");
|
||||
expect(result.error).toContain("video probe boom");
|
||||
});
|
||||
|
||||
it("propagates audio probe failure as success=false", async () => {
|
||||
const { input } = harness({
|
||||
video: { frameCount: 90, fpsNum: 30, fpsDen: 1 },
|
||||
audio: "throw",
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("failed to probe audio");
|
||||
expect(result.error).toContain("audio probe boom");
|
||||
});
|
||||
|
||||
it("propagates invalid video info (frameCount=0) as success=false", async () => {
|
||||
const { input } = harness({
|
||||
video: { frameCount: 0, fpsNum: 30, fpsDen: 1 },
|
||||
audio: { durationSeconds: 4.0 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("invalid video frame info");
|
||||
});
|
||||
|
||||
it("propagates invalid video info (fpsDen=0)", async () => {
|
||||
const { input } = harness({
|
||||
video: { frameCount: 100, fpsNum: 30, fpsDen: 0 },
|
||||
audio: { durationSeconds: 4.0 },
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("invalid video frame info");
|
||||
});
|
||||
|
||||
it("propagates ffmpeg failure as success=false with the ffmpeg error preserved", async () => {
|
||||
const { input } = harness({
|
||||
video: { frameCount: 180, fpsNum: 30, fpsDen: 1 },
|
||||
audio: { durationSeconds: 5.0 },
|
||||
ffmpeg: async () => ({ success: false, error: "synthetic ffmpeg failure" }),
|
||||
});
|
||||
const result = await padOrTrimAudioToVideoFrameCount(input);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("synthetic ffmpeg failure");
|
||||
expect(result.operation).toBe("pad");
|
||||
expect(result.targetDurationSeconds).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
// fallow-ignore-file complexity
|
||||
/**
|
||||
* audioPadTrim — pad-or-trim an `audio.aac` file so its exact duration
|
||||
* matches the assembled video's frame count divided by fps.
|
||||
*
|
||||
* Distributed render assemble step needs this because:
|
||||
* - The plan's audio is mixed once against the composition's *declared*
|
||||
* duration.
|
||||
* - The actual video produced by concatenating per-chunk encodes is the
|
||||
* sum of per-chunk frame counts. With closed-GOP concat-copy this is
|
||||
* deterministic and equals the planned frame count, BUT downstream
|
||||
* muxers (ffmpeg `-shortest` plus Apple's mov demuxer in particular)
|
||||
* are sensitive to ±1ms audio/video duration drift and produce silent
|
||||
* "audio cuts off early" or "video shows a frozen final frame" bugs.
|
||||
*
|
||||
* The fix: post-pad/trim audio to *exactly* `frameCount / fps` seconds at
|
||||
* assemble time. Pad by concat-copying a generated silence tail, trim with
|
||||
* `-t`, and avoid re-encoding the already mixed source AAC in either case.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { rmSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
extractAudioMetadata,
|
||||
formatFfmpegError,
|
||||
getFfmpegBinary,
|
||||
getFfprobeBinary,
|
||||
runFfmpeg,
|
||||
type AudioMetadata,
|
||||
} from "@hyperframes/engine";
|
||||
|
||||
/**
|
||||
* Tolerance used to decide whether an audio file is already short enough to
|
||||
* skip the pad/trim operation entirely. ~1ms is well below the perceptual
|
||||
* threshold and well below any frame interval at 24/30/60fps.
|
||||
*/
|
||||
const AUDIO_DURATION_TOLERANCE_SECONDS = 0.001;
|
||||
|
||||
export interface ProbeVideoFrameInfo {
|
||||
/** Number of video frames in the stream. */
|
||||
frameCount: number;
|
||||
/** Numerator of the frame rate fraction (e.g. 30 or 30000). */
|
||||
fpsNum: number;
|
||||
/** Denominator of the frame rate fraction (e.g. 1 or 1001). */
|
||||
fpsDen: number;
|
||||
}
|
||||
|
||||
export interface AudioProbeInfo {
|
||||
/** Decoded duration in seconds. */
|
||||
durationSeconds: number;
|
||||
/** Audio sample rate in Hz. Used when generating pad silence. */
|
||||
sampleRate?: number;
|
||||
/** Audio channel count. Used when generating pad silence. */
|
||||
channels?: number;
|
||||
/** Codec name reported by ffprobe. */
|
||||
audioCodec?: string;
|
||||
}
|
||||
|
||||
export interface PadTrimAudioInput {
|
||||
/** Path to the assembled video. Used to derive `frameCount / fps`. */
|
||||
videoPath: string;
|
||||
/** Path to the pre-mixed audio (typically `<planDir>/audio.aac`). */
|
||||
audioPath: string;
|
||||
/** Path the helper writes the duration-corrected audio to. */
|
||||
outputPath: string;
|
||||
/**
|
||||
* Optional injectables for unit tests. Production callers omit them and
|
||||
* get the real `ffprobe`/`ffmpeg`-backed implementations.
|
||||
*/
|
||||
probeVideoFrameInfo?: (videoPath: string) => Promise<ProbeVideoFrameInfo>;
|
||||
probeAudioInfo?: (audioPath: string) => Promise<AudioProbeInfo>;
|
||||
runFfmpeg?: (
|
||||
args: string[],
|
||||
options?: { stdin?: string },
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export type PadTrimOperation = "pad" | "trim" | "copy";
|
||||
|
||||
export interface PadTrimAudioResult {
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
/** `frameCount / fps` to ~nanosecond precision. */
|
||||
targetDurationSeconds: number;
|
||||
/** Probed duration of the input audio. */
|
||||
sourceDurationSeconds: number;
|
||||
/** How the duration was corrected. */
|
||||
operation: PadTrimOperation;
|
||||
/** Populated only when `success === false`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type PadTrimAudioStepKind = "copy" | "trim" | "pad-silence" | "pad-concat";
|
||||
|
||||
export interface PadTrimAudioStep {
|
||||
kind: PadTrimAudioStepKind;
|
||||
args: string[];
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
export interface PadTrimAudioPlan {
|
||||
operation: PadTrimOperation;
|
||||
steps: PadTrimAudioStep[];
|
||||
cleanupPaths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper: decide the pad/trim operation and build the ffmpeg argv
|
||||
* sequence that materializes it. Exported separately so unit tests can pin
|
||||
* every branch without spawning ffmpeg.
|
||||
*
|
||||
* - `sourceDuration < targetDuration` → generate only the missing silence
|
||||
* tail, then concat-copy the source AAC plus that tail. This avoids
|
||||
* re-encoding the already mixed `audio.aac`; the pad branch remains the
|
||||
* inverse of trim instead of becoming a second full-source AAC encode.
|
||||
* - `sourceDuration > targetDuration` → trim with `-t target`. `-c:a copy`
|
||||
* is preserved when the input is already AAC.
|
||||
* - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still
|
||||
* run ffmpeg with `-c:a copy` to materialize the output path.
|
||||
*/
|
||||
export function buildPadTrimAudioPlan(
|
||||
audioPath: string,
|
||||
outputPath: string,
|
||||
sourceDurationSeconds: number,
|
||||
targetDurationSeconds: number,
|
||||
audioInfo: Pick<AudioProbeInfo, "sampleRate" | "channels"> = {},
|
||||
): PadTrimAudioPlan {
|
||||
const delta = targetDurationSeconds - sourceDurationSeconds;
|
||||
const targetSec = formatSeconds(targetDurationSeconds);
|
||||
if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
|
||||
return {
|
||||
operation: "copy",
|
||||
steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
|
||||
cleanupPaths: [],
|
||||
};
|
||||
}
|
||||
if (delta > 0) {
|
||||
const padDur = formatSeconds(delta);
|
||||
const silencePath = `${outputPath}.pad-silence.aac`;
|
||||
return {
|
||||
operation: "pad",
|
||||
steps: [
|
||||
{
|
||||
kind: "pad-silence",
|
||||
args: [
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`anullsrc=channel_layout=${channelLayoutForChannels(audioInfo.channels)}:sample_rate=${sampleRateForFilter(audioInfo.sampleRate)}`,
|
||||
"-t",
|
||||
padDur,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-y",
|
||||
silencePath,
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "pad-concat",
|
||||
args: [
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-protocol_whitelist",
|
||||
"file,pipe,crypto,data",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
stdin: `${concatFileLine(audioPath)}\n${concatFileLine(silencePath)}\n`,
|
||||
},
|
||||
],
|
||||
cleanupPaths: [silencePath],
|
||||
};
|
||||
}
|
||||
// Trim. `-t` truncates AAC without re-encoding because AAC frames are
|
||||
// independently decodable; ffmpeg snaps the cut point to the nearest
|
||||
// packet boundary, fine for the ±1ms tolerance we care about here.
|
||||
return {
|
||||
operation: "trim",
|
||||
steps: [
|
||||
{ kind: "trim", args: ["-i", audioPath, "-t", targetSec, "-c:a", "copy", "-y", outputPath] },
|
||||
],
|
||||
cleanupPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPadTrimAudioArgs(
|
||||
audioPath: string,
|
||||
outputPath: string,
|
||||
sourceDurationSeconds: number,
|
||||
targetDurationSeconds: number,
|
||||
): { args: string[]; operation: PadTrimOperation } {
|
||||
const plan = buildPadTrimAudioPlan(
|
||||
audioPath,
|
||||
outputPath,
|
||||
sourceDurationSeconds,
|
||||
targetDurationSeconds,
|
||||
);
|
||||
return { operation: plan.operation, args: plan.steps[0]?.args ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration as a fixed-precision decimal string. ffmpeg parses
|
||||
* scientific notation inconsistently across versions (some treat `1e-3` as
|
||||
* a literal time arg, some don't), so we explicitly avoid it.
|
||||
*/
|
||||
function formatSeconds(sec: number): string {
|
||||
// 6 decimal places = ~microseconds, well under one AAC frame at 48 kHz.
|
||||
return sec.toFixed(6);
|
||||
}
|
||||
|
||||
function sampleRateForFilter(sampleRate: number | undefined): number {
|
||||
return sampleRate !== undefined && Number.isFinite(sampleRate) && sampleRate > 0
|
||||
? Math.round(sampleRate)
|
||||
: 48000;
|
||||
}
|
||||
|
||||
function channelLayoutForChannels(channels: number | undefined): string {
|
||||
if (channels === 1) return "mono";
|
||||
if (channels === 6) return "5.1";
|
||||
if (channels === 8) return "7.1";
|
||||
return "stereo";
|
||||
}
|
||||
|
||||
function concatFileLine(path: string): string {
|
||||
const normalized = pathToFileURL(path).href;
|
||||
return `file '${normalized.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad or trim `audio.aac` so its exact duration matches `frameCount / fps`
|
||||
* for the assembled video.
|
||||
*/
|
||||
export async function padOrTrimAudioToVideoFrameCount(
|
||||
input: PadTrimAudioInput,
|
||||
): Promise<PadTrimAudioResult> {
|
||||
const probeVideo = input.probeVideoFrameInfo ?? defaultProbeVideoFrameInfo;
|
||||
const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
|
||||
const runner = input.runFfmpeg ?? defaultRunFfmpeg;
|
||||
|
||||
// Probe video and audio in parallel — the two ffprobe invocations are
|
||||
// independent and account for most of this function's wall-clock time.
|
||||
const [videoResult, audioResult] = await Promise.allSettled([
|
||||
probeVideo(input.videoPath),
|
||||
probeAudio(input.audioPath),
|
||||
]);
|
||||
|
||||
if (videoResult.status === "rejected") {
|
||||
return failResult(
|
||||
input.outputPath,
|
||||
0,
|
||||
audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
|
||||
`audioPadTrim: failed to probe video: ${(videoResult.reason as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (audioResult.status === "rejected") {
|
||||
return failResult(
|
||||
input.outputPath,
|
||||
0,
|
||||
0,
|
||||
`audioPadTrim: failed to probe audio: ${(audioResult.reason as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const videoInfo = videoResult.value;
|
||||
const audioInfo = audioResult.value;
|
||||
|
||||
if (
|
||||
!Number.isFinite(videoInfo.frameCount) ||
|
||||
videoInfo.frameCount <= 0 ||
|
||||
!Number.isFinite(videoInfo.fpsNum) ||
|
||||
videoInfo.fpsNum <= 0 ||
|
||||
!Number.isFinite(videoInfo.fpsDen) ||
|
||||
videoInfo.fpsDen <= 0
|
||||
) {
|
||||
return failResult(
|
||||
input.outputPath,
|
||||
0,
|
||||
audioInfo.durationSeconds,
|
||||
`audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetDurationSeconds = (videoInfo.frameCount * videoInfo.fpsDen) / videoInfo.fpsNum;
|
||||
const plan = buildPadTrimAudioPlan(
|
||||
input.audioPath,
|
||||
input.outputPath,
|
||||
audioInfo.durationSeconds,
|
||||
targetDurationSeconds,
|
||||
audioInfo,
|
||||
);
|
||||
|
||||
try {
|
||||
for (const step of plan.steps) {
|
||||
const ffmpegResult = await runner(step.args, { stdin: step.stdin });
|
||||
if (!ffmpegResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath: input.outputPath,
|
||||
targetDurationSeconds,
|
||||
sourceDurationSeconds: audioInfo.durationSeconds,
|
||||
operation: plan.operation,
|
||||
error: ffmpegResult.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath: input.outputPath,
|
||||
targetDurationSeconds,
|
||||
sourceDurationSeconds: audioInfo.durationSeconds,
|
||||
operation: plan.operation,
|
||||
error: `audioPadTrim: failed to materialize ${plan.operation}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
};
|
||||
} finally {
|
||||
for (const path of plan.cleanupPaths) rmSync(path, { force: true });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
outputPath: input.outputPath,
|
||||
targetDurationSeconds,
|
||||
sourceDurationSeconds: audioInfo.durationSeconds,
|
||||
operation: plan.operation,
|
||||
};
|
||||
}
|
||||
|
||||
function failResult(
|
||||
outputPath: string,
|
||||
target: number,
|
||||
source: number,
|
||||
error: string,
|
||||
): PadTrimAudioResult {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
targetDurationSeconds: target,
|
||||
sourceDurationSeconds: source,
|
||||
operation: "copy",
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// ── default probe/run implementations ─────────────────────────────────────
|
||||
|
||||
interface FfprobeStreamInfo {
|
||||
nb_read_packets?: string;
|
||||
nb_frames?: string;
|
||||
r_frame_rate?: string;
|
||||
}
|
||||
|
||||
interface FfprobeOutput {
|
||||
streams?: FfprobeStreamInfo[];
|
||||
}
|
||||
|
||||
async function defaultProbeVideoFrameInfo(videoPath: string): Promise<ProbeVideoFrameInfo> {
|
||||
// Try the container header (`nb_frames`) first — single moov atom read,
|
||||
// no decode. Closed-GOP, B-frame-free streams (the only ones we'll ever
|
||||
// ask to pad/trim) reliably set it. Fall back to `-count_packets` which
|
||||
// walks the packet stream when the header doesn't carry the count.
|
||||
const fastInfo = await runFfprobeJson<FfprobeOutput>([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=nb_frames,r_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
videoPath,
|
||||
]);
|
||||
let stream = fastInfo.streams?.[0];
|
||||
const fastCount = Number(stream?.nb_frames);
|
||||
if (stream && Number.isFinite(fastCount) && fastCount > 0) {
|
||||
return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
||||
}
|
||||
const slowInfo = await runFfprobeJson<FfprobeOutput>([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_packets",
|
||||
"-show_entries",
|
||||
"stream=nb_read_packets,r_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
videoPath,
|
||||
]);
|
||||
stream = slowInfo.streams?.[0];
|
||||
if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
|
||||
const slowCount = Number(stream.nb_read_packets);
|
||||
if (!Number.isFinite(slowCount) || slowCount <= 0) {
|
||||
throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
|
||||
}
|
||||
return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
||||
}
|
||||
|
||||
function parseFrameRate(rate: string): { fpsNum: number; fpsDen: number } {
|
||||
const [n, d] = rate.split("/");
|
||||
const fpsNum = Number(n);
|
||||
const fpsDen = d === undefined ? 1 : Number(d);
|
||||
if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
|
||||
throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
|
||||
}
|
||||
return { fpsNum, fpsDen };
|
||||
}
|
||||
|
||||
async function defaultProbeAudioInfo(audioPath: string): Promise<AudioProbeInfo> {
|
||||
// extractAudioMetadata is the shared ffprobe wrapper (caches results).
|
||||
const metadata: AudioMetadata = await extractAudioMetadata(audioPath);
|
||||
return {
|
||||
durationSeconds: metadata.durationSeconds,
|
||||
sampleRate: metadata.sampleRate,
|
||||
channels: metadata.channels,
|
||||
audioCodec: metadata.audioCodec,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultRunFfmpeg(
|
||||
args: string[],
|
||||
options?: { stdin?: string },
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (options?.stdin !== undefined) return runFfmpegWithStdin(args, options.stdin);
|
||||
|
||||
const result = await runFfmpeg(args);
|
||||
if (result.success) return { success: true };
|
||||
return {
|
||||
success: false,
|
||||
error: `[audioPadTrim] ${formatFfmpegError(result.exitCode, result.stderr)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runFfmpegWithStdin(
|
||||
args: string[],
|
||||
stdin: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(getFfmpegBinary(), args);
|
||||
let stderr = "";
|
||||
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `[audioPadTrim] ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
success: false,
|
||||
error: `[audioPadTrim] ${formatFfmpegError(code, stderr)}`,
|
||||
});
|
||||
});
|
||||
|
||||
proc.stdin.end(stdin);
|
||||
});
|
||||
}
|
||||
|
||||
// ── ffprobe JSON runner (shared between fast/slow video probe paths) ─────
|
||||
|
||||
function runFfprobeJson<T>(args: string[]): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(getFfprobeBinary(), args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg."));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffprobe exited ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout) as T);
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse ffprobe output: ${(err as Error).message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveVideoCaptureBeyondViewport } from "./captureBeyondViewport.js";
|
||||
|
||||
describe("resolveVideoCaptureBeyondViewport", () => {
|
||||
it("leaves no-video renders on the engine default", () => {
|
||||
expect(resolveVideoCaptureBeyondViewport(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forces beyond-viewport for any video render so the bottom edge is not clipped", () => {
|
||||
// Regression: software hosts (including every distributed chunk render,
|
||||
// which resolves as "software") previously returned false here and clipped
|
||||
// ~87 bottom rows to black on video comps.
|
||||
expect(resolveVideoCaptureBeyondViewport(1)).toBe(true);
|
||||
expect(resolveVideoCaptureBeyondViewport(5)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Native video surfaces need Chrome's beyond-viewport screenshot path or the
|
||||
* viewport-bound capture clips the bottom edge of the frame — the same
|
||||
* #1094 tall-portrait guard the alpha capture paths already hardcode
|
||||
* (`captureScreenshotWithAlpha` / `captureAlphaPng`).
|
||||
*
|
||||
* This used to be gated to hardware-GPU captures to skip the full-surface
|
||||
* software re-rasterization tax on SwiftShader/CPU render hosts. But that left
|
||||
* software hosts clipping ~87 bottom rows to black on video comps — and every
|
||||
* distributed chunk render resolves as "software", so the entire distributed
|
||||
* fleet shipped video renders with a black bottom band. Correct output wins
|
||||
* over the software perf optimization: enable beyond-viewport for any render
|
||||
* that has a native video surface, regardless of GPU mode.
|
||||
*
|
||||
* ponytail: blanket-on for video. If the software re-raster tax proves
|
||||
* significant on the distributed fleet, narrow it back to comps whose content
|
||||
* actually reaches the bottom edge — but that needs a reliable clip predictor
|
||||
* first, and a black band is unshippable in the meantime.
|
||||
*/
|
||||
export function resolveVideoCaptureBeyondViewport(videoCount: number): boolean | undefined {
|
||||
if (videoCount <= 0) return undefined;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Capture-cost calibration and worker-count resolution.
|
||||
*
|
||||
* The "calibration" flow renders a handful of representative frames in
|
||||
* a throwaway `CaptureSession` and uses p95 capture time to scale the
|
||||
* auto-worker budget. The calibration ceiling
|
||||
* (`MAX_MEASURED_CAPTURE_COST_MULTIPLIER`) and target
|
||||
* (`CAPTURE_CALIBRATION_TARGET_MS`) are tunable knobs — they pin the
|
||||
* relationship between observed capture time and worker count.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { fpsToNumber } from "@hyperframes/core";
|
||||
import {
|
||||
type BeforeCaptureHook,
|
||||
type CaptureOptions,
|
||||
type CaptureSession,
|
||||
type EngineConfig,
|
||||
calculateOptimalWorkers,
|
||||
captureFrameToBuffer,
|
||||
closeCaptureSession,
|
||||
createCaptureSession,
|
||||
initializeSession,
|
||||
} from "@hyperframes/engine";
|
||||
import type { CompiledComposition } from "../htmlCompiler.js";
|
||||
import type { FileServerHandle } from "../fileServer.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../../logger.js";
|
||||
import type { RenderJob } from "../renderOrchestrator.js";
|
||||
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
||||
|
||||
export interface CaptureCostEstimate {
|
||||
multiplier: number;
|
||||
reasons: string[];
|
||||
p95Ms?: number;
|
||||
}
|
||||
|
||||
export interface CaptureCalibrationSample {
|
||||
frameIndex: number;
|
||||
captureTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target p95 capture time used to scale the auto-worker budget. If the
|
||||
* measured p95 exceeds this, the multiplier ratchets up. Empirically
|
||||
* tuned against the producer's regression-harness fixtures.
|
||||
*/
|
||||
export const CAPTURE_CALIBRATION_TARGET_MS = 600;
|
||||
|
||||
/**
|
||||
* Ceiling on the measured cost multiplier. Without this, a pathological
|
||||
* 30-second capture would push the auto-worker budget arbitrarily high.
|
||||
*/
|
||||
export const MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
|
||||
|
||||
/**
|
||||
* CDP protocol timeout used while running calibration. This is a ceiling,
|
||||
* not a floor — a wedged BeginFrame must time out fast so the sequencer
|
||||
* can fall back to screenshot mode via
|
||||
* `shouldFallbackToScreenshotAfterCalibrationError`.
|
||||
*/
|
||||
export const CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function estimateCaptureCostMultiplier(
|
||||
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
|
||||
): CaptureCostEstimate {
|
||||
let multiplier = 1;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (compiled.hasShaderTransitions) {
|
||||
multiplier += 2;
|
||||
reasons.push("shader-transitions");
|
||||
}
|
||||
|
||||
const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
|
||||
if (reasonCodes.has("requestAnimationFrame")) {
|
||||
multiplier += 1;
|
||||
reasons.push("requestAnimationFrame");
|
||||
}
|
||||
if (reasonCodes.has("iframe")) {
|
||||
multiplier += 0.5;
|
||||
reasons.push("iframe");
|
||||
}
|
||||
|
||||
return {
|
||||
multiplier: Math.round(multiplier * 100) / 100,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
function combineCaptureCostEstimates(
|
||||
staticCost: CaptureCostEstimate,
|
||||
measuredCost?: CaptureCostEstimate,
|
||||
): CaptureCostEstimate {
|
||||
if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
|
||||
if (staticCost.multiplier >= measuredCost.multiplier) {
|
||||
return {
|
||||
multiplier: staticCost.multiplier,
|
||||
reasons: [...staticCost.reasons, ...measuredCost.reasons],
|
||||
p95Ms: measuredCost.p95Ms,
|
||||
};
|
||||
}
|
||||
return {
|
||||
multiplier: measuredCost.multiplier,
|
||||
reasons: [...measuredCost.reasons, ...staticCost.reasons],
|
||||
p95Ms: measuredCost.p95Ms,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRenderWorkerCount(
|
||||
totalFrames: number,
|
||||
requestedWorkers: number | undefined,
|
||||
cfg: EngineConfig,
|
||||
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
|
||||
log: ProducerLogger = defaultLogger,
|
||||
measuredCaptureCost?: CaptureCostEstimate,
|
||||
): number {
|
||||
// TODO(htmlInCanvas): workaround — Chrome's experimental drawElementImage
|
||||
// API (CanvasDrawElement) is non-deterministic across concurrent browser
|
||||
// instances due to paint-cache races and SwiftShader contention.
|
||||
// Remove this clamp once Chromium stabilizes CanvasDrawElement for
|
||||
// concurrent use.
|
||||
const reasonCodes = new Set(compiled.renderModeHints.reasons.map((r) => r.code));
|
||||
if (reasonCodes.has("htmlInCanvas")) {
|
||||
log.warn(
|
||||
"[Render] html-in-canvas (drawElementImage) detected — pinning to 1 worker (Chrome concurrency limitation).",
|
||||
{ requestedWorkers },
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Low-memory safe profile pins capture to a single worker (unless the user
|
||||
// asked for a specific count) so the pipeline never runs N concurrent
|
||||
// Chrome instances on a constrained host. Kept here, alongside the other
|
||||
// worker-count decisions, so the "why workers=N" log stays coherent across
|
||||
// every path into capture.
|
||||
if (cfg.lowMemoryMode && requestedWorkers === undefined) {
|
||||
log.info(
|
||||
"[Render] Low-memory profile — pinning to 1 capture worker (auto-worker calibration skipped).",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const captureCost = combineCaptureCostEstimates(
|
||||
estimateCaptureCostMultiplier(compiled),
|
||||
measuredCaptureCost,
|
||||
);
|
||||
const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
|
||||
...cfg,
|
||||
captureCostMultiplier: captureCost.multiplier,
|
||||
});
|
||||
|
||||
if (requestedWorkers !== undefined || captureCost.multiplier <= 1) {
|
||||
return workerCount;
|
||||
}
|
||||
|
||||
const baselineWorkers = calculateOptimalWorkers(totalFrames, undefined, cfg);
|
||||
if (workerCount < baselineWorkers) {
|
||||
log.warn(
|
||||
"[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
|
||||
{
|
||||
from: baselineWorkers,
|
||||
to: workerCount,
|
||||
costMultiplier: captureCost.multiplier,
|
||||
reasons: captureCost.reasons,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return workerCount;
|
||||
}
|
||||
|
||||
export function createCaptureCalibrationConfig(cfg: EngineConfig): EngineConfig {
|
||||
return {
|
||||
...cfg,
|
||||
protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS),
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateMeasuredCaptureCostMultiplier(
|
||||
samples: CaptureCalibrationSample[],
|
||||
): CaptureCostEstimate {
|
||||
if (samples.length === 0) {
|
||||
return { multiplier: 1, reasons: [] };
|
||||
}
|
||||
|
||||
const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
|
||||
const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
|
||||
const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
|
||||
if (!p95Sample) {
|
||||
return { multiplier: 1, reasons: [] };
|
||||
}
|
||||
const p95Ms = Math.round(p95Sample.captureTimeMs);
|
||||
const multiplier = Math.min(
|
||||
MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
|
||||
Math.max(1, Math.round((p95Ms / CAPTURE_CALIBRATION_TARGET_MS) * 100) / 100),
|
||||
);
|
||||
|
||||
return {
|
||||
multiplier,
|
||||
reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
|
||||
p95Ms,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectCaptureCalibrationFrames(totalFrames: number): number[] {
|
||||
if (totalFrames <= 0) return [];
|
||||
const lastFrame = totalFrames - 1;
|
||||
const candidates = [
|
||||
0,
|
||||
Math.floor(totalFrames * 0.25),
|
||||
Math.floor(totalFrames * 0.5),
|
||||
Math.floor(totalFrames * 0.75),
|
||||
lastFrame,
|
||||
];
|
||||
return Array.from(
|
||||
new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame)))),
|
||||
).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export async function measureCaptureCostFromSession(
|
||||
session: CaptureSession,
|
||||
totalFrames: number,
|
||||
fps: number,
|
||||
log?: ProducerLogger,
|
||||
): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> {
|
||||
const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
|
||||
const samples: CaptureCalibrationSample[] = [];
|
||||
const totalSamples = sampledFrames.length;
|
||||
|
||||
// Calibration samples are SPARSE and non-contiguous, so static-frame dedup must not
|
||||
// fire here: a sampled frame in the static set would reuse a far-away sample's buffer
|
||||
// in ~0ms, both corrupting the per-frame cost estimate and returning the wrong pixels.
|
||||
// Bypass dedup for the calibration sweep; restore the armed set (and clear the
|
||||
// calibration-era buffer) so the real render that reuses this session still dedups.
|
||||
const savedStaticFrames = session.staticFrames;
|
||||
session.staticFrames = undefined;
|
||||
try {
|
||||
for (let i = 0; i < sampledFrames.length; i++) {
|
||||
const frameIndex = sampledFrames[i]!;
|
||||
log?.info(`Calibration: capturing test frame ${i + 1}/${totalSamples}...`);
|
||||
const time = frameIndex / fps;
|
||||
const startedAt = Date.now();
|
||||
const result = await captureFrameToBuffer(session, frameIndex, time);
|
||||
samples.push({
|
||||
frameIndex,
|
||||
captureTimeMs: result.captureTimeMs || Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
session.staticFrames = savedStaticFrames;
|
||||
session.lastFrameBuffer = undefined;
|
||||
}
|
||||
|
||||
const estimate = estimateMeasuredCaptureCostMultiplier(samples);
|
||||
if (estimate.p95Ms !== undefined) {
|
||||
log?.info(`Calibration complete, estimated cost: ${estimate.p95Ms}ms/frame (p95)`);
|
||||
}
|
||||
|
||||
return {
|
||||
estimate,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
export function logCaptureCalibrationResult(
|
||||
calibration: { estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] },
|
||||
log: ProducerLogger,
|
||||
): void {
|
||||
if (calibration.estimate.multiplier > 1) {
|
||||
log.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
|
||||
multiplier: calibration.estimate.multiplier,
|
||||
p95Ms: calibration.estimate.p95Ms,
|
||||
sampledFrames: calibration.samples.map((sample) => sample.frameIndex),
|
||||
});
|
||||
} else {
|
||||
log.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
|
||||
p95Ms: calibration.estimate.p95Ms,
|
||||
sampledFrames: calibration.samples.map((sample) => sample.frameIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type CaptureCalibrationFailureReason =
|
||||
| "calibration-failed"
|
||||
| "calibration-screenshot-failed";
|
||||
|
||||
export function createFailedCaptureCalibrationEstimate(reason: CaptureCalibrationFailureReason): {
|
||||
estimate: CaptureCostEstimate;
|
||||
samples: CaptureCalibrationSample[];
|
||||
} {
|
||||
return {
|
||||
estimate: {
|
||||
multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
|
||||
reasons: [reason],
|
||||
},
|
||||
samples: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface CaptureCalibrationOutcome {
|
||||
calibration: { estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] } | undefined;
|
||||
/** Flipped to `true` if BeginFrame calibration timed out and the screenshot retry fired. */
|
||||
forceScreenshot: boolean;
|
||||
/** Closed and nulled when the screenshot fallback fires; passthrough otherwise. */
|
||||
probeSession: CaptureSession | null;
|
||||
/** Buffer of whichever session was active last; the sequencer uses it for the error-path tail. */
|
||||
lastBrowserConsole: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the auto-worker capture-cost calibration, including the
|
||||
* BeginFrame → screenshot fallback on timeout. Owns the calibration
|
||||
* session lifecycle and may close the caller-owned `probeSession` when
|
||||
* the fallback fires (BeginFrame is no longer the active capture mode,
|
||||
* so the probe session is no longer reusable).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function runCaptureCalibration(input: {
|
||||
cfg: EngineConfig;
|
||||
fileServer: FileServerHandle;
|
||||
workDir: string;
|
||||
log: ProducerLogger;
|
||||
job: RenderJob;
|
||||
totalFrames: number;
|
||||
forceScreenshot: boolean;
|
||||
probeSession: CaptureSession | null;
|
||||
buildCaptureOptions: () => CaptureOptions;
|
||||
createRenderVideoFrameInjector: () => BeforeCaptureHook | null;
|
||||
/** Throws `RenderCancelledError` when the caller's abort signal fires. */
|
||||
assertNotAborted: () => void;
|
||||
}): Promise<CaptureCalibrationOutcome> {
|
||||
const {
|
||||
cfg,
|
||||
fileServer,
|
||||
workDir,
|
||||
log,
|
||||
job,
|
||||
totalFrames,
|
||||
buildCaptureOptions,
|
||||
createRenderVideoFrameInjector,
|
||||
assertNotAborted,
|
||||
} = input;
|
||||
let probeSession = input.probeSession;
|
||||
let forceScreenshot = input.forceScreenshot;
|
||||
let lastBrowserConsole: string[] = [];
|
||||
|
||||
const fps = fpsToNumber(job.config.fps);
|
||||
// Holds whichever calibration session is currently open. The closure
|
||||
// writes into the outer `sessionRef` (an object) rather than a `let`
|
||||
// so the `finally` and the fallback branch read the latest value
|
||||
// without TS narrowing it back to the initial `null`.
|
||||
const sessionRef: { current: CaptureSession | null } = { current: null };
|
||||
|
||||
const runOneCalibration = async (
|
||||
sessionDir: string,
|
||||
sessionCfg: EngineConfig,
|
||||
): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> => {
|
||||
log.info("Launching browser for capture calibration...");
|
||||
const session = await createCaptureSession(
|
||||
fileServer.url,
|
||||
sessionDir,
|
||||
buildCaptureOptions(),
|
||||
createRenderVideoFrameInjector(),
|
||||
sessionCfg,
|
||||
);
|
||||
sessionRef.current = session;
|
||||
if (!session.isInitialized) {
|
||||
log.info("Initializing calibration session...");
|
||||
const calInitStart = Date.now();
|
||||
const calHeartbeat = setInterval(() => {
|
||||
const elapsed = ((Date.now() - calInitStart) / 1000).toFixed(1);
|
||||
log.info(`Still waiting for browser initialization... (${elapsed}s elapsed)`);
|
||||
}, 30_000);
|
||||
try {
|
||||
await initializeSession(session);
|
||||
} finally {
|
||||
clearInterval(calHeartbeat);
|
||||
}
|
||||
}
|
||||
assertNotAborted();
|
||||
log.info("Calibration session ready, capturing test frames...");
|
||||
const result = await measureCaptureCostFromSession(session, totalFrames, fps, log);
|
||||
logCaptureCalibrationResult(result, log);
|
||||
return result;
|
||||
};
|
||||
|
||||
const calibrationCfg = createCaptureCalibrationConfig({ ...cfg, forceScreenshot });
|
||||
log.info("[Render] Calibration config", {
|
||||
protocolTimeout: calibrationCfg.protocolTimeout,
|
||||
parentProtocolTimeout: cfg.protocolTimeout,
|
||||
forceScreenshot,
|
||||
totalFrames,
|
||||
});
|
||||
let calibration:
|
||||
| { estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
calibration = await runOneCalibration(join(workDir, "capture-calibration"), calibrationCfg);
|
||||
} catch (error) {
|
||||
const shouldFallback =
|
||||
!forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
|
||||
if (!shouldFallback) {
|
||||
calibration = createFailedCaptureCalibrationEstimate("calibration-failed");
|
||||
log.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
|
||||
protocolTimeout: calibrationCfg.protocolTimeout,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} else {
|
||||
// BeginFrame failed on this host's Chrome build; switch the rest
|
||||
// of the pipeline to screenshot capture. Flip only the local
|
||||
// boolean — `cfg` stays the compile-time view; downstream stages
|
||||
// receive the new value via the explicit `forceScreenshot` param.
|
||||
forceScreenshot = true;
|
||||
if (probeSession) {
|
||||
// Snapshot the probe buffer before closing — if the screenshot
|
||||
// session create that follows also fails, this is the only place
|
||||
// the BeginFrame-era diagnostic survives for the caller's
|
||||
// error-path browser-console tail.
|
||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||
await closeCaptureSession(probeSession).catch(() => {});
|
||||
probeSession = null;
|
||||
}
|
||||
if (sessionRef.current) {
|
||||
lastBrowserConsole = sessionRef.current.browserConsoleBuffer;
|
||||
await closeCaptureSession(sessionRef.current).catch(() => {});
|
||||
sessionRef.current = null;
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"[Render] BeginFrame auto-worker calibration timed out; retrying calibration in screenshot capture mode.",
|
||||
{
|
||||
protocolTimeout: calibrationCfg.protocolTimeout,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
|
||||
const screenshotCfg = createCaptureCalibrationConfig({ ...cfg, forceScreenshot: true });
|
||||
try {
|
||||
calibration = await runOneCalibration(
|
||||
join(workDir, "capture-calibration-screenshot"),
|
||||
screenshotCfg,
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
calibration = createFailedCaptureCalibrationEstimate("calibration-screenshot-failed");
|
||||
log.warn(
|
||||
"[Render] Screenshot auto-worker calibration failed after BeginFrame fallback; using conservative worker budget.",
|
||||
{
|
||||
protocolTimeout: screenshotCfg.protocolTimeout,
|
||||
error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (sessionRef.current) {
|
||||
lastBrowserConsole = sessionRef.current.browserConsoleBuffer;
|
||||
await closeCaptureSession(sessionRef.current).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return { calibration, forceScreenshot, probeSession, lastBrowserConsole };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `runCaptureCalibration`'s error-classification check, but
|
||||
* exported separately because the sequencer also calls it from the
|
||||
* disk-capture retry loop. Returns `true` for the BeginFrame-specific
|
||||
* protocol errors that recover cleanly under screenshot mode.
|
||||
*/
|
||||
export function shouldFallbackToScreenshotAfterCalibrationError(error: unknown): boolean {
|
||||
const message = normalizeErrorMessage(error);
|
||||
return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame|Runtime\.callFunctionOn timed out|Runtime\.evaluate timed out/i.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
||||
|
||||
export class CaptureStageError extends Error {
|
||||
readonly browserConsole: string[];
|
||||
readonly cause: unknown;
|
||||
|
||||
constructor(input: { cause: unknown; browserConsole: string[] }) {
|
||||
super(normalizeErrorMessage(input.cause));
|
||||
this.name = "CaptureStageError";
|
||||
this.cause = input.cause;
|
||||
this.browserConsole = input.browserConsole.slice();
|
||||
if (input.cause instanceof Error && input.cause.stack) {
|
||||
this.stack = input.cause.stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapCaptureStageError(error: unknown, browserConsole: string[]): CaptureStageError {
|
||||
if (error instanceof CaptureStageError) return error;
|
||||
return new CaptureStageError({ cause: error, browserConsole });
|
||||
}
|
||||
|
||||
export function getCaptureStageBrowserConsole(error: unknown): string[] {
|
||||
if (error instanceof CaptureStageError) return error.browserConsole;
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Tests for the cancel/error-path helpers in `./cleanup.ts`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CaptureSession } from "@hyperframes/engine";
|
||||
import type { FileServerHandle } from "../fileServer.js";
|
||||
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./cleanup.js";
|
||||
|
||||
function makeLog() {
|
||||
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
||||
}
|
||||
|
||||
describe("safeCleanup", () => {
|
||||
it("returns normally when the operation succeeds", async () => {
|
||||
const log = makeLog();
|
||||
const op = vi.fn().mockResolvedValue(undefined);
|
||||
await safeCleanup("close x", op, log);
|
||||
expect(op).toHaveBeenCalledOnce();
|
||||
expect(log.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows thrown errors and logs them at debug", async () => {
|
||||
const log = makeLog();
|
||||
await safeCleanup(
|
||||
"close x",
|
||||
() => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
log,
|
||||
);
|
||||
expect(log.debug).toHaveBeenCalledWith("Cleanup failed (close x)", { error: "boom" });
|
||||
});
|
||||
|
||||
it("swallows async rejections", async () => {
|
||||
const log = makeLog();
|
||||
await safeCleanup("close x", async () => Promise.reject(new Error("async boom")), log);
|
||||
expect(log.debug).toHaveBeenCalledWith("Cleanup failed (close x)", { error: "async boom" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupRenderResources", () => {
|
||||
it("closes fileServer, probeSession, then removes workDir (non-debug)", async () => {
|
||||
const log = makeLog();
|
||||
const workDir = mkdtempSync(join(tmpdir(), "cleanup-test-"));
|
||||
writeFileSync(join(workDir, "marker.txt"), "x");
|
||||
|
||||
const order: string[] = [];
|
||||
const fileServer = {
|
||||
close: () => {
|
||||
order.push("fileServer.close");
|
||||
},
|
||||
} as unknown as FileServerHandle;
|
||||
const probeSession = {
|
||||
_markClosed: () => {
|
||||
order.push("probeSession.close");
|
||||
},
|
||||
} as unknown as CaptureSession;
|
||||
|
||||
// closeCaptureSession is the engine helper; the helper itself isn't
|
||||
// mockable per-call without intercepting the module import. Instead
|
||||
// we verify the higher-level invariants: fileServer.close was called,
|
||||
// and the workDir was rmSync'd.
|
||||
await cleanupRenderResources({
|
||||
fileServer,
|
||||
probeSession: null, // skip probe to keep this test focused on the workDir invariant
|
||||
workDir,
|
||||
debug: false,
|
||||
log,
|
||||
label: "cancel",
|
||||
});
|
||||
|
||||
expect(order).toEqual(["fileServer.close"]);
|
||||
expect(existsSync(workDir)).toBe(false);
|
||||
void probeSession; // suppress unused-var (kept as a doc of the surface)
|
||||
});
|
||||
|
||||
it("keeps workDir when debug=true", async () => {
|
||||
const log = makeLog();
|
||||
const workDir = mkdtempSync(join(tmpdir(), "cleanup-debug-"));
|
||||
writeFileSync(join(workDir, "marker.txt"), "x");
|
||||
|
||||
await cleanupRenderResources({
|
||||
fileServer: null,
|
||||
probeSession: null,
|
||||
workDir,
|
||||
debug: true,
|
||||
log,
|
||||
label: "error",
|
||||
});
|
||||
|
||||
expect(existsSync(workDir)).toBe(true);
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is a no-op for missing workDir thanks to rmSync force:true", async () => {
|
||||
const log = makeLog();
|
||||
const workDir = join(tmpdir(), `cleanup-missing-${Date.now()}`);
|
||||
expect(existsSync(workDir)).toBe(false);
|
||||
|
||||
await cleanupRenderResources({
|
||||
fileServer: null,
|
||||
probeSession: null,
|
||||
workDir,
|
||||
debug: false,
|
||||
log,
|
||||
label: "error",
|
||||
});
|
||||
|
||||
// No throw; nothing logged at debug for the rmSync step.
|
||||
expect(log.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs (and continues past) a fileServer.close that throws", async () => {
|
||||
const log = makeLog();
|
||||
const workDir = mkdtempSync(join(tmpdir(), "cleanup-throw-"));
|
||||
|
||||
const fileServer = {
|
||||
close: () => {
|
||||
throw new Error("server stuck");
|
||||
},
|
||||
} as unknown as FileServerHandle;
|
||||
|
||||
await cleanupRenderResources({
|
||||
fileServer,
|
||||
probeSession: null,
|
||||
workDir,
|
||||
debug: false,
|
||||
log,
|
||||
label: "error",
|
||||
});
|
||||
|
||||
expect(log.debug).toHaveBeenCalledWith("Cleanup failed (close file server (error))", {
|
||||
error: "server stuck",
|
||||
});
|
||||
expect(existsSync(workDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRenderErrorDetails", () => {
|
||||
const baseDiagnostics = { videoExtractionFailures: 0, imageDecodeFailures: 0 };
|
||||
|
||||
it("extracts message + stack from Error instances", () => {
|
||||
const err = new Error("nope");
|
||||
const result = buildRenderErrorDetails({
|
||||
error: err,
|
||||
pipelineStartMs: Date.now() - 5000,
|
||||
lastBrowserConsole: [],
|
||||
perfStages: {},
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(result.message).toBe("nope");
|
||||
expect(result.stack).toBeDefined();
|
||||
expect(result.elapsedMs).toBeGreaterThanOrEqual(5000);
|
||||
expect(typeof result.freeMemoryMB).toBe("number");
|
||||
});
|
||||
|
||||
it("stringifies non-Error rejections", () => {
|
||||
const result = buildRenderErrorDetails({
|
||||
error: "raw string failure",
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: [],
|
||||
perfStages: {},
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(result.message).toBe("raw string failure");
|
||||
expect(result.stack).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes browserConsoleTail only when buffer is non-empty (last 30 lines)", () => {
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `line ${i}`);
|
||||
const result = buildRenderErrorDetails({
|
||||
error: new Error("x"),
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: lines,
|
||||
perfStages: {},
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(result.browserConsoleTail).toHaveLength(30);
|
||||
expect(result.browserConsoleTail?.[0]).toBe("line 20");
|
||||
expect(result.browserConsoleTail?.[29]).toBe("line 49");
|
||||
});
|
||||
|
||||
it("omits browserConsoleTail when buffer is empty", () => {
|
||||
const result = buildRenderErrorDetails({
|
||||
error: new Error("x"),
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: [],
|
||||
perfStages: {},
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(result.browserConsoleTail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes perfStages snapshot only when non-empty", () => {
|
||||
const empty = buildRenderErrorDetails({
|
||||
error: new Error("x"),
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: [],
|
||||
perfStages: {},
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(empty.perfStages).toBeUndefined();
|
||||
|
||||
const populated = buildRenderErrorDetails({
|
||||
error: new Error("x"),
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: [],
|
||||
perfStages: { compileMs: 12, captureMs: 340 },
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(populated.perfStages).toEqual({ compileMs: 12, captureMs: 340 });
|
||||
});
|
||||
|
||||
it("includes hdrDiagnostics only when at least one failure counter > 0", () => {
|
||||
const clean = buildRenderErrorDetails({
|
||||
error: new Error("x"),
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: [],
|
||||
perfStages: {},
|
||||
hdrDiagnostics: baseDiagnostics,
|
||||
});
|
||||
expect(clean.hdrDiagnostics).toBeUndefined();
|
||||
|
||||
const failed = buildRenderErrorDetails({
|
||||
error: new Error("x"),
|
||||
pipelineStartMs: Date.now(),
|
||||
lastBrowserConsole: [],
|
||||
perfStages: {},
|
||||
hdrDiagnostics: { videoExtractionFailures: 2, imageDecodeFailures: 0 },
|
||||
});
|
||||
expect(failed.hdrDiagnostics).toEqual({ videoExtractionFailures: 2, imageDecodeFailures: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// Quiet unused-import warning — these are referenced via type-only paths.
|
||||
void mkdirSync;
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Sequencer cleanup + error-details helpers shared by the cancel and
|
||||
* error paths in `executeRenderJob`.
|
||||
*/
|
||||
|
||||
import { rmSync } from "node:fs";
|
||||
import { freemem } from "node:os";
|
||||
import {
|
||||
type CaptureSession,
|
||||
type SubTimelineWaitOutcome,
|
||||
closeCaptureSession,
|
||||
} from "@hyperframes/engine";
|
||||
import type { FileServerHandle } from "../fileServer.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../../logger.js";
|
||||
import type { HdrDiagnostics, RenderJob } from "../renderOrchestrator.js";
|
||||
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
||||
import type { RenderObservabilitySummary } from "./observability.js";
|
||||
|
||||
/**
|
||||
* Wrap a cleanup operation so it never throws, but logs any failure.
|
||||
* The sequencer needs to keep tearing down resources even when one of
|
||||
* them is stuck (e.g. a `fileServer.close()` hitting a TCP race); a
|
||||
* thrown cleanup error would mask the original render failure.
|
||||
*/
|
||||
export async function safeCleanup(
|
||||
label: string,
|
||||
fn: () => Promise<void> | void,
|
||||
log: ProducerLogger = defaultLogger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
log.debug(`Cleanup failed (${label})`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the file server, close the probe session, and remove the
|
||||
* working directory. Each step runs through `safeCleanup` so a stuck
|
||||
* resource doesn't mask the original render error.
|
||||
*/
|
||||
export async function cleanupRenderResources(input: {
|
||||
fileServer: FileServerHandle | null;
|
||||
probeSession: CaptureSession | null;
|
||||
workDir: string;
|
||||
debug: boolean;
|
||||
log: ProducerLogger;
|
||||
/** Suffix appended to safeCleanup labels. Pinned to the existing diagnostic payloads. */
|
||||
label: "cancel" | "error";
|
||||
}): Promise<void> {
|
||||
const { fileServer, probeSession, workDir, debug, log, label } = input;
|
||||
if (fileServer) {
|
||||
const fs = fileServer;
|
||||
await safeCleanup(
|
||||
`close file server (${label})`,
|
||||
() => {
|
||||
fs.close();
|
||||
},
|
||||
log,
|
||||
);
|
||||
}
|
||||
if (probeSession) {
|
||||
const session = probeSession;
|
||||
await safeCleanup(`close probe session (${label})`, () => closeCaptureSession(session), log);
|
||||
}
|
||||
if (!debug) {
|
||||
// `force: true` swallows ENOENT, so no need to existsSync first.
|
||||
await safeCleanup(
|
||||
`remove workDir (${label})`,
|
||||
() => rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
|
||||
log,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `RenderJob.errorDetails` shape downstream consumers (SSE,
|
||||
* sync `/render` response, queue introspection) read on failure.
|
||||
*/
|
||||
export function buildRenderErrorDetails(input: {
|
||||
error: unknown;
|
||||
pipelineStartMs: number;
|
||||
lastBrowserConsole: string[];
|
||||
perfStages: Record<string, number>;
|
||||
hdrDiagnostics: HdrDiagnostics;
|
||||
observability?: RenderObservabilitySummary;
|
||||
subTimelineWait?: SubTimelineWaitOutcome;
|
||||
}): NonNullable<RenderJob["errorDetails"]> {
|
||||
const errorMessage = normalizeErrorMessage(input.error);
|
||||
const errorStack = input.error instanceof Error ? input.error.stack : undefined;
|
||||
return {
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
elapsedMs: Date.now() - input.pipelineStartMs,
|
||||
freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
|
||||
browserConsoleTail:
|
||||
input.lastBrowserConsole.length > 0 ? input.lastBrowserConsole.slice(-30) : undefined,
|
||||
perfStages: Object.keys(input.perfStages).length > 0 ? { ...input.perfStages } : undefined,
|
||||
hdrDiagnostics:
|
||||
input.hdrDiagnostics.videoExtractionFailures > 0 ||
|
||||
input.hdrDiagnostics.imageDecodeFailures > 0
|
||||
? { ...input.hdrDiagnostics }
|
||||
: undefined,
|
||||
observability: input.observability,
|
||||
subTimelineWait: input.subTimelineWait,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tests for `resolveEffectiveHdrMode` — pins the four-signal fold
|
||||
* (caller hdrMode × probed video color × probed image color × output
|
||||
* format) so the format-gate ordering can't silently regress under a
|
||||
* future cleanup.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ExtractionResult, VideoColorSpace } from "@hyperframes/engine";
|
||||
import { resolveEffectiveHdrMode } from "./hdrMode.js";
|
||||
|
||||
function makeLog() {
|
||||
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
||||
}
|
||||
|
||||
function extractionWith(colorSpaces: (VideoColorSpace | null)[]): ExtractionResult | undefined {
|
||||
if (colorSpaces.length === 0) return undefined;
|
||||
return {
|
||||
extracted: colorSpaces.map((colorSpace) => ({
|
||||
videoId: "v",
|
||||
outputDir: "/tmp/v",
|
||||
framePaths: new Map<number, string>(),
|
||||
metadata: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
durationSeconds: 1,
|
||||
colorSpace,
|
||||
},
|
||||
})),
|
||||
} as unknown as ExtractionResult;
|
||||
}
|
||||
|
||||
const HDR_PQ: VideoColorSpace = {
|
||||
colorTransfer: "smpte2084",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
};
|
||||
|
||||
describe("resolveEffectiveHdrMode", () => {
|
||||
it("returns undefined when force-sdr is set, regardless of HDR sources", () => {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "force-sdr",
|
||||
outputFormat: "mp4",
|
||||
extractionResult: extractionWith([HDR_PQ]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
expect(log.info).toHaveBeenCalledWith("[Render] SDR forced by --sdr flag");
|
||||
});
|
||||
|
||||
it("auto-detects HDR from video sources when format=mp4", () => {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "auto",
|
||||
outputFormat: "mp4",
|
||||
extractionResult: extractionWith([HDR_PQ]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
expect(result).toEqual({ transfer: "pq" });
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-detected from source(s)"));
|
||||
});
|
||||
|
||||
it("auto-detects SDR with no HDR sources", () => {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "auto",
|
||||
outputFormat: "mp4",
|
||||
extractionResult: extractionWith([]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
expect(log.info).toHaveBeenCalledWith("[Render] No HDR sources detected — rendering SDR");
|
||||
});
|
||||
|
||||
it("force-hdr without sources falls back to HLG and warns", () => {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "force-hdr",
|
||||
outputFormat: "mp4",
|
||||
extractionResult: extractionWith([]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
expect(result).toEqual({ transfer: "hlg" });
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("HDR forced by --hdr flag, but no HDR sources were detected"),
|
||||
);
|
||||
});
|
||||
|
||||
it("force-hdr uses the dominant probed transfer when sources are HDR", () => {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "force-hdr",
|
||||
outputFormat: "mp4",
|
||||
extractionResult: extractionWith([HDR_PQ]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
expect(result).toEqual({ transfer: "pq" });
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downgrades to SDR with a warning when output format can't carry HDR", () => {
|
||||
for (const fmt of ["webm", "mov", "png-sequence"] as const) {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "auto",
|
||||
outputFormat: fmt,
|
||||
extractionResult: extractionWith([HDR_PQ]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`format is "${fmt}" — falling back to SDR`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("force-hdr without sources + non-mp4 format: still downgrades, two warns fire", () => {
|
||||
const log = makeLog();
|
||||
const result = resolveEffectiveHdrMode({
|
||||
hdrMode: "force-hdr",
|
||||
outputFormat: "webm",
|
||||
extractionResult: extractionWith([]),
|
||||
imageColorSpaces: [],
|
||||
log,
|
||||
});
|
||||
// Effective is undefined: format gate wins.
|
||||
expect(result).toBeUndefined();
|
||||
// Both warns fired in order: format-downgrade, then the
|
||||
// forced-without-sources note (preserved verbatim from the in-process
|
||||
// renderer's diagnostic ordering).
|
||||
expect(log.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining("HDR was forced without detected HDR sources"),
|
||||
);
|
||||
expect(log.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining("HDR forced by --hdr flag, but no HDR sources were detected"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* HDR / SDR mode resolution at the sequencer boundary.
|
||||
*
|
||||
* Folds three signals — `RenderConfig.hdrMode`, the probed video color
|
||||
* spaces, and the probed image color spaces — into a single
|
||||
* `effectiveHdr` decision, then emits the matching diagnostic log lines.
|
||||
* The format gate (HDR + alpha is unsupported, so non-mp4 output forces
|
||||
* SDR) lives here too so the sequencer doesn't need to know which
|
||||
* formats can carry an HDR signal.
|
||||
*/
|
||||
|
||||
import { analyzeCompositionHdr } from "@hyperframes/engine";
|
||||
import type { ExtractionResult, HdrTransfer, VideoColorSpace } from "@hyperframes/engine";
|
||||
import type { ProducerLogger } from "../../logger.js";
|
||||
import type { RenderConfig } from "../renderOrchestrator.js";
|
||||
|
||||
export function resolveEffectiveHdrMode(input: {
|
||||
hdrMode: RenderConfig["hdrMode"];
|
||||
outputFormat: NonNullable<RenderConfig["format"]>;
|
||||
extractionResult: ExtractionResult | null | undefined;
|
||||
imageColorSpaces: (VideoColorSpace | null)[];
|
||||
log: ProducerLogger;
|
||||
}): { transfer: HdrTransfer } | undefined {
|
||||
const hdrMode = input.hdrMode ?? "auto";
|
||||
const videoColorSpaces = (input.extractionResult?.extracted ?? []).map(
|
||||
(ext) => ext.metadata.colorSpace,
|
||||
);
|
||||
const allColorSpaces = [...videoColorSpaces, ...input.imageColorSpaces];
|
||||
const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null;
|
||||
|
||||
let effectiveHdr: { transfer: HdrTransfer } | undefined;
|
||||
let forcedHdrWithoutSources = false;
|
||||
|
||||
if (hdrMode === "force-sdr") {
|
||||
effectiveHdr = undefined;
|
||||
} else if (hdrMode === "force-hdr") {
|
||||
if (info?.hasHdr && info.dominantTransfer) {
|
||||
effectiveHdr = { transfer: info.dominantTransfer };
|
||||
} else {
|
||||
effectiveHdr = { transfer: "hlg" };
|
||||
forcedHdrWithoutSources = true;
|
||||
}
|
||||
} else if (info?.hasHdr && info.dominantTransfer) {
|
||||
effectiveHdr = { transfer: info.dominantTransfer };
|
||||
}
|
||||
|
||||
if (effectiveHdr && input.outputFormat !== "mp4") {
|
||||
const hdrSourceReason = forcedHdrWithoutSources
|
||||
? "HDR was forced without detected HDR sources"
|
||||
: "HDR source detected";
|
||||
input.log.warn(
|
||||
`[Render] ${hdrSourceReason}, but format is "${input.outputFormat}" — falling back to SDR. ` +
|
||||
`HDR + alpha is not supported. Use --format mp4 for HDR10 output.`,
|
||||
);
|
||||
effectiveHdr = undefined;
|
||||
}
|
||||
|
||||
if (forcedHdrWithoutSources) {
|
||||
input.log.warn(
|
||||
"[Render] HDR forced by --hdr flag, but no HDR sources were detected — defaulting to HLG. SDR-only compositions may look perceptually wrong on HDR displays.",
|
||||
);
|
||||
}
|
||||
if (effectiveHdr) {
|
||||
let reason: string;
|
||||
if (hdrMode === "force-hdr") {
|
||||
reason = forcedHdrWithoutSources
|
||||
? "forced by --hdr flag (no HDR sources detected — defaulting to HLG)"
|
||||
: "forced by --hdr flag";
|
||||
} else {
|
||||
reason = "auto-detected from source(s)";
|
||||
}
|
||||
input.log.info(
|
||||
`[Render] HDR ${reason} — output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`,
|
||||
);
|
||||
} else if (hdrMode === "force-sdr") {
|
||||
input.log.info("[Render] SDR forced by --sdr flag");
|
||||
} else {
|
||||
input.log.info("[Render] No HDR sources detected — rendering SDR");
|
||||
}
|
||||
|
||||
return effectiveHdr;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* HDR-pipeline perf instrumentation.
|
||||
*
|
||||
* `HdrPerfCollector` accumulates per-phase wall-clock ms for the
|
||||
* layered HDR / shader-transition composite path; `finalizeHdrPerf`
|
||||
* converts the running totals into the `HdrPerfSummary` shape that
|
||||
* lands in `RenderPerfSummary.hdrPerf`.
|
||||
*/
|
||||
|
||||
export type HdrPerfTimingKey =
|
||||
| "frameSeekMs"
|
||||
| "frameInjectMs"
|
||||
| "stackingQueryMs"
|
||||
| "canvasClearMs"
|
||||
| "normalCompositeMs"
|
||||
| "transitionCompositeMs"
|
||||
| "encoderWriteMs"
|
||||
| "hdrVideoReadDecodeMs"
|
||||
| "hdrVideoTransferMs"
|
||||
| "hdrVideoBlitMs"
|
||||
| "hdrImageTransferMs"
|
||||
| "hdrImageBlitMs"
|
||||
| "domLayerSeekMs"
|
||||
| "domLayerInjectMs"
|
||||
| "domMaskApplyMs"
|
||||
| "domScreenshotMs"
|
||||
| "domMaskRemoveMs"
|
||||
| "domPngDecodeMs"
|
||||
| "domBlitMs";
|
||||
|
||||
export interface HdrPerfCollector {
|
||||
frames: number;
|
||||
normalFrames: number;
|
||||
transitionFrames: number;
|
||||
domLayerCaptures: number;
|
||||
hdrVideoLayerBlits: number;
|
||||
hdrImageLayerBlits: number;
|
||||
timings: Record<HdrPerfTimingKey, number>;
|
||||
}
|
||||
|
||||
export interface HdrPerfSummary {
|
||||
frames: number;
|
||||
normalFrames: number;
|
||||
transitionFrames: number;
|
||||
domLayerCaptures: number;
|
||||
hdrVideoLayerBlits: number;
|
||||
hdrImageLayerBlits: number;
|
||||
timings: Record<string, number>;
|
||||
avgMs: Record<string, number>;
|
||||
}
|
||||
|
||||
export function createHdrPerfCollector(): HdrPerfCollector {
|
||||
return {
|
||||
frames: 0,
|
||||
normalFrames: 0,
|
||||
transitionFrames: 0,
|
||||
domLayerCaptures: 0,
|
||||
hdrVideoLayerBlits: 0,
|
||||
hdrImageLayerBlits: 0,
|
||||
timings: {
|
||||
frameSeekMs: 0,
|
||||
frameInjectMs: 0,
|
||||
stackingQueryMs: 0,
|
||||
canvasClearMs: 0,
|
||||
normalCompositeMs: 0,
|
||||
transitionCompositeMs: 0,
|
||||
encoderWriteMs: 0,
|
||||
hdrVideoReadDecodeMs: 0,
|
||||
hdrVideoTransferMs: 0,
|
||||
hdrVideoBlitMs: 0,
|
||||
hdrImageTransferMs: 0,
|
||||
hdrImageBlitMs: 0,
|
||||
domLayerSeekMs: 0,
|
||||
domLayerInjectMs: 0,
|
||||
domMaskApplyMs: 0,
|
||||
domScreenshotMs: 0,
|
||||
domMaskRemoveMs: 0,
|
||||
domPngDecodeMs: 0,
|
||||
domBlitMs: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function addHdrTiming(
|
||||
perf: HdrPerfCollector | undefined,
|
||||
key: HdrPerfTimingKey,
|
||||
startMs: number,
|
||||
) {
|
||||
if (!perf) return;
|
||||
perf.timings[key] += Date.now() - startMs;
|
||||
}
|
||||
|
||||
export function timeHdrPhase<T>(
|
||||
perf: HdrPerfCollector | undefined,
|
||||
key: HdrPerfTimingKey,
|
||||
fn: () => T,
|
||||
): T {
|
||||
if (!perf) return fn();
|
||||
const start = Date.now();
|
||||
const result = fn();
|
||||
addHdrTiming(perf, key, start);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function timeHdrPhaseAsync<T>(
|
||||
perf: HdrPerfCollector | undefined,
|
||||
key: HdrPerfTimingKey,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!perf) return fn();
|
||||
const start = Date.now();
|
||||
const result = await fn();
|
||||
addHdrTiming(perf, key, start);
|
||||
return result;
|
||||
}
|
||||
|
||||
function averageTiming(totalMs: number, count: number): number {
|
||||
return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
export function finalizeHdrPerf(perf: HdrPerfCollector): HdrPerfSummary {
|
||||
const avgMs: Record<string, number> = {};
|
||||
const perFrameKeys: HdrPerfTimingKey[] = [
|
||||
"frameSeekMs",
|
||||
"frameInjectMs",
|
||||
"stackingQueryMs",
|
||||
"canvasClearMs",
|
||||
"encoderWriteMs",
|
||||
];
|
||||
for (const key of perFrameKeys) avgMs[key] = averageTiming(perf.timings[key], perf.frames);
|
||||
avgMs.normalCompositeMs = averageTiming(perf.timings.normalCompositeMs, perf.normalFrames);
|
||||
avgMs.transitionCompositeMs = averageTiming(
|
||||
perf.timings.transitionCompositeMs,
|
||||
perf.transitionFrames,
|
||||
);
|
||||
|
||||
const perDomLayerKeys: HdrPerfTimingKey[] = [
|
||||
"domLayerSeekMs",
|
||||
"domLayerInjectMs",
|
||||
"domMaskApplyMs",
|
||||
"domScreenshotMs",
|
||||
"domMaskRemoveMs",
|
||||
"domPngDecodeMs",
|
||||
"domBlitMs",
|
||||
];
|
||||
for (const key of perDomLayerKeys) {
|
||||
avgMs[key] = averageTiming(perf.timings[key], perf.domLayerCaptures);
|
||||
}
|
||||
|
||||
const perHdrVideoKeys: HdrPerfTimingKey[] = [
|
||||
"hdrVideoReadDecodeMs",
|
||||
"hdrVideoTransferMs",
|
||||
"hdrVideoBlitMs",
|
||||
];
|
||||
for (const key of perHdrVideoKeys) {
|
||||
avgMs[key] = averageTiming(perf.timings[key], perf.hdrVideoLayerBlits);
|
||||
}
|
||||
|
||||
const perHdrImageKeys: HdrPerfTimingKey[] = ["hdrImageTransferMs", "hdrImageBlitMs"];
|
||||
for (const key of perHdrImageKeys) {
|
||||
avgMs[key] = averageTiming(perf.timings[key], perf.hdrImageLayerBlits);
|
||||
}
|
||||
|
||||
return {
|
||||
frames: perf.frames,
|
||||
normalFrames: perf.normalFrames,
|
||||
transitionFrames: perf.transitionFrames,
|
||||
domLayerCaptures: perf.domLayerCaptures,
|
||||
hdrVideoLayerBlits: perf.hdrVideoLayerBlits,
|
||||
hdrImageLayerBlits: perf.hdrImageLayerBlits,
|
||||
timings: { ...perf.timings },
|
||||
avgMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CaptureStageError, getCaptureStageBrowserConsole } from "./captureStageError.js";
|
||||
import {
|
||||
computeCompositionObservabilityHash,
|
||||
observeRenderStage,
|
||||
RenderObservabilityRecorder,
|
||||
sanitizeObservationMessage,
|
||||
summarizeBrowserDiagnostics,
|
||||
} from "./observability.js";
|
||||
|
||||
function makeLog() {
|
||||
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
||||
}
|
||||
|
||||
function makeExtractionObservability() {
|
||||
return {
|
||||
videoCount: 6,
|
||||
extractedVideoCount: 6,
|
||||
totalFramesExtracted: 167_400,
|
||||
maxFramesPerVideo: 27_900,
|
||||
avgFramesPerExtractedVideo: 27_900,
|
||||
vfrProbeMs: 120,
|
||||
vfrPreflightMs: 2400,
|
||||
vfrPreflightCount: 6,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 6,
|
||||
};
|
||||
}
|
||||
|
||||
describe("summarizeBrowserDiagnostics", () => {
|
||||
it("classifies navigation, page, request, HTTP, and console diagnostics", () => {
|
||||
const summary = summarizeBrowserDiagnostics([
|
||||
"[FrameCapture:NAV] page.goto start mode=screenshot timeoutMs=60000 url=http://127.0.0.1:4173/index.html",
|
||||
"[FrameCapture:ERROR] page.goto failed mode=screenshot timeoutMs=60000 elapsedMs=60001 url=http://127.0.0.1:4173/index.html error=Navigation timeout",
|
||||
"[Browser:PAGEERROR] ReferenceError: gsap is not defined",
|
||||
"[Browser:REQUESTFAILED] GET http://127.0.0.1:4173/video.mp4 resource=media error=net::ERR_FAILED",
|
||||
"[Browser:HTTP404] GET http://127.0.0.1:4173/missing.png resource=image",
|
||||
"[warn] parser-blocking script",
|
||||
"[error] failed to load resource",
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
total: 7,
|
||||
errors: 0,
|
||||
pageErrors: 1,
|
||||
requestFailed: 1,
|
||||
httpErrors: 1,
|
||||
navigationStarts: 1,
|
||||
navigationFailures: 1,
|
||||
consoleErrors: 1,
|
||||
consoleWarnings: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps generic error counts exclusive from specific diagnostic buckets", () => {
|
||||
const summary = summarizeBrowserDiagnostics([
|
||||
"[Browser:PAGEERROR] ReferenceError: gsap is not defined",
|
||||
"[FrameCapture:ERROR] page.goto failed",
|
||||
"Unhandled ERROR outside browser diagnostic buckets",
|
||||
]);
|
||||
|
||||
expect(summary.errors).toBe(1);
|
||||
expect(summary.pageErrors).toBe(1);
|
||||
expect(summary.navigationFailures).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeObservationMessage", () => {
|
||||
it("redacts local paths and URL query strings before telemetry/log forwarding", () => {
|
||||
expect(
|
||||
sanitizeObservationMessage(
|
||||
"ENOENT: open '/home/ubuntu/project/media/video.mp4' https://example.com/video.mp4?X-Amz-Signature=secret",
|
||||
),
|
||||
).toBe("ENOENT: open '[path]' https://example.com/video.mp4?…");
|
||||
});
|
||||
|
||||
it("computes a stable short hash for compiled composition correlation", () => {
|
||||
expect(computeCompositionObservabilityHash("<html><body>Hello</body></html>")).toBe(
|
||||
"03ee66f1452916b4",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CaptureStageError", () => {
|
||||
it("preserves the original message and browser console diagnostics", () => {
|
||||
const cause = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const browserConsole = ["[FrameCapture:ERROR] page.goto failed"];
|
||||
const error = new CaptureStageError({ cause, browserConsole });
|
||||
|
||||
browserConsole.push("mutated after wrap");
|
||||
|
||||
expect(error.message).toBe("Navigation timeout of 60000 ms exceeded");
|
||||
expect(getCaptureStageBrowserConsole(error)).toEqual(["[FrameCapture:ERROR] page.goto failed"]);
|
||||
expect(getCaptureStageBrowserConsole(cause)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RenderObservabilityRecorder", () => {
|
||||
it("emits capped heartbeats while a stage is still running and stops after settlement", async () => {
|
||||
vi.useFakeTimers();
|
||||
const log = makeLog();
|
||||
const recorder = new RenderObservabilityRecorder({
|
||||
pipelineStartMs: Date.now(),
|
||||
log,
|
||||
renderJobId: "render-hang",
|
||||
});
|
||||
let resolveStage: (() => void) | undefined;
|
||||
let framesCompleted = 0;
|
||||
const stage = observeRenderStage(
|
||||
recorder,
|
||||
"capture_streaming",
|
||||
{
|
||||
workerCount: 1,
|
||||
captureMode: "screenshot",
|
||||
captureOperation: "captureScreenshot",
|
||||
totalFrames: 900,
|
||||
get framesCompleted() {
|
||||
return framesCompleted;
|
||||
},
|
||||
},
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveStage = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
framesCompleted = 12;
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
"[Render:trace]",
|
||||
expect.objectContaining({
|
||||
phase: "capture_streaming",
|
||||
status: "checkpoint",
|
||||
message: "stage still running",
|
||||
heartbeatIndex: 1,
|
||||
stageElapsedMs: 30_000,
|
||||
captureMode: "screenshot",
|
||||
captureOperation: "captureScreenshot",
|
||||
framesCompleted: 12,
|
||||
totalFrames: 900,
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
const heartbeatCalls = log.info.mock.calls.filter(
|
||||
([message, meta]) => message === "[Render:trace]" && meta?.message === "stage still running",
|
||||
);
|
||||
expect(heartbeatCalls).toHaveLength(3);
|
||||
|
||||
resolveStage?.();
|
||||
await stage;
|
||||
const endCall = log.info.mock.calls.find(
|
||||
// fallow-ignore-next-line complexity
|
||||
([message, meta]) =>
|
||||
message === "[Render:trace]" &&
|
||||
meta?.phase === "capture_streaming" &&
|
||||
meta?.status === "end",
|
||||
);
|
||||
expect(endCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
framesCompleted: 12,
|
||||
totalFrames: 900,
|
||||
captureMode: "screenshot",
|
||||
captureOperation: "captureScreenshot",
|
||||
workerCount: 1,
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
expect(
|
||||
log.info.mock.calls.filter(
|
||||
([message, meta]) =>
|
||||
message === "[Render:trace]" && meta?.message === "stage still running",
|
||||
),
|
||||
).toHaveLength(3);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps emitting heartbeats every 120s once the initial ramp is exhausted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const log = makeLog();
|
||||
const recorder = new RenderObservabilityRecorder({
|
||||
pipelineStartMs: Date.now(),
|
||||
log,
|
||||
renderJobId: "render-long-hang",
|
||||
});
|
||||
let resolveStage: (() => void) | undefined;
|
||||
const stage = observeRenderStage(
|
||||
recorder,
|
||||
"capture_streaming",
|
||||
{ captureMode: "screenshot" },
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveStage = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
// Ramp: 30s, 60s, 120s → 3 heartbeats. Then steady 120s cadence: 240s, 360s.
|
||||
await vi.advanceTimersByTimeAsync(360_000);
|
||||
const heartbeatCalls = log.info.mock.calls.filter(
|
||||
([message, meta]) => message === "[Render:trace]" && meta?.message === "stage still running",
|
||||
);
|
||||
expect(heartbeatCalls.map(([, meta]) => meta?.heartbeatIndex)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(heartbeatCalls.map(([, meta]) => meta?.stageElapsedMs)).toEqual([
|
||||
30_000, 60_000, 120_000, 240_000, 360_000,
|
||||
]);
|
||||
|
||||
resolveStage?.();
|
||||
await stage;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clears pending heartbeats when a stage rejects", async () => {
|
||||
vi.useFakeTimers();
|
||||
const log = makeLog();
|
||||
const recorder = new RenderObservabilityRecorder({
|
||||
pipelineStartMs: Date.now(),
|
||||
log,
|
||||
renderJobId: "render-error",
|
||||
});
|
||||
|
||||
await expect(
|
||||
observeRenderStage(
|
||||
recorder,
|
||||
"capture_disk",
|
||||
{
|
||||
workerCount: 2,
|
||||
totalFrames: 42,
|
||||
framesCompleted: 7,
|
||||
captureMode: "screenshot",
|
||||
captureOperation: "captureScreenshot",
|
||||
},
|
||||
async () => {
|
||||
throw new Error("capture failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("capture failed");
|
||||
const errorCall = log.info.mock.calls.find(
|
||||
// fallow-ignore-next-line complexity
|
||||
([message, meta]) =>
|
||||
message === "[Render:trace]" && meta?.phase === "capture_disk" && meta?.status === "error",
|
||||
);
|
||||
expect(errorCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
framesCompleted: 7,
|
||||
totalFrames: 42,
|
||||
captureMode: "screenshot",
|
||||
captureOperation: "captureScreenshot",
|
||||
workerCount: 2,
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
|
||||
expect(
|
||||
log.info.mock.calls.some(
|
||||
([message, meta]) =>
|
||||
message === "[Render:trace]" && meta?.message === "stage still running",
|
||||
),
|
||||
).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
it("records bounded phase events and summarizes browser diagnostics", () => {
|
||||
const log = makeLog();
|
||||
const recorder = new RenderObservabilityRecorder({
|
||||
pipelineStartMs: Date.now() - 100,
|
||||
log,
|
||||
renderJobId: "render-123",
|
||||
});
|
||||
const startedAt = recorder.stageStart("capture_hdr_layered", { workerCount: 1 });
|
||||
|
||||
recorder.stageError(
|
||||
"capture_hdr_layered",
|
||||
startedAt - 5,
|
||||
new Error("Navigation timeout of 60000 ms exceeded for /Users/alice/project/video.mp4"),
|
||||
{ projectPath: "/Users/alice/project", workerCount: 1 },
|
||||
);
|
||||
|
||||
const summary = recorder.summary({
|
||||
lastBrowserConsole: [
|
||||
"[FrameCapture:NAV] page.goto start",
|
||||
"[FrameCapture:ERROR] page.goto failed",
|
||||
"[FrameCapture:INIT] complete initDurationMs=1234 tweenCount=42",
|
||||
],
|
||||
capture: {
|
||||
forceScreenshot: true,
|
||||
captureMode: "screenshot",
|
||||
workerCount: 1,
|
||||
useLayeredComposite: true,
|
||||
},
|
||||
extraction: makeExtractionObservability(),
|
||||
compositionHash: "abc123",
|
||||
});
|
||||
|
||||
expect(summary.eventCount).toBe(2);
|
||||
expect(summary.renderJobId).toBe("render-123");
|
||||
expect(summary.compositionHash).toBe("abc123");
|
||||
expect(summary.failedPhase).toBe("capture_hdr_layered");
|
||||
expect(summary.lastEvent?.status).toBe("error");
|
||||
expect(summary.lastEvent?.message).toBe("Navigation timeout of 60000 ms exceeded for [path]");
|
||||
expect(summary.lastEvent?.data).toEqual({ workerCount: 1 });
|
||||
expect(summary.browserDiagnostics.navigationStarts).toBe(1);
|
||||
expect(summary.browserDiagnostics.navigationFailures).toBe(1);
|
||||
expect(summary.capture.captureMode).toBe("screenshot");
|
||||
expect(summary.extraction?.totalFramesExtracted).toBe(167_400);
|
||||
expect(summary.extraction?.vfrPreflightCount).toBe(6);
|
||||
expect(summary.init).toEqual({ initDurationMs: 1234, tweenCount: 42 });
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
"[Render:trace]",
|
||||
expect.objectContaining({
|
||||
renderJobId: "render-123",
|
||||
phase: "capture_hdr_layered",
|
||||
status: "error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { redactTelemetryString } from "@hyperframes/core";
|
||||
import type { ProducerLogger } from "../../logger.js";
|
||||
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
||||
|
||||
export type RenderObservationStatus = "start" | "end" | "error" | "checkpoint";
|
||||
export type RenderObservationValue = string | number | boolean | null;
|
||||
export type RenderObservationData = Record<string, RenderObservationValue>;
|
||||
|
||||
export interface RenderObservationEvent {
|
||||
renderJobId?: string;
|
||||
phase: string;
|
||||
status: RenderObservationStatus;
|
||||
elapsedMs: number;
|
||||
durationMs?: number;
|
||||
message?: string;
|
||||
data?: RenderObservationData;
|
||||
}
|
||||
|
||||
export interface BrowserDiagnosticSummary {
|
||||
total: number;
|
||||
/** Generic browser error lines after page/request/navigation/console-specific diagnostics are classified. */
|
||||
errors: number;
|
||||
pageErrors: number;
|
||||
requestFailed: number;
|
||||
httpErrors: number;
|
||||
navigationStarts: number;
|
||||
navigationFailures: number;
|
||||
consoleErrors: number;
|
||||
consoleWarnings: number;
|
||||
}
|
||||
|
||||
export interface RenderCaptureObservability {
|
||||
forceScreenshot: boolean;
|
||||
captureMode: "screenshot" | "beginframe";
|
||||
captureBeyondViewport?: boolean;
|
||||
workerCount?: number;
|
||||
useStreamingEncode?: boolean;
|
||||
useLayeredComposite?: boolean;
|
||||
usePageSideCompositing?: boolean;
|
||||
hasHdrContent?: boolean;
|
||||
browserGpuMode?: string;
|
||||
/**
|
||||
* drawElement per-render SELF-VERIFICATION tripped (blank/PSNR) → whole
|
||||
* render re-ran via screenshot. NARROWED semantics since the pinned-fallback
|
||||
* retry was widened (review): OOM- and generic-capture-error-triggered
|
||||
* fallbacks report FALSE here, with `deFallbackReason` ∈ {oom,
|
||||
* capture_error}. The "any fallback fired" signal is `deFallbackReason`
|
||||
* being set, NOT this flag — dashboards keyed on `de_self_verify_fallback =
|
||||
* true` as any-fallback must migrate to `de_fallback_reason IS NOT NULL`.
|
||||
*/
|
||||
deSelfVerifyFallback?: boolean;
|
||||
/**
|
||||
* Why the capture-stage retry (self-verify OR the pinned-worker-count
|
||||
* fallback) fired: "blank"/"psnr" for a real self-verify trip,
|
||||
* "oom"/"capture_error" for the widened generic-failure retry. Set
|
||||
* whenever a fallback is attempted, independent of whether that retry
|
||||
* itself later succeeds — so a render that fails AFTER a fallback attempt
|
||||
* (perfSummary never built) is still distinguishable in failure-path
|
||||
* telemetry from one that never attempted any fallback.
|
||||
*/
|
||||
deFallbackReason?: string;
|
||||
/** Auto-parallel inversion outcome: "inverted" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
|
||||
deWorkerInversion?: "inverted" | "reverted";
|
||||
/** Worker count the resolver would have used absent the inversion; undefined if it never fired. */
|
||||
dePreInversionWorkers?: number;
|
||||
/** DE parallel-router outcome: "routed" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
|
||||
deParallelRouter?: "routed" | "reverted";
|
||||
/** Worker count the resolver would have used absent the router; undefined if it never fired. */
|
||||
dePreRouterWorkers?: number;
|
||||
/**
|
||||
* Non-DE parallel-streaming router outcome (HF_CAPTURE_PARALLEL_STREAM):
|
||||
* "screenshot" | "beginframe" — the render passed every gate AND the kill
|
||||
* switch was on, so it was routed through the interleaved streaming encoder
|
||||
* (the value is the capture mode that streamed); "eligible_off" — the render
|
||||
* passed every gate EXCEPT the kill switch (passive cohort-sizing signal for
|
||||
* the default-off soak: how many renders WOULD route if enabled). Absent =
|
||||
* ineligible regardless of the switch.
|
||||
*/
|
||||
captureParallelStream?: "screenshot" | "beginframe" | "eligible_off";
|
||||
protocolTimeoutMs?: number;
|
||||
pageNavigationTimeoutMs?: number;
|
||||
playerReadyTimeoutMs?: number;
|
||||
/**
|
||||
* Render-reliability counters (see PostHog dashboard 1783183). Emitted so the
|
||||
* capture-hardening in #1842 is measurable from a metric, not just logs:
|
||||
* how often the bounded transient-tab-death retry fired on a render that
|
||||
* ultimately succeeded, and whether the failure was classified as an
|
||||
* out-of-memory exhaustion (`Set maximum size exceeded` and friends).
|
||||
*/
|
||||
transientRetries?: number;
|
||||
memoryExhaustionDetected?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderExtractionObservability {
|
||||
videoCount: number;
|
||||
extractedVideoCount: number;
|
||||
totalFramesExtracted: number;
|
||||
maxFramesPerVideo: number;
|
||||
avgFramesPerExtractedVideo?: number;
|
||||
vfrProbeMs?: number;
|
||||
vfrPreflightMs?: number;
|
||||
vfrPreflightCount?: number;
|
||||
cacheHits?: number;
|
||||
cacheMisses?: number;
|
||||
}
|
||||
|
||||
export interface RenderInitObservability {
|
||||
initDurationMs?: number;
|
||||
tweenCount?: number;
|
||||
}
|
||||
|
||||
export interface RenderObservabilitySummary {
|
||||
renderJobId?: string;
|
||||
compositionHash?: string;
|
||||
events: RenderObservationEvent[];
|
||||
eventCount: number;
|
||||
lastEvent?: RenderObservationEvent;
|
||||
failedPhase?: string;
|
||||
browserDiagnostics: BrowserDiagnosticSummary;
|
||||
capture: RenderCaptureObservability;
|
||||
extraction?: RenderExtractionObservability;
|
||||
init?: RenderInitObservability;
|
||||
}
|
||||
|
||||
const MAX_EVENTS = 160;
|
||||
/** Allow-list of non-sensitive string fields accepted into structured render trace data. */
|
||||
const ALLOWED_STRING_DATA_KEYS = new Set([
|
||||
"browserGpuMode",
|
||||
"captureMode",
|
||||
"captureOperation",
|
||||
"compositionHash",
|
||||
"effectiveHdr",
|
||||
"format",
|
||||
"quality",
|
||||
"renderJobId",
|
||||
"requestedHdrMode",
|
||||
"requestedWorkers",
|
||||
]);
|
||||
const RESERVED_LOG_KEYS = new Set([
|
||||
"data",
|
||||
"durationMs",
|
||||
"elapsedMs",
|
||||
"message",
|
||||
"phase",
|
||||
"renderJobId",
|
||||
"status",
|
||||
]);
|
||||
|
||||
export function sanitizeObservationMessage(value: string): string {
|
||||
return redactTelemetryString(value);
|
||||
}
|
||||
|
||||
export function computeCompositionObservabilityHash(compiledHtml: string): string {
|
||||
return createHash("sha256").update(compiledHtml, "utf8").digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function sanitizeObservationData(
|
||||
data: RenderObservationData | undefined,
|
||||
): RenderObservationData | undefined {
|
||||
if (!data) return undefined;
|
||||
const sanitized: RenderObservationData = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (RESERVED_LOG_KEYS.has(key)) continue;
|
||||
if (typeof value === "string") {
|
||||
if (!ALLOWED_STRING_DATA_KEYS.has(key)) continue;
|
||||
sanitized[key] = sanitizeObservationMessage(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function isHttpErrorDiagnostic(line: string): boolean {
|
||||
return /\[Browser:HTTP\d{3}\]/.test(line);
|
||||
}
|
||||
|
||||
function readUnsignedIntAfter(line: string, prefix: string): number | undefined {
|
||||
const start = line.indexOf(prefix);
|
||||
if (start < 0) return undefined;
|
||||
let value = 0;
|
||||
let digits = 0;
|
||||
for (let i = start + prefix.length; i < line.length; i++) {
|
||||
const code = line.charCodeAt(i);
|
||||
if (code < 48 || code > 57) break;
|
||||
value = value * 10 + code - 48;
|
||||
digits += 1;
|
||||
if (value > Number.MAX_SAFE_INTEGER) return undefined;
|
||||
}
|
||||
return digits > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function summarizeInitObservability(lines: string[]): RenderInitObservability | undefined {
|
||||
let initDurationMs: number | undefined;
|
||||
let tweenCount: number | undefined;
|
||||
for (const line of lines) {
|
||||
if (!line.includes("[FrameCapture:INIT]")) continue;
|
||||
const duration = readUnsignedIntAfter(line, "initDurationMs=");
|
||||
const tweens = readUnsignedIntAfter(line, "tweenCount=");
|
||||
// Multiple worker/session INIT records can appear; keep the worst observed startup cost.
|
||||
if (duration !== undefined) {
|
||||
initDurationMs = initDurationMs === undefined ? duration : Math.max(initDurationMs, duration);
|
||||
}
|
||||
if (tweens !== undefined) {
|
||||
tweenCount = tweenCount === undefined ? tweens : Math.max(tweenCount, tweens);
|
||||
}
|
||||
}
|
||||
if (initDurationMs === undefined && tweenCount === undefined) return undefined;
|
||||
return { initDurationMs, tweenCount };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function summarizeBrowserDiagnostics(lines: string[]): BrowserDiagnosticSummary {
|
||||
let errors = 0;
|
||||
let pageErrors = 0;
|
||||
let requestFailed = 0;
|
||||
let httpErrors = 0;
|
||||
let navigationStarts = 0;
|
||||
let navigationFailures = 0;
|
||||
let consoleErrors = 0;
|
||||
let consoleWarnings = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const isPageError = line.includes("PAGEERROR");
|
||||
const isRequestFailed = line.includes("REQUESTFAILED");
|
||||
const isHttpError = isHttpErrorDiagnostic(line);
|
||||
const isNavigationFailure = line.includes("[FrameCapture:ERROR] page.goto failed");
|
||||
const isConsoleError = line.includes("[error]");
|
||||
|
||||
if (isPageError) pageErrors++;
|
||||
if (isRequestFailed) requestFailed++;
|
||||
if (isHttpError) httpErrors++;
|
||||
if (line.includes("[FrameCapture:NAV] page.goto start")) navigationStarts++;
|
||||
if (isNavigationFailure) navigationFailures++;
|
||||
if (isConsoleError) consoleErrors++;
|
||||
if (line.includes("[warn]")) consoleWarnings++;
|
||||
if (
|
||||
line.includes("ERROR") &&
|
||||
!isPageError &&
|
||||
!isRequestFailed &&
|
||||
!isHttpError &&
|
||||
!isNavigationFailure &&
|
||||
!isConsoleError
|
||||
) {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: lines.length,
|
||||
errors,
|
||||
pageErrors,
|
||||
requestFailed,
|
||||
httpErrors,
|
||||
navigationStarts,
|
||||
navigationFailures,
|
||||
consoleErrors,
|
||||
consoleWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
export class RenderObservabilityRecorder {
|
||||
private readonly events: RenderObservationEvent[] = [];
|
||||
private eventCount = 0;
|
||||
private failedPhase: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
pipelineStartMs: number;
|
||||
log: ProducerLogger;
|
||||
renderJobId?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
checkpoint(phase: string, message: string, data?: RenderObservationData): RenderObservationEvent {
|
||||
return this.record({
|
||||
phase,
|
||||
status: "checkpoint",
|
||||
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
||||
message: sanitizeObservationMessage(message),
|
||||
data: sanitizeObservationData(data),
|
||||
});
|
||||
}
|
||||
|
||||
stageStart(phase: string, data?: RenderObservationData): number {
|
||||
this.record({
|
||||
phase,
|
||||
status: "start",
|
||||
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
||||
data: sanitizeObservationData(data),
|
||||
});
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
stageEnd(phase: string, startedAtMs: number, data?: RenderObservationData): void {
|
||||
this.record({
|
||||
phase,
|
||||
status: "end",
|
||||
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
data: sanitizeObservationData(data),
|
||||
});
|
||||
}
|
||||
|
||||
stageError(
|
||||
phase: string,
|
||||
startedAtMs: number,
|
||||
error: unknown,
|
||||
data?: RenderObservationData,
|
||||
): void {
|
||||
this.failedPhase = phase;
|
||||
this.record({
|
||||
phase,
|
||||
status: "error",
|
||||
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
message: sanitizeObservationMessage(normalizeErrorMessage(error)),
|
||||
data: sanitizeObservationData(data),
|
||||
});
|
||||
}
|
||||
|
||||
summary(input: {
|
||||
lastBrowserConsole: string[];
|
||||
capture: RenderCaptureObservability;
|
||||
extraction?: RenderExtractionObservability;
|
||||
compositionHash?: string;
|
||||
}): RenderObservabilitySummary {
|
||||
const lastEvent = this.events[this.events.length - 1];
|
||||
return {
|
||||
renderJobId: this.input.renderJobId,
|
||||
compositionHash: input.compositionHash,
|
||||
events: this.events.slice(),
|
||||
eventCount: this.eventCount,
|
||||
lastEvent,
|
||||
failedPhase: this.failedPhase,
|
||||
browserDiagnostics: summarizeBrowserDiagnostics(input.lastBrowserConsole),
|
||||
capture: { ...input.capture },
|
||||
extraction: input.extraction ? { ...input.extraction } : undefined,
|
||||
init: summarizeInitObservability(input.lastBrowserConsole),
|
||||
};
|
||||
}
|
||||
|
||||
hasFailure(): boolean {
|
||||
return this.failedPhase !== undefined;
|
||||
}
|
||||
|
||||
/** A phase failure that was subsequently recovered (e.g. the drawElement
|
||||
* self-verify fallback re-rendering via screenshot) should not brand the
|
||||
* whole render as failed in the summary. */
|
||||
clearFailure(phase: string): void {
|
||||
if (this.failedPhase === phase) this.failedPhase = undefined;
|
||||
}
|
||||
|
||||
private record(event: RenderObservationEvent): RenderObservationEvent {
|
||||
this.eventCount++;
|
||||
const eventWithJob = { ...event, renderJobId: this.input.renderJobId };
|
||||
this.events.push(eventWithJob);
|
||||
if (this.events.length > MAX_EVENTS) {
|
||||
this.events.shift();
|
||||
}
|
||||
|
||||
this.input.log.info("[Render:trace]", {
|
||||
renderJobId: eventWithJob.renderJobId,
|
||||
phase: eventWithJob.phase,
|
||||
status: eventWithJob.status,
|
||||
elapsedMs: eventWithJob.elapsedMs,
|
||||
durationMs: eventWithJob.durationMs,
|
||||
message: eventWithJob.message,
|
||||
...eventWithJob.data,
|
||||
});
|
||||
|
||||
return eventWithJob;
|
||||
}
|
||||
}
|
||||
|
||||
/** Heartbeat ramp before falling back to a steady repeat cadence. */
|
||||
const HEARTBEAT_RAMP_MS = [30_000, 60_000, 120_000];
|
||||
const HEARTBEAT_REPEAT_MS = 120_000;
|
||||
const HEARTBEAT_RAMP_END_MS =
|
||||
HEARTBEAT_RAMP_MS[HEARTBEAT_RAMP_MS.length - 1] ?? HEARTBEAT_REPEAT_MS;
|
||||
|
||||
/** Target elapsed-ms for the Nth heartbeat (0-indexed): ramp, then steady repeat so long stalls keep emitting breadcrumbs instead of going dark after the ramp. */
|
||||
function heartbeatTargetMs(index: number): number {
|
||||
const rampTarget = HEARTBEAT_RAMP_MS[index];
|
||||
if (rampTarget !== undefined) return rampTarget;
|
||||
const overflow = index - HEARTBEAT_RAMP_MS.length + 1;
|
||||
return HEARTBEAT_RAMP_END_MS + overflow * HEARTBEAT_REPEAT_MS;
|
||||
}
|
||||
|
||||
export async function observeRenderStage<T>(
|
||||
recorder: RenderObservabilityRecorder,
|
||||
phase: string,
|
||||
data: RenderObservationData | undefined,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const startedAt = recorder.stageStart(phase, data);
|
||||
let heartbeatCount = 0;
|
||||
let lastFiredAtMs = 0;
|
||||
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const scheduleNextHeartbeat = () => {
|
||||
const targetMs = heartbeatTargetMs(heartbeatCount);
|
||||
heartbeatTimer = setTimeout(() => {
|
||||
lastFiredAtMs = targetMs;
|
||||
heartbeatCount += 1;
|
||||
recorder.checkpoint(phase, "stage still running", {
|
||||
...data,
|
||||
heartbeatIndex: heartbeatCount,
|
||||
stageElapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
scheduleNextHeartbeat();
|
||||
}, targetMs - lastFiredAtMs);
|
||||
heartbeatTimer.unref?.();
|
||||
};
|
||||
scheduleNextHeartbeat();
|
||||
const clearHeartbeats = () => {
|
||||
clearTimeout(heartbeatTimer);
|
||||
};
|
||||
try {
|
||||
const result = await fn();
|
||||
clearHeartbeats();
|
||||
recorder.stageEnd(phase, startedAt, data);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearHeartbeats();
|
||||
recorder.stageError(phase, startedAt, error, data);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CapturePerfSummary } from "@hyperframes/engine";
|
||||
import { buildRenderPerfSummary } from "./perfSummary.js";
|
||||
import { createRenderJob } from "../renderOrchestrator.js";
|
||||
|
||||
function baseInput(
|
||||
dedupPerfs: CapturePerfSummary[],
|
||||
overrides: Partial<Parameters<typeof buildRenderPerfSummary>[0]> = {},
|
||||
) {
|
||||
return {
|
||||
job: createRenderJob({ fps: { num: 30, den: 1 }, quality: "high" }),
|
||||
workerCount: dedupPerfs.length || 1,
|
||||
enableChunkedEncode: false,
|
||||
chunkedEncodeSize: 0,
|
||||
compositionDurationSeconds: 5,
|
||||
totalFrames: 150,
|
||||
outputWidth: 1920,
|
||||
outputHeight: 1080,
|
||||
videoCount: 0,
|
||||
audioCount: 0,
|
||||
totalElapsedMs: 1000,
|
||||
perfStages: {},
|
||||
videoExtractBreakdown: undefined,
|
||||
tmpPeakBytes: 0,
|
||||
captureAttempts: [],
|
||||
hdrDiagnostics: { videoExtractionFailures: 0, imageDecodeFailures: 0 },
|
||||
peakRssBytes: 0,
|
||||
peakHeapUsedBytes: 0,
|
||||
dedupPerfs,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function perf(p: Partial<CapturePerfSummary>): CapturePerfSummary {
|
||||
return {
|
||||
frames: 0,
|
||||
avgTotalMs: 0,
|
||||
avgSeekMs: 0,
|
||||
avgBeforeCaptureMs: 0,
|
||||
avgScreenshotMs: 0,
|
||||
staticDedupReused: 0,
|
||||
staticDedupEnabled: false,
|
||||
staticDedupArmed: false,
|
||||
staticDedupPredicted: 0,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildRenderPerfSummary static-dedup aggregation", () => {
|
||||
it("is undefined when no capture session ran", () => {
|
||||
expect(buildRenderPerfSummary(baseInput([])).staticDedup).toBeUndefined();
|
||||
});
|
||||
|
||||
it("SUMs reused/predicted and ORs armed across workers", () => {
|
||||
const s = buildRenderPerfSummary(
|
||||
baseInput([
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: true,
|
||||
staticDedupPredicted: 40,
|
||||
staticDedupReused: 30,
|
||||
}),
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: false,
|
||||
staticDedupSkipReason: "ineligible",
|
||||
}),
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: true,
|
||||
staticDedupPredicted: 20,
|
||||
staticDedupReused: 18,
|
||||
}),
|
||||
]),
|
||||
).staticDedup;
|
||||
expect(s).toEqual({
|
||||
enabled: true,
|
||||
armed: true,
|
||||
predictedFrames: 60,
|
||||
reusedFrames: 48,
|
||||
skipReason: undefined, // armed → no skip reason
|
||||
});
|
||||
});
|
||||
|
||||
it("reports skipReason when no worker armed", () => {
|
||||
const s = buildRenderPerfSummary(
|
||||
baseInput([
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: false,
|
||||
staticDedupSkipReason: "capture_mode",
|
||||
}),
|
||||
]),
|
||||
).staticDedup;
|
||||
expect(s?.armed).toBe(false);
|
||||
expect(s?.skipReason).toBe("capture_mode");
|
||||
expect(s?.reusedFrames).toBe(0);
|
||||
});
|
||||
|
||||
it("joins DISTINCT skip reasons across diverging unarmed workers (sorted, deduped)", () => {
|
||||
const s = buildRenderPerfSummary(
|
||||
baseInput([
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: false,
|
||||
staticDedupSkipReason: "ineligible",
|
||||
}),
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: false,
|
||||
staticDedupSkipReason: "capture_mode",
|
||||
}),
|
||||
perf({
|
||||
staticDedupEnabled: true,
|
||||
staticDedupArmed: false,
|
||||
staticDedupSkipReason: "capture_mode",
|
||||
}),
|
||||
]),
|
||||
).staticDedup;
|
||||
expect(s?.armed).toBe(false);
|
||||
expect(s?.skipReason).toBe("capture_mode|ineligible"); // sorted, deduped
|
||||
});
|
||||
|
||||
it("carries enabled=false through (opt-out renders)", () => {
|
||||
const s = buildRenderPerfSummary(baseInput([perf({ staticDedupEnabled: false })])).staticDedup;
|
||||
expect(s).toEqual({
|
||||
enabled: false,
|
||||
armed: false,
|
||||
predictedFrames: 0,
|
||||
reusedFrames: 0,
|
||||
skipReason: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRenderPerfSummary capture average attribution", () => {
|
||||
it("uses frame-capture time for captureAvgMs instead of setup-inclusive captureMs", () => {
|
||||
const summary = buildRenderPerfSummary(
|
||||
baseInput([], {
|
||||
totalFrames: 120,
|
||||
perfStages: {
|
||||
captureMs: 5_100,
|
||||
captureSetupMs: 1_860,
|
||||
captureFrameMs: 3_240,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.captureAvgMs).toBe(27);
|
||||
});
|
||||
|
||||
it("falls back to legacy captureMs when captureFrameMs is absent", () => {
|
||||
const summary = buildRenderPerfSummary(
|
||||
baseInput([], {
|
||||
totalFrames: 120,
|
||||
perfStages: {
|
||||
captureMs: 5_100,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.captureAvgMs).toBe(43);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRenderPerfSummary beginframe no-damage reuse aggregation", () => {
|
||||
it("is undefined when no capture session ran", () => {
|
||||
expect(buildRenderPerfSummary(baseInput([])).beginFrameReuse).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is undefined when no session captured in beginframe mode (both counters zero)", () => {
|
||||
const s = buildRenderPerfSummary(
|
||||
baseInput([perf({ staticDedupEnabled: true, staticDedupReused: 10 })]),
|
||||
).beginFrameReuse;
|
||||
expect(s).toBeUndefined();
|
||||
});
|
||||
|
||||
it("SUMs no-damage and has-damage frames across workers", () => {
|
||||
const s = buildRenderPerfSummary(
|
||||
baseInput([
|
||||
perf({ beginFrameNoDamage: 240, beginFrameHasDamage: 160 }),
|
||||
perf({ beginFrameNoDamage: 245, beginFrameHasDamage: 155 }),
|
||||
perf({ beginFrameNoDamage: 235, beginFrameHasDamage: 165 }),
|
||||
]),
|
||||
).beginFrameReuse;
|
||||
expect(s).toEqual({ noDamageFrames: 720, hasDamageFrames: 480 });
|
||||
});
|
||||
|
||||
it("reports an all-damage beginframe render (noDamageFrames 0, not undefined)", () => {
|
||||
const s = buildRenderPerfSummary(
|
||||
baseInput([perf({ beginFrameNoDamage: 0, beginFrameHasDamage: 400 })]),
|
||||
).beginFrameReuse;
|
||||
expect(s).toEqual({ noDamageFrames: 0, hasDamageFrames: 400 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Build the `RenderPerfSummary` that lands on `job.perfSummary` and
|
||||
* the `perf-summary.json` debug artifact.
|
||||
*/
|
||||
|
||||
import { fpsToNumber } from "@hyperframes/core";
|
||||
import type { CapturePerfSummary, SubTimelineWaitOutcome } from "@hyperframes/engine";
|
||||
import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js";
|
||||
import type {
|
||||
CaptureAttemptSummary,
|
||||
HdrDiagnostics,
|
||||
RenderJob,
|
||||
RenderPerfSummary,
|
||||
} from "../renderOrchestrator.js";
|
||||
import { type HdrPerfCollector, finalizeHdrPerf } from "./hdrPerf.js";
|
||||
import type { RenderObservabilitySummary } from "./observability.js";
|
||||
|
||||
/**
|
||||
* Worst sub-composition timeline wait outcome across sessions: script_failure
|
||||
* > timeout > ready. Shared by the success-path perf summary and the error
|
||||
* path (a fail-fast can still be followed by an unrelated downstream
|
||||
* failure, e.g. in `pollVideosReady` or encode — that render fires
|
||||
* `render_error`, not `render_complete`, and should carry this too).
|
||||
*/
|
||||
export function worstSubTimelineWaitOutcome(
|
||||
perfs: CapturePerfSummary[],
|
||||
): SubTimelineWaitOutcome | undefined {
|
||||
const outcomes = perfs
|
||||
.map((p) => p.subTimelineWaitOutcome)
|
||||
.filter((o): o is SubTimelineWaitOutcome => !!o);
|
||||
if (outcomes.length === 0) return undefined;
|
||||
if (outcomes.includes("script_failure")) return "script_failure";
|
||||
if (outcomes.includes("timeout")) return "timeout";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Append each parallel worker's static-dedup perf into the render-level sink
|
||||
* (skipping workers that reported none). Shared by the disk + streaming parallel
|
||||
* paths so the collection contract lives in one place.
|
||||
*/
|
||||
export function pushWorkerDedupPerfs(
|
||||
results: ReadonlyArray<{ perf?: CapturePerfSummary }>,
|
||||
sink: CapturePerfSummary[],
|
||||
): void {
|
||||
for (const r of results) {
|
||||
if (r.perf) sink.push(r.perf);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse per-session/per-worker static-dedup perf into one render-level
|
||||
* outcome. enabled/armed = OR across workers (they run the same gates on the
|
||||
* same composition); predicted/reused = SUM (each worker dedups its own frame
|
||||
* range); skipReason = the distinct reasons (sorted, `|`-joined) when not armed.
|
||||
*/
|
||||
/**
|
||||
* Collapse per-session capture perf + producer-side decisions into the
|
||||
* render-level drawElement outcome. mode/gateReason |-join distinct values
|
||||
* across workers (bounded cardinality); counters SUM.
|
||||
*/
|
||||
/** Orchestrator-supplied render-level drawElement outcome (one shape, used by
|
||||
* both the aggregate function and buildRenderPerfSummary's input). */
|
||||
export interface DrawElementPerfInput {
|
||||
compileGate?: string;
|
||||
clampReason?: string;
|
||||
workerInversion?: "inverted" | "reverted";
|
||||
/** Auto-resolved worker count before the inversion pinned it to 1 (set only when the inversion fired). */
|
||||
preInversionWorkers?: number;
|
||||
parallelRouter?: "routed" | "reverted";
|
||||
/** Auto-resolved worker count before the router pinned it to 3 (set only when the router fired). */
|
||||
preRouterWorkers?: number;
|
||||
selfVerifyFallback: boolean;
|
||||
fallbackReason?: string;
|
||||
drainStats?: {
|
||||
verifyChecked: number;
|
||||
verifyMinDb?: number;
|
||||
blankSuspects: number;
|
||||
blankDeterministicAccepts: number;
|
||||
blankRecaptures: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Flat field mapping — branches are ?? fallbacks, not logic.
|
||||
// fallow-ignore-next-line complexity
|
||||
function aggregateDrawElement(
|
||||
perfs: CapturePerfSummary[],
|
||||
de: DrawElementPerfInput,
|
||||
): RenderPerfSummary["drawElement"] {
|
||||
if (perfs.length === 0) return undefined;
|
||||
const modes = [...new Set(perfs.map((p) => p.captureMode).filter(Boolean))].sort();
|
||||
const gateReasons = [
|
||||
...new Set(perfs.map((p) => p.deGateReason).filter((r): r is string => !!r)),
|
||||
].sort();
|
||||
const drain = de.drainStats;
|
||||
return {
|
||||
mode: modes.join("|") || "unknown",
|
||||
compileGate: de.compileGate,
|
||||
clampReason: de.clampReason,
|
||||
workerInversion: de.workerInversion ?? "none",
|
||||
preInversionWorkers: de.preInversionWorkers,
|
||||
parallelRouter: de.parallelRouter ?? "none",
|
||||
preRouterWorkers: de.preRouterWorkers,
|
||||
gateReason: gateReasons.length > 0 ? gateReasons.join("|") : undefined,
|
||||
workerEncode: perfs.some((p) => p.deWorkerEncode),
|
||||
verifyArmed: perfs.reduce((sum, p) => sum + (p.deVerifyArmed ?? 0), 0),
|
||||
verifyChecked: drain?.verifyChecked ?? 0,
|
||||
verifyMinDb:
|
||||
drain?.verifyMinDb === undefined
|
||||
? undefined
|
||||
: Math.round(Math.min(drain.verifyMinDb, 999) * 10) / 10,
|
||||
verifyInitMs: perfs.reduce((sum, p) => sum + (p.deVerifyInitMs ?? 0), 0),
|
||||
selfVerifyFallback: de.selfVerifyFallback,
|
||||
fallbackReason: de.fallbackReason,
|
||||
blankSuspects: drain?.blankSuspects ?? 0,
|
||||
blankDeterministicAccepts: drain?.blankDeterministicAccepts ?? 0,
|
||||
blankRecaptures: drain?.blankRecaptures ?? 0,
|
||||
boundaryFrames: perfs.reduce((sum, p) => sum + (p.deBoundaryFrames ?? 0), 0),
|
||||
ncprFallbacks: perfs.reduce((sum, p) => sum + (p.deNcprFallbacks ?? 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateDedup(perfs: CapturePerfSummary[]): RenderPerfSummary["staticDedup"] {
|
||||
if (perfs.length === 0) return undefined;
|
||||
const armed = perfs.some((p) => p.staticDedupArmed);
|
||||
// When unarmed, report every DISTINCT skip reason across workers (sorted, joined)
|
||||
// rather than just the first — workers can diverge (e.g. one `ineligible`, one
|
||||
// `capture_mode`), and dropping the rest hides why dedup didn't engage. Cardinality
|
||||
// stays bounded (a handful of codes, small combinations).
|
||||
const skipReasons = armed
|
||||
? []
|
||||
: [
|
||||
...new Set(perfs.map((p) => p.staticDedupSkipReason).filter((r): r is string => !!r)),
|
||||
].sort();
|
||||
return {
|
||||
enabled: perfs.some((p) => p.staticDedupEnabled),
|
||||
armed,
|
||||
predictedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupPredicted ?? 0), 0),
|
||||
reusedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupReused ?? 0), 0),
|
||||
skipReason: skipReasons.length > 0 ? skipReasons.join("|") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse per-session/per-worker BeginFrame damage counters into one
|
||||
* render-level reuse outcome (SUM across workers — each worker ticks its own
|
||||
* frame range). Both zero ⟺ no beginframe session ran (screenshot/drawElement
|
||||
* sessions never increment these) → undefined, mirroring staticDedup's
|
||||
* "undefined when it never engaged" contract. Also inherits `dedupPerfs`'
|
||||
* retry semantics: a partial-capture retry resets the sink, so the sums cover
|
||||
* only the final attempt's recaptured ranges (may be < totalFrames).
|
||||
*/
|
||||
function aggregateBeginFrameReuse(
|
||||
perfs: CapturePerfSummary[],
|
||||
): RenderPerfSummary["beginFrameReuse"] {
|
||||
const noDamageFrames = perfs.reduce((sum, p) => sum + (p.beginFrameNoDamage ?? 0), 0);
|
||||
const hasDamageFrames = perfs.reduce((sum, p) => sum + (p.beginFrameHasDamage ?? 0), 0);
|
||||
if (noDamageFrames + hasDamageFrames === 0) return undefined;
|
||||
return { noDamageFrames, hasDamageFrames };
|
||||
}
|
||||
|
||||
export function buildRenderPerfSummary(input: {
|
||||
job: RenderJob;
|
||||
workerCount: number;
|
||||
enableChunkedEncode: boolean;
|
||||
chunkedEncodeSize: number;
|
||||
compositionDurationSeconds: number;
|
||||
totalFrames: number;
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
videoCount: number;
|
||||
audioCount: number;
|
||||
totalElapsedMs: number;
|
||||
perfStages: Record<string, number>;
|
||||
videoExtractBreakdown: RenderPerfSummary["videoExtractBreakdown"];
|
||||
tmpPeakBytes: number;
|
||||
captureCalibration?: {
|
||||
estimate: CaptureCostEstimate;
|
||||
samples: CaptureCalibrationSample[];
|
||||
};
|
||||
captureAttempts: CaptureAttemptSummary[];
|
||||
hdrDiagnostics: HdrDiagnostics;
|
||||
hdrPerf?: HdrPerfCollector;
|
||||
observability?: RenderObservabilitySummary;
|
||||
peakRssBytes: number;
|
||||
peakHeapUsedBytes: number;
|
||||
/** Per-session/per-worker static-dedup perf; aggregated into `staticDedup`. */
|
||||
dedupPerfs: CapturePerfSummary[];
|
||||
drawElement?: DrawElementPerfInput;
|
||||
}): RenderPerfSummary {
|
||||
return {
|
||||
renderId: input.job.id,
|
||||
totalElapsedMs: input.totalElapsedMs,
|
||||
// RenderPerfSummary surfaces fps as a decimal because it lands in JSON
|
||||
// payloads (CLI telemetry, regression-harness reports) where a single
|
||||
// number is friendlier than `{num,den}`. Callers needing the rational
|
||||
// back can read `job.config.fps`.
|
||||
fps: fpsToNumber(input.job.config.fps),
|
||||
quality: input.job.config.quality,
|
||||
workers: input.workerCount,
|
||||
chunkedEncode: input.enableChunkedEncode,
|
||||
chunkSizeFrames: input.enableChunkedEncode ? input.chunkedEncodeSize : null,
|
||||
compositionDurationSeconds: input.compositionDurationSeconds,
|
||||
totalFrames: input.totalFrames,
|
||||
resolution: { width: input.outputWidth, height: input.outputHeight },
|
||||
videoCount: input.videoCount,
|
||||
audioCount: input.audioCount,
|
||||
stages: input.perfStages,
|
||||
videoExtractBreakdown: input.videoExtractBreakdown,
|
||||
tmpPeakBytes: input.tmpPeakBytes,
|
||||
captureCalibration: input.captureCalibration
|
||||
? {
|
||||
sampledFrames: input.captureCalibration.samples.map((sample) => sample.frameIndex),
|
||||
p95Ms: input.captureCalibration.estimate.p95Ms,
|
||||
multiplier: input.captureCalibration.estimate.multiplier,
|
||||
reasons: input.captureCalibration.estimate.reasons,
|
||||
}
|
||||
: undefined,
|
||||
captureAttempts: input.captureAttempts.length > 0 ? input.captureAttempts : undefined,
|
||||
hdrDiagnostics:
|
||||
input.hdrDiagnostics.videoExtractionFailures > 0 ||
|
||||
input.hdrDiagnostics.imageDecodeFailures > 0
|
||||
? { ...input.hdrDiagnostics }
|
||||
: undefined,
|
||||
hdrPerf: input.hdrPerf ? finalizeHdrPerf(input.hdrPerf) : undefined,
|
||||
observability: input.observability,
|
||||
captureAvgMs:
|
||||
input.totalFrames > 0
|
||||
? Math.round(
|
||||
(input.perfStages.captureFrameMs ?? input.perfStages.captureMs ?? 0) /
|
||||
input.totalFrames,
|
||||
)
|
||||
: undefined,
|
||||
subTimelineWait: worstSubTimelineWaitOutcome(input.dedupPerfs),
|
||||
captureP50Ms: (() => {
|
||||
// Per-frame median from the engine's samples; when parallel workers
|
||||
// report separately, take the busiest session's median.
|
||||
const withSamples = input.dedupPerfs.filter((p) => (p.p50TotalMs ?? 0) > 0);
|
||||
if (withSamples.length === 0) return undefined;
|
||||
return withSamples.reduce((a, b) => (b.frames > a.frames ? b : a)).p50TotalMs;
|
||||
})(),
|
||||
peakRssMb: Math.round(input.peakRssBytes / (1024 * 1024)),
|
||||
peakHeapUsedMb: Math.round(input.peakHeapUsedBytes / (1024 * 1024)),
|
||||
staticDedup: aggregateDedup(input.dedupPerfs),
|
||||
beginFrameReuse: aggregateBeginFrameReuse(input.dedupPerfs),
|
||||
drawElement: aggregateDrawElement(
|
||||
input.dedupPerfs,
|
||||
input.drawElement ?? { selfVerifyFallback: false },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Tests for plan-time validators. Each validator pins both branches:
|
||||
*
|
||||
* - PASS — the config is acceptable; no throw.
|
||||
* - FAIL — the config trips a banned-in-distributed-mode rule; throws
|
||||
* PlanValidationError with the expected typed `code`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
BROWSER_GPU_NOT_SOFTWARE,
|
||||
DISTRIBUTED_DURATION_OUT_OF_RANGE,
|
||||
MAX_DISTRIBUTED_DURATION_SECONDS,
|
||||
PlanValidationError,
|
||||
SYSTEM_FONT_USED,
|
||||
parseFontFamilyValue,
|
||||
validateDistributedDuration,
|
||||
validateNoGpuEncode,
|
||||
validateNoSystemFonts,
|
||||
} from "./planValidation.js";
|
||||
|
||||
describe("PlanValidationError", () => {
|
||||
it("preserves the typed `code` field", () => {
|
||||
const err = new PlanValidationError("EXAMPLE_CODE", "msg");
|
||||
expect(err.code).toBe("EXAMPLE_CODE");
|
||||
expect(err.message).toBe("msg");
|
||||
expect(err.name).toBe("PlanValidationError");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateNoGpuEncode", () => {
|
||||
it("accepts a software-only config (no fields set)", () => {
|
||||
expect(() => validateNoGpuEncode({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts useGpu=false + browserGpuMode='software'", () => {
|
||||
expect(() => validateNoGpuEncode({ useGpu: false, browserGpuMode: "software" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts useGpu=undefined (in-process default) + browserGpuMode='software'", () => {
|
||||
expect(() => validateNoGpuEncode({ browserGpuMode: "software" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws BROWSER_GPU_NOT_SOFTWARE when useGpu === true", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoGpuEncode({ useGpu: true });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
||||
expect((caught as PlanValidationError).code).toBe("BROWSER_GPU_NOT_SOFTWARE");
|
||||
expect((caught as Error).message).toContain("GPU encode is banned");
|
||||
expect((caught as Error).message).toContain("useGpu === true");
|
||||
});
|
||||
|
||||
it("throws BROWSER_GPU_NOT_SOFTWARE when browserGpuMode === 'auto'", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoGpuEncode({ browserGpuMode: "auto" });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
||||
expect((caught as Error).message).toContain("Hardware browser GPU is banned");
|
||||
expect((caught as Error).message).toContain(`"auto"`);
|
||||
});
|
||||
|
||||
it("throws BROWSER_GPU_NOT_SOFTWARE for any non-'software' browserGpuMode value", () => {
|
||||
for (const mode of ["hardware", "discrete", "any", "swiftshader-fallback"]) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoGpuEncode({ browserGpuMode: mode });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks useGpu BEFORE browserGpuMode so the useGpu message wins when both trip", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoGpuEncode({ useGpu: true, browserGpuMode: "auto" });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as Error).message).toContain("GPU encode is banned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDistributedDuration", () => {
|
||||
it("accepts a finite duration within the distributed ceiling", () => {
|
||||
expect(() =>
|
||||
validateDistributedDuration({
|
||||
duration: MAX_DISTRIBUTED_DURATION_SECONDS,
|
||||
totalFrames: MAX_DISTRIBUTED_DURATION_SECONDS * 30,
|
||||
fps: 30,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for the engine's infinite-timeline sentinel", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateDistributedDuration({
|
||||
duration: 10_000_000_000,
|
||||
totalFrames: 300_000_000_000,
|
||||
fps: 30,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
|
||||
expect((caught as Error).message).toContain("300000000000");
|
||||
expect((caught as Error).message).toContain("GSAP repeat:-1");
|
||||
});
|
||||
|
||||
it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for non-finite or zero values", () => {
|
||||
for (const input of [
|
||||
{ duration: Number.POSITIVE_INFINITY, totalFrames: 1, fps: 30 },
|
||||
{ duration: 0, totalFrames: 1, fps: 30 },
|
||||
{ duration: 1, totalFrames: 0, fps: 30 },
|
||||
{ duration: 1, totalFrames: 1, fps: Number.NaN },
|
||||
]) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateDistributedDuration(input);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFontFamilyValue", () => {
|
||||
it("splits a comma-separated list and strips whitespace + quotes", () => {
|
||||
expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([
|
||||
"Inter",
|
||||
"-apple-system",
|
||||
"sans-serif",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips single quotes too", () => {
|
||||
expect(parseFontFamilyValue(`'My Custom Font', serif`)).toEqual(["My Custom Font", "serif"]);
|
||||
});
|
||||
|
||||
it("ignores empty entries (trailing commas)", () => {
|
||||
expect(parseFontFamilyValue(`Inter,,sans-serif`)).toEqual(["Inter", "sans-serif"]);
|
||||
});
|
||||
|
||||
it("handles a single value with no commas", () => {
|
||||
expect(parseFontFamilyValue(`"My Font"`)).toEqual(["My Font"]);
|
||||
});
|
||||
|
||||
it("handles an all-whitespace value as empty", () => {
|
||||
expect(parseFontFamilyValue(` `)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateNoSystemFonts", () => {
|
||||
const CLEAN_HTML = `<!doctype html>
|
||||
<html><head><style>
|
||||
body { font-family: "Inter", -apple-system, sans-serif; margin: 0; }
|
||||
h1 { font-family: "Montserrat", "Helvetica Neue", sans-serif; }
|
||||
</style></head>
|
||||
<body><h1 data-font-family="Outfit, sans-serif">Hello</h1></body>
|
||||
</html>`;
|
||||
|
||||
it("passes a composition with deterministic web fonts as primary", () => {
|
||||
expect(() => validateNoSystemFonts(CLEAN_HTML)).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes when font-family is absent entirely (plain text composition)", () => {
|
||||
expect(() =>
|
||||
validateNoSystemFonts(`<!doctype html><html><body><p>no fonts here</p></body></html>`),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws SYSTEM_FONT_USED when primary family is `-apple-system`", () => {
|
||||
const offending = `<style>body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as PlanValidationError).code).toBe("SYSTEM_FONT_USED");
|
||||
expect((caught as Error).message).toContain(`"-apple-system"`);
|
||||
expect((caught as Error).message).toContain("font-family");
|
||||
});
|
||||
|
||||
it("throws SYSTEM_FONT_USED when primary family is `system-ui`", () => {
|
||||
const offending = `<div style="font-family: system-ui, sans-serif">text</div>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain(`"system-ui"`);
|
||||
});
|
||||
|
||||
it("throws SYSTEM_FONT_USED when primary family is `sans-serif` (CSS generic alone)", () => {
|
||||
const offending = `<style>.x { font-family: sans-serif; }</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain(`"sans-serif"`);
|
||||
});
|
||||
|
||||
it("treats data-font-family= as a valid surface for the same check", () => {
|
||||
const offending = `<h1 data-font-family="ui-monospace, monospace">hi</h1>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain("data-font-family");
|
||||
});
|
||||
|
||||
it("is case-insensitive (`SYSTEM-UI` is the same as `system-ui`)", () => {
|
||||
const offending = `<style>p { font-family: SYSTEM-UI, sans-serif; }</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
});
|
||||
|
||||
it("accepts generic families when used only as fallbacks", () => {
|
||||
// The whole point: `font-family: "Inter", -apple-system, sans-serif` is
|
||||
// the canonical fallback chain. We want this to pass.
|
||||
const ok = `<style>body { font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; }</style>`;
|
||||
expect(() => validateNoSystemFonts(ok)).not.toThrow();
|
||||
});
|
||||
|
||||
it("resolves simple CSS var() primary aliases before rejecting system fonts", () => {
|
||||
const offending = `<style>
|
||||
:root { --ui-font: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
body { font-family: var(--ui-font), sans-serif; }
|
||||
</style>`;
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateNoSystemFonts(offending);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PlanValidationError);
|
||||
expect((caught as PlanValidationError).code).toBe(SYSTEM_FONT_USED);
|
||||
expect((caught as Error).message).toContain(`"-apple-system"`);
|
||||
});
|
||||
|
||||
it("accepts CSS var() primary aliases that resolve to deterministic fonts", () => {
|
||||
const ok = `<style>
|
||||
:root { --ui-font: "Inter", -apple-system, sans-serif; }
|
||||
body { font-family: var(--ui-font), sans-serif; }
|
||||
</style>`;
|
||||
expect(() => validateNoSystemFonts(ok)).not.toThrow();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user