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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+339
View File
@@ -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);
});
+10
View File
@@ -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";
+107
View File
@@ -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;
}
+120
View File
@@ -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";
+319
View File
@@ -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);
});
});
});
+95
View File
@@ -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");
+21
View File
@@ -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}`);
+429
View File
@@ -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;
});
+32
View File
@@ -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,
}),
);
+87
View File
@@ -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");
});
});
+816
View File
@@ -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();
});
});
@@ -0,0 +1,174 @@
/**
* Plan-time validators for the distributed render pipeline. Each validator
* is invoked before freezing the plan, so banned configurations fail fast
* with a typed non-retryable error instead of being baked into a planDir
* and only surfacing on the chunk worker.
*/
import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
import {
collectFontFamilyCustomProperties,
GENERIC_FAMILIES,
iterateFontFamilyDeclarations,
resolveFontFamilyDeclarationFamilies,
} from "../deterministicFonts.js";
/**
* Re-export the BROWSER_GPU_NOT_SOFTWARE code so distributed adapters and
* Step Functions / Temporal retry policies can match it without a
* cross-package import.
*/
export { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
/**
* Re-export the shared font-family parser. The plan-time validator and the
* @font-face injector consume the same surface, so the parser lives next to
* the data.
*/
export { parseFontFamilyValue } from "../deterministicFonts.js";
/**
* Typed plan-validation error. Workflow adapters key retry policies off the
* `code` field to mark errors as non-retryable.
*/
export class PlanValidationError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "PlanValidationError";
this.code = code;
}
}
/**
* Subset of the merged plan / engine / render config that the GPU validator
* inspects. Both `useGpu` (RenderConfig) and `browserGpuMode` (EngineConfig)
* are optional so callers can pass any of the surrounding config shapes
* without an adapter layer.
*
* - `useGpu === true` → encoder GPU acceleration (NVENC/QSV/VAAPI). Banned
* because GPU encoders produce non-byte-identical output across machines.
* - `browserGpuMode !== "software"` → headless Chrome's WebGL is allowed
* to use hardware GL. Banned because hardware GL is bitwise unstable
* across drivers. Pairs with the runtime `assertSwiftShader` check that
* catches workers whose environment ignores Chrome's `--use-gl=swiftshader`.
*/
export interface ValidateNoGpuEncodeInput {
useGpu?: boolean;
browserGpuMode?: string;
}
/**
* Typed code for {@link validateNoSystemFonts}. Distributed chunk workers
* render in a Linux container without host-OS fonts; compositions declaring
* `-apple-system` / `system-ui` as a primary family would render differently
* on the worker, breaking byte-identical retries.
*/
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
/**
* Typed code for {@link validateDistributedDuration}. A duration this large
* almost always means an unbounded runtime timeline escaped into plan(),
* e.g. GSAP `repeat: -1` reporting its internal sentinel duration. Letting
* that reach chunk planning creates billions of frames and turns an authoring
* error into worker churn.
*/
export const DISTRIBUTED_DURATION_OUT_OF_RANGE = "DISTRIBUTED_DURATION_OUT_OF_RANGE";
/** Distributed renders are operationally bounded to one day of output. */
export const MAX_DISTRIBUTED_DURATION_SECONDS = 24 * 60 * 60;
/**
* Reject any config that would let GPU encode or hardware-GL slip into a
* distributed render. Throws {@link PlanValidationError} with
* `code === BROWSER_GPU_NOT_SOFTWARE` when either gate trips. The message
* names the offending field so the caller can surface a clean error.
*/
export function validateNoGpuEncode(config: ValidateNoGpuEncodeInput): void {
if (config.useGpu === true) {
throw new PlanValidationError(
BROWSER_GPU_NOT_SOFTWARE,
"[planValidation] GPU encode is banned in distributed mode: " +
"config.useGpu === true. " +
"Distributed retries must be byte-identical, but NVENC/QSV/VAAPI " +
"produce different output across machines. Set useGpu=false (the " +
"default) — software libx264/libx265 is the only supported encoder " +
"in distributed mode.",
);
}
if (config.browserGpuMode !== undefined && config.browserGpuMode !== "software") {
throw new PlanValidationError(
BROWSER_GPU_NOT_SOFTWARE,
`[planValidation] Hardware browser GPU is banned in distributed mode: ` +
`config.browserGpuMode === ${JSON.stringify(config.browserGpuMode)}. ` +
`Hardware GL is bitwise unstable across drivers. Set browserGpuMode="software" ` +
`so Chrome launches with --use-gl=swiftshader.`,
);
}
}
/**
* Reject a compiled HTML document whose top-priority font-family resolves to
* a host-OS / generic family. Throws {@link PlanValidationError} with
* `code === SYSTEM_FONT_USED` and the offending family in the message.
*
* Inspects the FIRST entry of each font-family declaration: that's the
* family the browser tries to use. Subsequent entries are CSS fallbacks,
* and a generic fallback is fine and conventional — so
* `font-family: "Inter", -apple-system, sans-serif` passes and
* `font-family: -apple-system, BlinkMacSystemFont, "Segoe UI"` fails.
*
* Reads font-family surfaces via `iterateFontFamilyDeclarations` so the
* @font-face injector and this validator scan the same regions.
*/
export function validateNoSystemFonts(compiledHtml: string): void {
const customProperties = collectFontFamilyCustomProperties(compiledHtml);
for (const { surface, declaration } of iterateFontFamilyDeclarations(compiledHtml)) {
const families = resolveFontFamilyDeclarationFamilies(declaration, customProperties);
if (families.length === 0) continue;
const primaryRaw = families[0]!;
// Unresolved var() primaries are left to the browser; resolved custom
// properties are checked above so common `--font: system-ui` aliases fail.
if (!GENERIC_FAMILIES.has(primaryRaw.toLowerCase())) continue;
throw new PlanValidationError(
SYSTEM_FONT_USED,
`[planValidation] Composition declares a host-OS / generic primary ${surface}: ` +
`${JSON.stringify(primaryRaw)} (full declaration: ${JSON.stringify(declaration.trim())}). ` +
`Distributed chunk workers render in a Linux container and cannot produce byte-identical ` +
`output for fonts that resolve to host system installations. Use a deterministic web font ` +
`(e.g. Inter, Montserrat, or another @fontsource family) as the primary family; generic ` +
`names like "sans-serif" / "-apple-system" / "system-ui" are only allowed as fallbacks.`,
);
}
}
export function validateDistributedDuration(input: {
duration: number;
totalFrames: number;
fps: number;
}): void {
const { duration, totalFrames, fps } = input;
const maxFrames = Math.ceil(MAX_DISTRIBUTED_DURATION_SECONDS * fps);
if (
Number.isFinite(duration) &&
duration > 0 &&
Number.isFinite(fps) &&
fps > 0 &&
Number.isSafeInteger(totalFrames) &&
totalFrames > 0 &&
totalFrames <= maxFrames
) {
return;
}
throw new PlanValidationError(
DISTRIBUTED_DURATION_OUT_OF_RANGE,
`[planValidation] Distributed render duration is out of range: ` +
`duration=${String(duration)}s totalFrames=${String(totalFrames)} fps=${String(fps)} ` +
`(maxDuration=${String(MAX_DISTRIBUTED_DURATION_SECONDS)}s, maxFrames=${String(maxFrames)}). ` +
`This usually means an unbounded timeline escaped into render planning, such as ` +
`GSAP repeat:-1 / yoyo loops without an explicit finite root duration. Add a finite ` +
`data-duration or replace infinite repeats with a finite repeat count before rendering.`,
);
}
@@ -0,0 +1,159 @@
/**
* Tests for the `snapshotRuntimeEnv` helper that backs the
* `LockedRenderConfig.runtimeEnv` field.
*/
import { describe, expect, it } from "bun:test";
import {
applyRuntimeEnvSnapshot,
RUNTIME_ENV_PREFIXES,
snapshotRuntimeEnv,
} from "./runtimeEnvSnapshot.js";
describe("snapshotRuntimeEnv", () => {
it("captures PRODUCER_RUNTIME_* keys", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5",
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5",
});
});
it("captures PRODUCER_RENDER_* keys", () => {
const env = {
PRODUCER_RENDER_SEEK_STEP: "0.008",
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RENDER_SEEK_STEP: "0.008",
});
});
it("captures both prefix families in a single snapshot", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RENDER_SEEK_STEP: "0.008",
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RENDER_SEEK_STEP: "0.008",
});
});
it("ignores keys that don't match either prefix", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
HOME: "/home/ci",
PATH: "/usr/bin:/bin",
NODE_ENV: "production",
// Off-by-one prefix variants — must NOT be captured.
PRODUCER_RUNTIM_FOO: "x",
PRODUCER_RENDR_BAR: "y",
PRODUCER_DEBUG_SEEK_DIAGNOSTICS: "true",
};
const snapshot = snapshotRuntimeEnv(env);
expect(snapshot).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
});
expect(Object.keys(snapshot)).toHaveLength(1);
});
it("skips keys whose value is undefined", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RUNTIME_OTHER: undefined,
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
});
});
it("returns an empty object when no keys match", () => {
expect(snapshotRuntimeEnv({ HOME: "/home/ci", PATH: "/usr/bin" })).toEqual({});
});
it("returns a NEW object each call (no live reference to process.env)", () => {
const env = { PRODUCER_RUNTIME_X: "v1" };
const first = snapshotRuntimeEnv(env);
env.PRODUCER_RUNTIME_X = "v2";
const second = snapshotRuntimeEnv(env);
expect(first.PRODUCER_RUNTIME_X).toBe("v1");
expect(second.PRODUCER_RUNTIME_X).toBe("v2");
expect(first).not.toBe(second);
});
it("defaults to process.env when no argument is passed", () => {
const key = `PRODUCER_RUNTIME_FREEZEPLAN_TEST_${Date.now()}`;
const sentinel = "freezeplan-test-value";
process.env[key] = sentinel;
try {
const snapshot = snapshotRuntimeEnv();
expect(snapshot[key]).toBe(sentinel);
} finally {
delete process.env[key];
}
});
it("exports the prefix list for chunk-worker materialization", () => {
// Chunk workers must apply the SAME prefix filter when reading the
// snapshot back; asymmetric handling would let stale controller env
// leak into chunk-worker behavior.
expect(RUNTIME_ENV_PREFIXES).toEqual(["PRODUCER_RUNTIME_", "PRODUCER_RENDER_"]);
});
});
describe("applyRuntimeEnvSnapshot", () => {
it("filters by RUNTIME_ENV_PREFIXES — refuses arbitrary keys", () => {
const env: Record<string, string | undefined> = {};
applyRuntimeEnvSnapshot(
{
PRODUCER_RUNTIME_OK: "ok",
// A poisoned planDir could include `PATH` or `LD_PRELOAD`;
// the apply-side filter must drop them.
PATH: "/evil/bin",
ARBITRARY_KEY: "x",
},
env,
);
expect(env.PRODUCER_RUNTIME_OK).toBe("ok");
expect(env.PATH).toBeUndefined();
expect(env.ARBITRARY_KEY).toBeUndefined();
});
it("restore() reverts touched keys and only those keys", () => {
const env: Record<string, string | undefined> = {
PRODUCER_RUNTIME_PREEXISTING: "host-value",
PRODUCER_RENDER_NEW: undefined,
HOST_UNRELATED: "stays",
};
const { restore } = applyRuntimeEnvSnapshot(
{
PRODUCER_RUNTIME_PREEXISTING: "snapshot-value",
PRODUCER_RENDER_NEW: "snapshot-new",
},
env,
);
expect(env.PRODUCER_RUNTIME_PREEXISTING).toBe("snapshot-value");
expect(env.PRODUCER_RENDER_NEW).toBe("snapshot-new");
restore();
// Pre-existing key returns to its host value; previously-undefined
// key is deleted (not left as the snapshot value).
expect(env.PRODUCER_RUNTIME_PREEXISTING).toBe("host-value");
expect(env.PRODUCER_RENDER_NEW).toBeUndefined();
// Unrelated keys are untouched at apply time AND at restore time.
expect(env.HOST_UNRELATED).toBe("stays");
});
it("restore() is safe to call multiple times", () => {
const env: Record<string, string | undefined> = {};
const { restore } = applyRuntimeEnvSnapshot({ PRODUCER_RUNTIME_X: "y" }, env);
restore();
expect(env.PRODUCER_RUNTIME_X).toBeUndefined();
// Second restore is a no-op (no entries to revert).
expect(() => restore()).not.toThrow();
expect(env.PRODUCER_RUNTIME_X).toBeUndefined();
});
});
@@ -0,0 +1,93 @@
/**
* runtimeEnvSnapshot — capture / re-apply the env vars that drive in-page
* render behavior.
*
* `fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
* variables at module-load time and bakes them into the served HTML's
* `RENDER_MODE_SCRIPT`. Distributed chunk workers are separate processes
* that may inherit a different environment, so the plan freezes a snapshot
* of the controller's env. The chunk worker then materializes the snapshot
* back into `process.env` before launching its file server, which keeps the
* served HTML byte-identical to what the controller would have served.
*
* Used by `freezePlan` (capture side) and the chunked render worker
* (re-apply side). Kept here as a standalone utility because it has no
* dependency on the plan-freeze pipeline.
*/
/**
* Env-var name prefixes captured by {@link snapshotRuntimeEnv}. Exported so
* the chunk-worker side can apply the same filter when materializing a
* snapshot — asymmetric handling would leak stale controller env into
* worker behavior.
*/
export const RUNTIME_ENV_PREFIXES: readonly string[] = [
"PRODUCER_RUNTIME_",
"PRODUCER_RENDER_",
] as const;
/**
* Snapshot `process.env` keys that match any of {@link RUNTIME_ENV_PREFIXES}
* into a plain string→string record. Returns a NEW object each call (never a
* live reference to `process.env`) so subsequent mutations of the process
* env do not retroactively change a frozen plan.
*
* Pass an optional `env` for tests that don't want to mutate the real
* process env. The default reads `process.env`.
*/
export function snapshotRuntimeEnv(
env: Record<string, string | undefined> = process.env,
): Record<string, string> {
const snapshot: Record<string, string> = {};
for (const key of Object.keys(env)) {
const matches = RUNTIME_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
if (!matches) continue;
const value = env[key];
// Skip undefined / non-string values. `process.env` only ever returns
// strings, but `Record<string, string | undefined>` lets tests pass an
// env object with explicit `undefined` slots (e.g. after `delete`).
if (typeof value !== "string") continue;
snapshot[key] = value;
}
return snapshot;
}
/**
* Apply a `runtimeEnv` snapshot to `process.env` (or another env-like
* record) before the file server starts. Filters by
* {@link RUNTIME_ENV_PREFIXES} so a planDir with extra keys can't smuggle
* arbitrary env vars onto the worker — the controller's
* {@link snapshotRuntimeEnv} already filtered, but apply-side filtering
* is mandatory defense-in-depth against a hand-crafted or corrupted plan.
*
* Returns a `restore()` function that reverts `env` to its pre-apply
* state for the keys this call touched. Callers that run multiple chunks
* in a single process (Cloud Run Job, Temporal activity worker) MUST
* invoke `restore()` in a `finally` block — without it, chunk N's
* snapshot leaks into chunk N+1's environment.
*
* Existing snapshot keys are overwritten. Keys NOT in the snapshot are
* never touched — the worker's host may set additional runtime knobs
* (`HYPERFRAMES_EXTRACT_CACHE_DIR`, etc.).
*/
export function applyRuntimeEnvSnapshot(
snapshot: Record<string, string>,
env: Record<string, string | undefined> = process.env,
): { restore: () => void } {
const previous: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(snapshot)) {
if (typeof value !== "string") continue;
const matches = RUNTIME_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
if (!matches) continue;
previous[key] = env[key];
env[key] = value;
}
return {
restore: () => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete env[key];
else env[key] = value;
}
},
};
}
@@ -0,0 +1,491 @@
/**
* Shared types and pure helpers used by the staged render pipeline.
*
* Lives in its own module so the stage files in `./stages/` can import the
* helpers they need without reaching back into `renderOrchestrator.ts` —
* the orchestrator imports the stage functions, so a runtime cycle would
* otherwise form (and grow as more stages are extracted).
*
* `renderOrchestrator.ts` re-exports everything declared here for
* backwards compatibility with existing test files and external callers.
*/
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import {
CANVAS_DIMENSIONS,
checkOutputResolutionCompatibility,
type CanvasResolution,
} from "@hyperframes/core";
import type {
AudioElement,
ExtractedFrames,
ImageElement,
VideoElement,
} from "@hyperframes/engine";
import type { CompiledComposition } from "../htmlCompiler.js";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { isPathInside } from "../../utils/paths.js";
import type { ProgressCallback, RenderJob, RenderStatus } from "../renderOrchestrator.js";
export interface CompositionMetadata {
duration: number;
videos: VideoElement[];
audios: AudioElement[];
images: ImageElement[];
width: number;
height: number;
}
/**
* Floating-point tolerance for reconciling browser-discovered media timing
* against statically-parsed metadata. Used when the browser reports a
* slightly different `end` / `mediaStart` / `volume` than the compiled
* HTML and we want to ignore sub-millisecond float noise.
*/
export const BROWSER_MEDIA_EPSILON = 0.0001;
export function writeFileExclusiveSync(path: string, data: NodeJS.ArrayBufferView | string): void {
try {
writeFileSync(path, data, { flag: "wx", mode: 0o600 });
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
return;
}
throw error;
}
}
/**
* Browser-discovered media inside inlined sub-compositions can still report
* scene-local timing from the merged DOM (e.g. start=0, end=85.52) while the
* compiled metadata is already offset into the parent host timeline
* (e.g. start=4.417, end=89.937). Reproject browser end-time into the
* compiled element's time origin before reconciling it back into the render
* metadata.
*/
export function projectBrowserEndToCompositionTimeline(
existingStart: number,
browserStart: number,
browserEnd: number,
): number {
return browserEnd + (existingStart - browserStart);
}
/**
* Translate the user-facing `--resolution` flag into a Chrome
* `deviceScaleFactor`. The composition's intrinsic dimensions stay the
* page-layout viewport; the screenshot lands at output dims via DPR.
*
* The scale must be a positive integer ≥ 1 — fractional DPRs introduce
* visible aliasing and we'd rather fail loudly than produce a blurry
* 4K render. Downsampling (output < composition) is rejected because
* the user is unlikely to have intended it; if the use case appears
* we can plumb a separate flag.
*
* Throws on:
* - HDR + outputResolution (HDR compositor processes raw pixel buffers
* at composition dimensions and would need parallel scaling).
* - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k).
* - Non-integer scale ratio.
* - Downsampling (output dimensions smaller than composition).
*/
export function resolveDeviceScaleFactor(input: {
compositionWidth: number;
compositionHeight: number;
outputResolution: CanvasResolution | undefined;
hdrRequested: boolean;
alphaRequested: boolean;
}): number {
if (!input.outputResolution) return 1;
// Single source of truth for the aspect/alpha/HDR/scale constraints, shared
// with the CLI render pre-flight so both raise the identical, actionable
// message. This is the deep defense-in-depth throw; the pre-flight aborts
// long before this runs on the common (aspect/alpha) mistakes.
const compat = checkOutputResolutionCompatibility({
compositionWidth: input.compositionWidth,
compositionHeight: input.compositionHeight,
outputResolution: input.outputResolution,
alphaRequested: input.alphaRequested,
hdrRequested: input.hdrRequested,
});
if (!compat.ok) throw new Error(compat.message);
const target = CANVAS_DIMENSIONS[input.outputResolution];
// Aspect ratios match → widthRatio === heightRatio. Compute once.
return target.width / input.compositionWidth;
}
/**
* Write compiled HTML and sub-compositions to the work directory.
*
* Exported for integration tests. Not part of the stable public API —
* callers outside this package should use `executeRenderJob` instead.
*/
export function writeCompiledArtifacts(
compiled: CompiledComposition,
workDir: string,
includeSummary: boolean,
): void {
const compileDir = join(workDir, "compiled");
mkdirSync(compileDir, { recursive: true });
writeFileSync(join(compileDir, "index.html"), compiled.html, "utf-8");
for (const [srcPath, html] of compiled.subCompositions) {
const outPath = join(compileDir, srcPath);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, html, "utf-8");
}
// Copy external assets (files outside projectDir) into the compiled directory
// so the file server can serve them. The safe-path check uses
// `isPathInside()` rather than a hardcoded separator — on Windows,
// `compileDir + "/"` never matches because paths use `\\`, which caused
// every external asset to be wrongly rejected as "unsafe" (see GH #321).
for (const [relativePath, absolutePath] of compiled.externalAssets) {
const outPath = resolve(join(compileDir, relativePath));
if (!isPathInside(outPath, compileDir)) {
console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`);
continue;
}
mkdirSync(dirname(outPath), { recursive: true });
copyFileSync(absolutePath, outPath);
}
if (includeSummary) {
const summary = {
width: compiled.width,
height: compiled.height,
staticDuration: compiled.staticDuration,
videos: compiled.videos.map((v) => ({
id: v.id,
src: v.src,
start: v.start,
end: v.end,
mediaStart: v.mediaStart,
})),
audios: compiled.audios.map((a) => ({
id: a.id,
src: a.src,
start: a.start,
end: a.end,
mediaStart: a.mediaStart,
})),
subCompositions: Array.from(compiled.subCompositions.keys()),
renderModeHints: compiled.renderModeHints,
hasShaderTransitions: compiled.hasShaderTransitions,
};
writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
}
}
export interface RenderModeHintResult {
/** Resolved capture-mode boolean after folding in the hint. */
forceScreenshot: boolean;
/** True iff the hint flipped a `false` input to `true` (warn log fired). */
autoSelected: boolean;
}
/**
* Fold the composition's `renderModeHints.recommendScreenshot` signal
* into the caller's already-resolved `forceScreenshot` value. Pure: the
* caller owns the assignment to its own config. When the hint is the
* deciding factor (caller passed `false`, hint says recommend), fires
* the auto-select warn log with the composition's reason codes.
*/
export function applyRenderModeHints(
alreadyForced: boolean,
compiled: CompiledComposition,
log: ProducerLogger = defaultLogger,
): RenderModeHintResult {
if (alreadyForced || !compiled.renderModeHints.recommendScreenshot) {
return { forceScreenshot: alreadyForced, autoSelected: false };
}
log.warn("Auto-selected screenshot capture mode for render compatibility", {
reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code),
reasons: compiled.renderModeHints.reasons.map((reason) => reason.message),
});
return { forceScreenshot: true, autoSelected: true };
}
/**
* Mutate the `RenderJob` view of the pipeline's progress and fire the
* caller's `onProgress` callback. Hoisted here (out of `renderOrchestrator.ts`)
* so the stage modules can call it without forming a runtime cycle.
*
* `completedAt` is stamped on the terminal `"failed"` / `"complete"`
* transitions so callers that poll the job state can tell when the
* pipeline finished.
*/
export function updateJobStatus(
job: RenderJob,
status: RenderStatus,
stage: string,
progress: number,
onProgress?: ProgressCallback,
): void {
job.status = status;
job.currentStage = stage;
job.progress = progress;
if (status === "failed" || status === "complete") job.completedAt = new Date();
if (onProgress) onProgress(job, stage);
}
/**
* Build a `resolver(framePath)` closure that maps an absolute path to
* a frame inside `compiledDir` into a server-relative URL the producer's
* file server will serve. Returns `null` for any path that escapes the
* compiled directory — the resolver is used by the video frame injector
* to rewrite local frame references into HTTP `<video>` srcs.
*/
export function createCompiledFrameSrcResolver(
compiledDir: string,
): (framePath: string) => string | null {
const compiledRoot = resolve(compiledDir);
return (framePath: string): string | null => {
const resolvedFramePath = resolve(framePath);
if (!isPathInside(resolvedFramePath, compiledRoot)) return null;
const relativePath = relative(compiledRoot, resolvedFramePath);
if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) {
return null;
}
return `/${relativePath
.split(/[\\/]+/)
.map((segment) => encodeURIComponent(segment))
.join("/")}`;
};
}
type MaterializedExtractedFrames = Pick<ExtractedFrames, "videoId" | "outputDir" | "framePaths">;
type MaterializePathModule = {
resolve: (...segments: string[]) => string;
join: (...segments: string[]) => string;
dirname: (path: string) => string;
basename: (path: string) => string;
relative: (from: string, to: string) => string;
isAbsolute: (path: string) => boolean;
};
type MaterializeFileSystem = {
existsSync: (path: string) => boolean;
mkdirSync: (path: string, options: { recursive: true }) => unknown;
symlinkSync: (target: string, path: string) => unknown;
cpSync: (src: string, dest: string, options: { recursive: true }) => unknown;
// Optional: only the stale-entry (EEXIST) recovery path calls it, and the
// default fileSystem always supplies it. Test doubles that never trigger
// EEXIST may omit it.
rmSync?: (path: string, options: { recursive: true; force: true }) => unknown;
};
type MaterializeExtractedFramesOptions = {
pathModule?: MaterializePathModule;
fileSystem?: MaterializeFileSystem;
/**
* When `true`, recursively copy frames into `compiledDir` as real files
* instead of creating a single symlink per video. Required for
* distributed plan() output where the planDir must be self-contained
* across machines (symlinks don't survive S3 / GCS round-trips).
* Default `false` preserves the in-process renderer's symlink behavior.
*/
materializeSymlinks?: boolean;
};
const materializePathModule: MaterializePathModule = {
resolve,
join,
dirname,
basename,
relative,
isAbsolute,
};
const materializeFileSystem: MaterializeFileSystem = {
existsSync,
mkdirSync,
symlinkSync,
cpSync,
rmSync,
};
/**
* Periodic peak-RSS / peak-heapUsed sampler. The benchmark harness reads
* the peaks to detect memory regressions (e.g. unbounded image-cache
* growth) that wall-clock metrics miss.
*
* Sampled every 250ms; the interval is `unref`'d so the sampler never
* holds the event loop open on its own. Callers MUST invoke `stop()`
* in a `finally` block — `stop()` takes one final reading before
* clearing the interval so the peak values are accurate up to the
* moment the render returns.
*/
export interface MemorySampler {
/** Take an immediate sample then read the peak RSS in bytes. */
peakRssBytes: () => number;
/** Take an immediate sample then read the peak heap-used in bytes. */
peakHeapUsedBytes: () => number;
/** Stop the interval after one final sample. Idempotent. */
stop: () => void;
}
export function createMemorySampler(intervalMs: number = 250): MemorySampler {
let peakRss = 0;
let peakHeap = 0;
const sample = (): void => {
try {
const m = process.memoryUsage();
if (m.rss > peakRss) peakRss = m.rss;
if (m.heapUsed > peakHeap) peakHeap = m.heapUsed;
} catch {
// Defensive: process.memoryUsage() shouldn't throw, but if it ever
// does we don't want to take down the render for a benchmark accessory.
}
};
sample();
const interval: NodeJS.Timeout = setInterval(sample, intervalMs);
interval.unref();
let stopped = false;
return {
// Resampling at read time means callers see the value at the
// moment of inspection, not the last 250ms tick — important for
// the success-path perf summary which captures peaks just before
// returning.
peakRssBytes: () => {
sample();
return peakRss;
},
peakHeapUsedBytes: () => {
sample();
return peakHeap;
},
stop: () => {
if (stopped) return;
stopped = true;
sample();
clearInterval(interval);
},
};
}
/**
* Symlink (or copy) each extracted-frames directory into a stable path
* under `compiledDir/__hyperframes_video_frames/<videoId>/`, and rewrite
* the per-frame paths so the file server can serve them.
*
* Exported for integration tests; not part of the stable public API —
* external callers should use `executeRenderJob` instead.
*/
// Stage one video's extracted-frame dir into the compiled dir. Default is a
// single symlink (cheap; the in-process renderer); `materializeSymlinks` copies
// instead (distributed plan() needs a self-contained dir). On Windows without
// Developer Mode/Administrator symlink creation is rejected with EPERM/EACCES,
// which failed high/standard renders — degrade to a copy there rather than
// throwing. Non-permission errors still propagate so real failures aren't hidden.
// One-time guard for the symlink→copy fallback notice below.
let warnedSymlinkFallback = false;
// Create the symlink, degrading to a copy on Windows' no-symlink-privilege
// errors (EPERM/EACCES, plus UNKNOWN — some Windows builds surface a symlink
// privilege denial as an UNKNOWN-coded error rather than EPERM). Non-permission
// errors propagate.
function linkOrCopyFrameDir(fileSystem: MaterializeFileSystem, src: string, dest: string): void {
try {
fileSystem.symlinkSync(src, dest);
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== "EPERM" && code !== "EACCES" && code !== "UNKNOWN") throw err;
// Copying is measurably slower than symlinking, so surface the degrade once
// — it explains a render that suddenly got heavier and saves a support
// round-trip diagnosing slow frame staging on Windows.
if (!warnedSymlinkFallback) {
warnedSymlinkFallback = true;
defaultLogger.info(
`[Render] Symlinking extracted frames was rejected (${code}); copying them into the compiled dir instead. Expected on Windows without Developer Mode/Administrator.`,
);
}
fileSystem.cpSync(src, dest, { recursive: true });
}
}
function stageExtractedFrameDir(
fileSystem: MaterializeFileSystem,
src: string,
dest: string,
materializeSymlinks: boolean,
): void {
try {
stageExtractedFrameDirOnce(fileSystem, src, dest, materializeSymlinks);
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code !== "EEXIST") throw err;
// A stale entry already sits at `dest` — typically a DANGLING symlink left
// after the extraction cache was GC'd (its target removed), or a Windows
// machine reusing a dir a prior Linux run populated with symlinks (the eager
// cpSync then collides with the existing link). The caller's existsSync()
// guard follows the link, so the dead link reads as absent and we reach
// here; the entry itself still exists, so re-staging collides with EEXIST.
// Clear the stale entry and re-stage. Applies to BOTH the symlink and
// eager-copy paths so a cross-platform dir reuse self-heals either way.
fileSystem.rmSync?.(dest, { recursive: true, force: true });
stageExtractedFrameDirOnce(fileSystem, src, dest, materializeSymlinks);
}
}
// One staging attempt: eager copy for the distributed self-contained dir,
// otherwise a symlink (degrading to a copy on no-symlink-privilege errors).
function stageExtractedFrameDirOnce(
fileSystem: MaterializeFileSystem,
src: string,
dest: string,
materializeSymlinks: boolean,
): void {
if (materializeSymlinks) {
fileSystem.cpSync(src, dest, { recursive: true });
return;
}
linkOrCopyFrameDir(fileSystem, src, dest);
}
export function materializeExtractedFramesForCompiledDir(
extracted: MaterializedExtractedFrames[],
compiledDir: string,
options: MaterializeExtractedFramesOptions = {},
): void {
const pathModule = options.pathModule ?? materializePathModule;
const fileSystem = options.fileSystem ?? materializeFileSystem;
const resolvedCompiledDir = pathModule.resolve(compiledDir);
const compiledFrameRoot = pathModule.join(resolvedCompiledDir, "__hyperframes_video_frames");
for (const ext of extracted) {
const resolvedOut = pathModule.resolve(ext.outputDir);
if (isPathInside(resolvedOut, resolvedCompiledDir, { pathModule })) continue;
const linkPath = pathModule.join(compiledFrameRoot, ext.videoId);
if (!fileSystem.existsSync(linkPath)) {
fileSystem.mkdirSync(pathModule.dirname(linkPath), { recursive: true });
stageExtractedFrameDir(
fileSystem,
resolvedOut,
linkPath,
options.materializeSymlinks === true,
);
}
const remapped = new Map<number, string>();
for (const [idx, framePath] of ext.framePaths) {
remapped.set(idx, pathModule.join(linkPath, pathModule.basename(framePath)));
}
ext.framePaths = remapped;
ext.outputDir = linkPath;
}
}
@@ -0,0 +1,85 @@
/**
* assembleStage — Stage 6 of `executeRenderJob`. Final mux + faststart.
*
* Skipped entirely for png-sequence (there's no container to mux; the
* frames were copied directly to `outputPath` by `encodeStage`).
*
* When the composition has audio, runs `muxVideoWithAudio(videoOnlyPath,
* audioOutputPath, outputPath)`. When it doesn't, runs
* `applyFaststart(videoOnlyPath, outputPath)` to move the `moov` atom to
* the front so the file plays from a partial download.
*
* Hard constraints preserved verbatim:
* - The "Assembling final video" `updateJobStatus` payload fires at
* 90% at the start of the stage.
* - "Audio muxing failed: <err>" / "Faststart failed: <err>" throw
* verbatim on the respective `success: false` results.
*/
import { applyFaststart, muxVideoWithAudio } from "@hyperframes/engine";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { updateJobStatus } from "../shared.js";
export interface AssembleStageInput {
job: RenderJob;
/** Encoded video produced by `encodeStage` or `captureStreamingStage`. */
videoOnlyPath: string;
/** Mixed audio path (only read when `hasAudio` is true). */
audioOutputPath: string;
/** Final on-disk output. */
outputPath: string;
hasAudio: boolean;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export interface AssembleStageResult {
/** Wall-clock ms for the assemble phase. */
assembleMs: number;
}
export async function runAssembleStage(input: AssembleStageInput): Promise<AssembleStageResult> {
const {
job,
videoOnlyPath,
audioOutputPath,
outputPath,
hasAudio,
abortSignal,
assertNotAborted,
onProgress,
} = input;
const stage6Start = Date.now();
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
if (hasAudio) {
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
audioOutputPath,
outputPath,
abortSignal,
{ audioCodec: "aac" },
job.config.fps,
);
assertNotAborted();
if (!muxResult.success) {
throw new Error(`Audio muxing failed: ${muxResult.error}`);
}
} else {
const faststartResult = await applyFaststart(
videoOnlyPath,
outputPath,
abortSignal,
undefined,
job.config.fps,
);
assertNotAborted();
if (!faststartResult.success) {
throw new Error(`Faststart failed: ${faststartResult.error}`);
}
}
return { assembleMs: Date.now() - stage6Start };
}
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AudioElement } from "@hyperframes/engine";
const { processCompositionAudioMock } = vi.hoisted(() => ({
processCompositionAudioMock: vi.fn(),
}));
vi.mock("@hyperframes/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@hyperframes/engine")>();
return { ...actual, processCompositionAudio: processCompositionAudioMock };
});
import { runAudioStage } from "./audioStage.js";
// Regression: hasAudio flipping to false used to be indistinguishable from
// "no audio was authored" — processCompositionAudio's error (per-element
// failures, or the mix's own failure) was read into hasAudio and then
// discarded, so a real audio-mix failure shipped a silent video-only render
// with no indication anything went wrong. audioError carries that reason.
describe("runAudioStage", () => {
const tempDirs: string[] = [];
const audios: AudioElement[] = [
{ id: "a1", src: "narration.wav", start: 0, end: 5, mediaStart: 0, volume: 1, type: "audio" },
];
afterEach(() => {
processCompositionAudioMock.mockClear();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function makeInput(overrides: Partial<Parameters<typeof runAudioStage>[0]> = {}) {
const workDir = mkdtempSync(join(tmpdir(), "hf-audiostage-"));
tempDirs.push(workDir);
return {
projectDir: workDir,
workDir,
compiledDir: join(workDir, "compiled"),
duration: 5,
audios,
abortSignal: undefined,
assertNotAborted: () => {},
...overrides,
};
}
it("surfaces the mixer's error as audioError when the mix fails", async () => {
processCompositionAudioMock.mockResolvedValue({
success: false,
outputPath: "audio.aac",
durationMs: 1,
tracksProcessed: 0,
error: "Source not found: a1 (narration.wav)",
});
const result = await runAudioStage(makeInput());
expect(result.hasAudio).toBe(false);
expect(result.audioError).toBe("Source not found: a1 (narration.wav)");
});
it("falls back to a generic message when the mixer fails without an error string", async () => {
processCompositionAudioMock.mockResolvedValue({
success: false,
outputPath: "audio.aac",
durationMs: 1,
tracksProcessed: 0,
});
const result = await runAudioStage(makeInput());
expect(result.hasAudio).toBe(false);
expect(result.audioError).toBe("audio mix failed for an unknown reason");
});
it("does not set audioError when the mix succeeds", async () => {
processCompositionAudioMock.mockResolvedValue({
success: true,
outputPath: "audio.aac",
durationMs: 1,
tracksProcessed: 1,
});
const result = await runAudioStage(makeInput());
expect(result.hasAudio).toBe(true);
expect(result.audioError).toBeUndefined();
});
it("does not set audioError when there is no audio to mix", async () => {
const result = await runAudioStage(makeInput({ audios: [] }));
expect(processCompositionAudioMock).not.toHaveBeenCalled();
expect(result.hasAudio).toBe(false);
expect(result.audioError).toBeUndefined();
});
});
@@ -0,0 +1,82 @@
/**
* audioStage — mix the composition's audio tracks into `workDir/audio.aac`.
*
* Trivial wrapper around `processCompositionAudio`. The stage is skipped
* (no ffmpeg invocation) when the composition has no audio elements; the
* timer is still set so the perf summary stays consistent across renders.
*
* Hard constraints preserved verbatim:
* - `audioOutputPath` is always `join(workDir, "audio.aac")`, regardless
* of whether any audio was actually produced.
* - `hasAudio` reflects `audioResult.success` from
* `processCompositionAudio`; it is `false` when there are no audio
* elements (skips the call entirely) and also when the call returns
* `success: false`.
* - `perfStages.audioProcessMs` is set whether or not the call ran.
*/
import { join } from "node:path";
import { processCompositionAudio } from "@hyperframes/engine";
import type { CompositionMetadata } from "../shared.js";
export interface AudioStageInput {
projectDir: string;
workDir: string;
/** `join(workDir, "compiled")`; passed through to the audio mixer for asset resolution. */
compiledDir: string;
/** Composition duration (post-probe). Must be > 0 — probeStage guarantees this. */
duration: number;
/** Read-only view of `composition.audios`. */
audios: CompositionMetadata["audios"];
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
}
export interface AudioStageResult {
/** Always `join(workDir, "audio.aac")`. */
audioOutputPath: string;
/** True iff the audio mix actually produced a file. False when there are no audio elements. */
hasAudio: boolean;
/** Wall-clock ms for the audio mix phase. Zero-elements path is near-zero but always set. */
audioProcessMs: number;
/**
* Set when `audios.length > 0` but the mix failed (`hasAudio` is `false`
* despite audio being expected) — the caller should surface this. `undefined`
* both when there was no audio to mix and when the mix succeeded.
*/
audioError?: string;
}
export async function runAudioStage(input: AudioStageInput): Promise<AudioStageResult> {
const { projectDir, workDir, compiledDir, duration, audios, abortSignal, assertNotAborted } =
input;
const stage3Start = Date.now();
const audioOutputPath = join(workDir, "audio.aac");
let hasAudio = false;
let audioError: string | undefined;
if (audios.length > 0) {
const audioResult = await processCompositionAudio(
audios,
projectDir,
join(workDir, "audio-work"),
audioOutputPath,
duration,
abortSignal,
undefined,
compiledDir,
);
assertNotAborted();
hasAudio = audioResult.success;
// processCompositionAudio's error (per-element failures or the mix's own
// error) used to be discarded here — the caller only saw hasAudio flip to
// false with no explanation, so a real audio failure looked identical to
// "no audio was authored" and shipped a silent video-only render.
if (!hasAudio) audioError = audioResult.error ?? "audio mix failed for an unknown reason";
}
const audioProcessMs = Date.now() - stage3Start;
return { audioOutputPath, hasAudio, audioProcessMs, audioError };
}
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ElementStackingInfo } from "@hyperframes/engine";
import {
applyDomLayerMask,
blitRgba8OverRgb48le,
captureAlphaPng,
decodePng,
removeDomLayerMask,
} from "@hyperframes/engine";
import type { ProducerLogger } from "../../../logger.js";
import type { HdrCompositeContext } from "../../hdrCompositor.js";
import { captureSceneIntoBuffer } from "./captureHdrFrameShared.js";
vi.mock("@hyperframes/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@hyperframes/engine")>();
return {
...actual,
applyDomLayerMask: vi.fn(async () => undefined),
captureAlphaPng: vi.fn(async () => Buffer.from("png")),
decodePng: vi.fn(() => ({ data: Buffer.alloc(4), width: 1, height: 1 })),
removeDomLayerMask: vi.fn(async () => undefined),
blitRgba8OverRgb48le: vi.fn(),
};
});
function makeEl(id: string, overrides?: Partial<ElementStackingInfo>): ElementStackingInfo {
return {
id,
zIndex: 0,
x: 0,
y: 0,
width: 1,
height: 1,
layoutWidth: 1,
layoutHeight: 1,
opacity: 1,
visible: true,
renderFrameVisible: false,
isHdr: false,
transform: "none",
borderRadius: [0, 0, 0, 0],
objectFit: "fill",
objectPosition: "50% 50%",
clipRect: null,
...overrides,
};
}
function makeContext(): HdrCompositeContext {
return {
log: makeLogger(),
domSession: { page: { evaluate: vi.fn() } } as never,
beforeCaptureHook: null,
width: 1,
height: 1,
fps: 30,
compositeTransfer: "srgb",
nativeHdrImageIds: new Set(),
hdrImageBuffers: new Map(),
hdrImageTransferCache: new Map(),
hdrVideoFrameSources: new Map(),
hdrVideoStartTimes: new Map(),
imageTransfers: new Map(),
videoTransfers: new Map(),
debugDumpEnabled: false,
debugDumpDir: null,
};
}
function makeLogger(): ProducerLogger {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as ProducerLogger;
}
describe("captureSceneIntoBuffer", () => {
beforeEach(() => {
vi.mocked(applyDomLayerMask).mockClear();
vi.mocked(captureAlphaPng).mockClear();
vi.mocked(decodePng).mockClear();
vi.mocked(removeDomLayerMask).mockClear();
vi.mocked(blitRgba8OverRgb48le).mockClear();
});
it("does not re-show hidden transition-scene members in the DOM mask", async () => {
const page = { evaluate: vi.fn(async () => undefined) };
await captureSceneIntoBuffer({
session: { page, onBeforeCapture: null } as never,
sceneBuf: Buffer.alloc(6),
sceneIds: new Set(["visible-overlay", "hidden-inner", "hidden-sdr-video"]),
stackingInfo: [
makeEl("visible-overlay"),
makeEl("hidden-inner", { visible: false }),
makeEl("hidden-sdr-video", { visible: false, renderFrameVisible: true }),
makeEl("outside-scene"),
],
time: 0.5,
width: 1,
height: 1,
nativeHdrIds: new Set(),
nativeHdrImageIds: new Set(),
beforeCaptureHook: null,
hdrCompositeCtx: makeContext(),
compositeTransfer: "srgb",
hdrTargetTransfer: undefined,
hdrPerf: undefined,
log: makeLogger(),
frameIdx: 15,
});
expect(applyDomLayerMask).toHaveBeenCalledWith(
page,
["visible-overlay", "hidden-sdr-video"],
["outside-scene"],
);
});
});
@@ -0,0 +1,178 @@
/**
* Tests for the hf#732 hybrid layered-path gating + partitioning predicates.
* These pin the contracts that the dispatcher in `captureHdrStage.ts`
* depends on; both helpers are pure so the tests are cheap to maintain.
*/
import { describe, expect, it } from "vitest";
import {
distributeLayeredHybridFrameRanges,
partitionTransitionFrames,
shouldUseHybridLayeredPath,
} from "./captureHdrFrameShared.js";
describe("shouldUseHybridLayeredPath", () => {
it("returns false for HDR content (HDR raw-frame sources are fd-bound)", () => {
expect(
shouldUseHybridLayeredPath({
hasHdrContent: true,
transitionFramesCount: 30,
totalFrames: 300,
workerCount: 6,
}),
).toBe(false);
});
it("returns false for single-worker budgets (sequential loop is already optimal)", () => {
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 30,
totalFrames: 300,
workerCount: 1,
}),
).toBe(false);
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 30,
totalFrames: 300,
workerCount: 0,
}),
).toBe(false);
});
it("returns false when every frame is inside a transition window", () => {
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 120,
totalFrames: 120,
workerCount: 6,
}),
).toBe(false);
// Transition-frame count strictly greater than total is degenerate and
// should still be rejected (parallel workers can't help).
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 200,
totalFrames: 120,
workerCount: 6,
}),
).toBe(false);
});
it("returns false for empty timelines", () => {
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 0,
totalFrames: 0,
workerCount: 6,
}),
).toBe(false);
});
it("returns true for SDR multi-worker with mixed transition/normal frames", () => {
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 30,
totalFrames: 300,
workerCount: 6,
}),
).toBe(true);
});
it("returns true when there are no transitions at all (pure normal-frame parallelism)", () => {
expect(
shouldUseHybridLayeredPath({
hasHdrContent: false,
transitionFramesCount: 0,
totalFrames: 300,
workerCount: 6,
}),
).toBe(true);
});
});
describe("distributeLayeredHybridFrameRanges", () => {
it("partitions [0, n) into contiguous slices that cover exactly the range", () => {
const ranges = distributeLayeredHybridFrameRanges(300, 6);
expect(ranges.length).toBe(6);
expect(ranges[0]!.start).toBe(0);
let prevEnd = 0;
for (const r of ranges) {
expect(r.start).toBe(prevEnd);
expect(r.end).toBeGreaterThanOrEqual(r.start);
expect(r.end).toBeLessThanOrEqual(300);
prevEnd = r.end;
}
expect(prevEnd).toBe(300);
});
it("does NOT pin all transition frames to one worker (contiguous chunking spreads them)", () => {
// 300 frames, 6 workers → ~50 per worker. Transition window 60-69
// (10 frames) falls in worker 1's slice [50, 100). The transition
// frames are not all assigned to worker 0.
const ranges = distributeLayeredHybridFrameRanges(300, 6);
const worker0 = ranges[0]!;
const transitionInWorker0 = [];
for (let i = 60; i <= 69; i++) {
if (i >= worker0.start && i < worker0.end) transitionInWorker0.push(i);
}
expect(transitionInWorker0.length).toBe(0);
});
it("clamps non-positive workerCount to 1", () => {
const ranges = distributeLayeredHybridFrameRanges(100, 0);
expect(ranges.length).toBe(1);
expect(ranges[0]).toEqual({ start: 0, end: 100 });
const negative = distributeLayeredHybridFrameRanges(100, -5);
expect(negative.length).toBe(1);
});
it("assigns zero-width ranges to workers past the frame budget", () => {
const ranges = distributeLayeredHybridFrameRanges(5, 10);
expect(ranges.length).toBe(10);
expect(ranges.slice(5).every((r) => r.start === r.end)).toBe(true);
});
it("handles zero-frame inputs", () => {
const ranges = distributeLayeredHybridFrameRanges(0, 4);
expect(ranges.length).toBe(4);
expect(ranges.every((r) => r.start === 0 && r.end === 0)).toBe(true);
});
});
describe("partitionTransitionFrames", () => {
it("returns a Set of frame indices that fall inside any transition window", () => {
const ranges = [
{ startFrame: 30, endFrame: 39 },
{ startFrame: 120, endFrame: 125 },
];
const set = partitionTransitionFrames(ranges, 200);
expect(set.size).toBe(10 + 6);
expect(set.has(30)).toBe(true);
expect(set.has(39)).toBe(true);
expect(set.has(40)).toBe(false);
expect(set.has(120)).toBe(true);
expect(set.has(125)).toBe(true);
expect(set.has(126)).toBe(false);
});
it("clamps range endpoints to [0, totalFrames - 1]", () => {
const ranges = [{ startFrame: -5, endFrame: 5 }];
const set = partitionTransitionFrames(ranges, 3);
expect(set.has(-1)).toBe(false);
expect(set.has(0)).toBe(true);
expect(set.has(2)).toBe(true);
expect(set.has(3)).toBe(false);
});
it("returns empty set for non-positive totalFrames", () => {
expect(partitionTransitionFrames([{ startFrame: 0, endFrame: 5 }], 0).size).toBe(0);
expect(partitionTransitionFrames([{ startFrame: 0, endFrame: 5 }], -1).size).toBe(0);
});
});
@@ -0,0 +1,422 @@
/**
* captureHdrFrameShared — shared helpers and types for the HDR
* layered-composite frame loop (sequential + hybrid).
*
* Extracted from `captureHdrStage.ts` so the per-frame logic can live
* under the project's 500-line file ceiling. The hybrid parallel path
* (hf#732) adds a multi-DOM-worker dispatcher on top of the same per-
* frame primitives the sequential loop uses, so the primitives are
* centralized here.
*/
import { rmSync } from "node:fs";
import {
type BeforeCaptureHook,
type CaptureSession,
type ElementStackingInfo,
applyDomLayerMask,
blitRgba8OverRgb48le,
captureAlphaPng,
decodePng,
queryElementStacking,
removeDomLayerMask,
} from "@hyperframes/engine";
import type { ProducerLogger } from "../../../logger.js";
import {
type HdrCompositeContext,
type HdrVideoFrameSource,
type TransitionRange,
blitHdrImageLayer,
blitHdrVideoLayer,
closeHdrVideoFrameSource,
selectDomLayerShowIds,
} from "../../hdrCompositor.js";
import {
type HdrPerfCollector,
type HdrPerfTimingKey,
timeHdrPhase,
timeHdrPhaseAsync,
} from "../hdrPerf.js";
// ─── Hybrid path gating + partitioning ─────────────────────────────────────
/**
* Decide whether the hybrid parallel layered path is safe to use. Returns
* `false` (legacy sequential path) when:
* - HDR content is present (HDR video raw-frame sources are fd-bound to a
* single worker; sharing across workers is out of scope for hf#732).
* - Every frame is inside a transition window (parallel workers buy
* nothing; sequential loop is fine).
* - workerCount <= 1.
*
* Exported so tests can pin the predicate without spinning up a render.
*/
export function shouldUseHybridLayeredPath(args: {
hasHdrContent: boolean;
transitionFramesCount: number;
totalFrames: number;
workerCount: number;
}): boolean {
if (args.hasHdrContent) return false;
if (args.workerCount <= 1) return false;
if (args.totalFrames <= 0) return false;
if (args.transitionFramesCount >= args.totalFrames) return false;
return true;
}
/**
* Distribute [0, totalFrames) across `workerCount` workers as roughly
* equal contiguous slices. Transition-frame boundaries are NOT respected —
* each worker runs both flavors of compositing on its own session.
*/
export function distributeLayeredHybridFrameRanges(
totalFrames: number,
workerCount: number,
): Array<{ start: number; end: number }> {
const safeWorkers = Math.max(1, workerCount);
const safeFrames = Math.max(0, totalFrames);
const framesPerWorker = Math.max(1, Math.ceil(safeFrames / safeWorkers));
const ranges: Array<{ start: number; end: number }> = [];
for (let w = 0; w < safeWorkers; w++) {
const start = Math.min(safeFrames, w * framesPerWorker);
const end = Math.min(safeFrames, start + framesPerWorker);
ranges.push({ start, end });
}
return ranges;
}
/** Build a Set of frame indices that fall inside any transition window. */
export function partitionTransitionFrames(
transitionRanges: ReadonlyArray<Pick<TransitionRange, "startFrame" | "endFrame">>,
totalFrames: number,
): Set<number> {
const frames = new Set<number>();
if (totalFrames <= 0) return frames;
for (const range of transitionRanges) {
const start = Math.max(0, range.startFrame);
const end = Math.min(totalFrames - 1, range.endFrame);
for (let i = start; i <= end; i++) frames.add(i);
}
return frames;
}
// ─── Types ─────────────────────────────────────────────────────────────────
export interface LayeredTransitionBuffers {
bufferA: Buffer;
bufferB: Buffer;
output: Buffer;
}
// ─── Seek + inject + stacking query (shared across all three loop paths) ──
/**
* Seek the page to `time` and run the optional before-capture hook.
* Used by `captureSceneIntoBuffer` which receives stacking info from
* its caller, and by `seekInjectAndQueryStacking` which appends a
* stacking query.
*/
async function seekAndInject(
page: CaptureSession["page"],
time: number,
beforeCaptureHook: BeforeCaptureHook | null,
hdrPerf: HdrPerfCollector | undefined,
seekKey: HdrPerfTimingKey,
injectKey: HdrPerfTimingKey,
): Promise<void> {
await timeHdrPhaseAsync(hdrPerf, seekKey, () =>
page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time),
);
if (beforeCaptureHook) {
await timeHdrPhaseAsync(hdrPerf, injectKey, () => beforeCaptureHook(page, time));
}
}
/**
* Seek the page to `time`, run the optional before-capture hook, then
* query element stacking order. Each phase is individually timed via the
* caller-provided perf keys so the sequential loop, hybrid worker, and
* per-scene transition capture each emit the correct telemetry label
* (`frameSeekMs` vs. `domLayerSeekMs`, etc.).
*/
export async function seekInjectAndQueryStacking(
page: CaptureSession["page"],
time: number,
beforeCaptureHook: BeforeCaptureHook | null,
nativeHdrIds: Set<string>,
hdrPerf: HdrPerfCollector | undefined,
seekKey: HdrPerfTimingKey,
injectKey: HdrPerfTimingKey,
stackingKey: HdrPerfTimingKey,
): Promise<ElementStackingInfo[]> {
await seekAndInject(page, time, beforeCaptureHook, hdrPerf, seekKey, injectKey);
return timeHdrPhaseAsync(hdrPerf, stackingKey, () => queryElementStacking(page, nativeHdrIds));
}
// ─── Per-scene capture (shared by sequential transition + hybrid worker) ──
export interface CaptureSceneArgs {
session: CaptureSession;
sceneBuf: Buffer;
sceneIds: Set<string>;
stackingInfo: ElementStackingInfo[];
time: number;
width: number;
height: number;
nativeHdrIds: Set<string>;
nativeHdrImageIds: Set<string>;
beforeCaptureHook: CaptureSession["onBeforeCapture"];
hdrCompositeCtx: HdrCompositeContext;
compositeTransfer: "srgb" | "pq" | "hlg";
hdrTargetTransfer: "pq" | "hlg" | undefined;
hdrPerf: HdrPerfCollector | undefined;
log: ProducerLogger;
frameIdx: number;
}
export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void> {
const {
session,
sceneBuf,
sceneIds,
stackingInfo,
time,
width,
height,
nativeHdrIds,
nativeHdrImageIds,
beforeCaptureHook,
hdrCompositeCtx,
compositeTransfer,
hdrTargetTransfer,
hdrPerf,
log,
frameIdx,
} = a;
await seekAndInject(
session.page,
time,
beforeCaptureHook,
hdrPerf,
"domLayerSeekMs",
"domLayerInjectMs",
);
for (const el of stackingInfo) {
if (!el.isHdr || !sceneIds.has(el.id)) continue;
if (nativeHdrImageIds.has(el.id)) {
blitHdrImageLayer(
sceneBuf,
el,
hdrCompositeCtx.hdrImageBuffers,
hdrCompositeCtx.hdrImageTransferCache,
width,
height,
log,
hdrCompositeCtx.imageTransfers.get(el.id),
hdrTargetTransfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
sceneBuf,
el,
time,
hdrCompositeCtx.fps,
hdrCompositeCtx.hdrVideoFrameSources,
hdrCompositeCtx.hdrVideoStartTimes,
width,
height,
log,
hdrCompositeCtx.videoTransfers.get(el.id),
hdrTargetTransfer,
hdrPerf,
);
}
}
const showIds = selectDomLayerShowIds(Array.from(sceneIds), stackingInfo);
const hideIds = stackingInfo
.map((e) => e.id)
.filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id));
if (showIds.length === 0) return;
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () =>
applyDomLayerMask(session.page, showIds, hideIds),
);
const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () =>
captureAlphaPng(session.page, width, height),
);
await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () =>
removeDomLayerMask(session.page, hideIds),
);
try {
const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng));
timeHdrPhase(hdrPerf, "domBlitMs", () =>
blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer),
);
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", {
frameIndex: frameIdx,
sceneIds: Array.from(sceneIds),
error: err instanceof Error ? err.message : String(err),
});
}
}
// ─── Per-frame transition capture (hybrid worker path) ─────────────────────
export interface CaptureTransitionOnWorkerArgs {
session: CaptureSession;
frameIdx: number;
time: number;
transition: TransitionRange;
buffers: LayeredTransitionBuffers;
nativeHdrIds: Set<string>;
nativeHdrImageIds: Set<string>;
sceneElements: Record<string, string[]>;
hdrCompositeCtx: HdrCompositeContext;
width: number;
height: number;
compositeTransfer: "srgb" | "pq" | "hlg";
hdrTargetTransfer: "pq" | "hlg" | undefined;
hdrPerf: HdrPerfCollector | undefined;
log: ProducerLogger;
}
export async function captureTransitionFrameOnWorker(
a: CaptureTransitionOnWorkerArgs,
): Promise<void> {
const {
session,
frameIdx,
time,
transition,
buffers,
nativeHdrIds,
nativeHdrImageIds,
sceneElements,
hdrCompositeCtx,
width,
height,
compositeTransfer,
hdrTargetTransfer,
hdrPerf,
log,
} = a;
const beforeCaptureHook = session.onBeforeCapture;
if (hdrPerf) {
hdrPerf.frames += 1;
hdrPerf.transitionFrames += 1;
}
const stackingInfo = await seekInjectAndQueryStacking(
session.page,
time,
beforeCaptureHook,
nativeHdrIds,
hdrPerf,
"frameSeekMs",
"frameInjectMs",
"stackingQueryMs",
);
const sceneAIds = new Set(sceneElements[transition.fromScene] ?? []);
const sceneBIds = new Set(sceneElements[transition.toScene] ?? []);
buffers.bufferA.fill(0);
buffers.bufferB.fill(0);
const sceneCaptures: [Buffer, Set<string>][] = [
[buffers.bufferA, sceneAIds],
[buffers.bufferB, sceneBIds],
];
for (const [sceneBuf, sceneIds] of sceneCaptures) {
await captureSceneIntoBuffer({
session,
sceneBuf,
sceneIds,
stackingInfo,
time,
width,
height,
nativeHdrIds,
nativeHdrImageIds,
beforeCaptureHook,
hdrCompositeCtx,
compositeTransfer,
hdrTargetTransfer,
hdrPerf,
log,
frameIdx,
});
}
}
// ─── Streaming-encoder write guard ──────────────────────────────────────────
/**
* Streaming-encoder writes report `false` when FFmpeg is already gone.
* Continuing to capture into a dead encoder wastes the rest of the render,
* so every frame loop stops with a frame-indexed error instead.
*
* When the encoder is provided, the thrown error includes FFmpeg's own failure
* reason (exit code + stderr tail) — otherwise the message is just "exited
* before frame N", which for the frame-0 case (bad args / unsupported codec /
* missing binary quirk) is cryptic and unactionable.
*/
export function ensureFrameWritten(
frameWritten: boolean,
frameIndex: number,
encoder?: { getExitError: () => string | undefined },
): void {
if (frameWritten) return;
const reason = encoder?.getExitError();
const base = `Streaming encoder exited before frame ${frameIndex} was written`;
throw new Error(reason ? `${base}: ${reason}` : base);
}
// ─── HDR video raw-frame cleanup (sequential path only) ────────────────────
export function cleanupEndedHdrVideos(args: {
time: number;
activeTransition: TransitionRange | undefined;
hdrVideoEndTimes: Map<string, number>;
cleanedUpVideos: Set<string>;
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>;
sceneElements: Record<string, string[]>;
log: ProducerLogger;
}): void {
if (process.env.KEEP_TEMP === "1") return;
const {
time,
activeTransition,
hdrVideoEndTimes,
cleanedUpVideos,
hdrVideoFrameSources,
sceneElements,
log,
} = args;
for (const [videoId, endTime] of hdrVideoEndTimes) {
if (time > endTime && !cleanedUpVideos.has(videoId)) {
const stillNeeded =
activeTransition &&
(sceneElements[activeTransition.fromScene]?.includes(videoId) ||
sceneElements[activeTransition.toScene]?.includes(videoId));
if (!stillNeeded) {
const frameSource = hdrVideoFrameSources.get(videoId);
if (frameSource) {
closeHdrVideoFrameSource(frameSource, log);
try {
rmSync(frameSource.dir, { recursive: true, force: true });
} catch (err) {
log.warn("Failed to clean up HDR raw frame directory", {
videoId,
frameDir: frameSource.dir,
rawPath: frameSource.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
hdrVideoFrameSources.delete(videoId);
}
cleanedUpVideos.add(videoId);
}
}
}
}
@@ -0,0 +1,358 @@
/**
* captureHdrHybridLoop — the hf#732 hybrid parallel layered path.
*
* Spreads per-frame DOM capture work across N DOM worker sessions (one
* Chrome session per worker) and offloads the per-pixel shader-blend onto
* a `worker_threads` pool. The encoder is fed via a frame-reorder buffer
* so out-of-order worker completions still hit the muxer in ascending
* index order.
*
* Restrictions enforced by `shouldUseHybridLayeredPath`:
* - SDR only (HDR raw-frame sources are fd-bound to one worker).
* - workerCount >= 2.
* - Not every frame inside a transition window.
*
* Pool teardown is guaranteed in the outer `finally` regardless of which
* path threw — see `runHybridLayeredFrameLoop`. The shader-blend pool is
* spawned lazily (only when the composition has transitions); the DOM
* worker sessions are always spawned.
*/
import { join } from "node:path";
import {
type CaptureOptions,
type CaptureSession,
type EngineConfig,
type StreamingEncoder,
type TransitionFn,
TRANSITIONS,
closeCaptureSession,
createCaptureSession,
createFrameReorderBuffer,
crossfade,
initTransparentBackground,
initializeSession,
} from "@hyperframes/engine";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import {
type HdrCompositeContext,
type TransitionRange,
compositeHdrFrame,
} from "../../hdrCompositor.js";
import { type HdrPerfCollector, addHdrTiming, timeHdrPhaseAsync } from "../hdrPerf.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { writeFileExclusiveSync } from "../shared.js";
import {
type ShaderTransitionWorkerPool,
createShaderTransitionWorkerPool,
} from "../../shaderTransitionWorkerPool.js";
import {
type LayeredTransitionBuffers,
captureTransitionFrameOnWorker,
distributeLayeredHybridFrameRanges,
ensureFrameWritten,
partitionTransitionFrames,
seekInjectAndQueryStacking,
} from "./captureHdrFrameShared.js";
import { updateJobStatus } from "../shared.js";
export interface HybridLoopInput {
job: RenderJob;
cfg: EngineConfig;
log: ProducerLogger;
framesDir: string;
width: number;
height: number;
totalFrames: number;
nativeHdrIds: Set<string>;
nativeHdrImageIds: Set<string>;
hdrCompositeCtx: HdrCompositeContext;
hdrPerf: HdrPerfCollector | undefined;
hdrEncoder: StreamingEncoder;
domSession: CaptureSession;
fileServer: FileServerHandle;
buildCaptureOptions: () => CaptureOptions;
createRenderVideoFrameInjector: () => Parameters<typeof createCaptureSession>[3];
transitionRanges: TransitionRange[];
sceneElements: Record<string, string[]>;
compositeTransfer: "srgb" | "pq" | "hlg";
hdrTargetTransfer: "pq" | "hlg" | undefined;
workerCount: number;
debugDumpEnabled: boolean;
debugDumpDir: string | null;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise<void> {
const {
job,
cfg,
log,
width,
height,
totalFrames,
nativeHdrIds,
nativeHdrImageIds,
hdrCompositeCtx,
hdrPerf,
hdrEncoder,
domSession,
fileServer,
buildCaptureOptions,
createRenderVideoFrameInjector,
transitionRanges,
sceneElements,
compositeTransfer,
hdrTargetTransfer,
workerCount,
debugDumpEnabled,
debugDumpDir,
assertNotAborted,
onProgress,
} = input;
const transitionFramesSet = partitionTransitionFrames(transitionRanges, totalFrames);
const hasTransitions = transitionRanges.length > 0;
const bufSize = width * height * 6;
const workerSessions: CaptureSession[] = [];
let shaderPool: ShaderTransitionWorkerPool | null = null;
try {
for (let w = 0; w < workerCount - 1; w++) {
const s = await createCaptureSession(
fileServer.url,
input.framesDir,
buildCaptureOptions(),
createRenderVideoFrameInjector(),
cfg,
);
await initializeSession(s);
await initTransparentBackground(s.page);
workerSessions.push(s);
}
const sessions: CaptureSession[] = [domSession, ...workerSessions];
const activeWorkerCount = sessions.length;
if (hasTransitions) {
try {
shaderPool = await createShaderTransitionWorkerPool({ size: activeWorkerCount, log });
} catch (err) {
log.warn(
"[Render] Failed to spawn shader-blend worker pool; falling back to inline shader blend",
{ error: err instanceof Error ? err.message : String(err) },
);
shaderPool = null;
}
}
const workerCanvases: Buffer[] = sessions.map(() => Buffer.alloc(bufSize));
// hf#732 PR 5: K-deep ring of transition buffer-triples per worker. The
// ring lets capture-N+1 proceed on the DOM worker while the shader-blend
// pool is still working on frames N-K+1..N. Without the ring (PR 4), each
// worker awaited its own blend before the next capture, capping the pool
// at <=1 task per worker. With K=4, the pool sees up to min(N_workers * K,
// poolSize) concurrent blends, which empirically pushes shader-render
// wall time another ~10-20% past PR 4 alone.
//
// The ideal K is `blend_per_frame / capture_per_frame`. For 854x480
// rgb48le with the more complex shaders this is ~910ms / ~175ms ≈ 5.
// K=4 strikes a perf vs. memory balance. Override via
// `HF_TRANSITION_RING_DEPTH` if a workload's blend/capture ratio is very
// different (simpler shaders that blend in ~100ms tolerate K=1-2 without
// perf loss).
const DEFAULT_TRANSITION_RING_DEPTH = 4;
const TRANSITION_RING_DEPTH = Math.max(
1,
Number(process.env.HF_TRANSITION_RING_DEPTH ?? String(DEFAULT_TRANSITION_RING_DEPTH)),
);
const workerTransitionRings: Array<LayeredTransitionBuffers[] | null> = sessions.map(() => {
if (!hasTransitions) return null;
const ring: LayeredTransitionBuffers[] = [];
for (let k = 0; k < TRANSITION_RING_DEPTH; k++) {
ring.push({
bufferA: Buffer.alloc(bufSize),
bufferB: Buffer.alloc(bufSize),
output: Buffer.alloc(bufSize),
});
}
return ring;
});
const workerRanges = distributeLayeredHybridFrameRanges(totalFrames, activeWorkerCount);
let framesWritten = 0;
const reorderBuffer = createFrameReorderBuffer(0, totalFrames);
const writeEncoded = async (frameIdx: number, buf: Buffer): Promise<void> => {
await reorderBuffer.waitForFrame(frameIdx);
const writeStart = Date.now();
ensureFrameWritten(await hdrEncoder.writeFrame(buf), frameIdx, hdrEncoder);
addHdrTiming(hdrPerf, "encoderWriteMs", writeStart);
reorderBuffer.advanceTo(frameIdx + 1);
framesWritten += 1;
job.framesRendered = framesWritten;
if (framesWritten % 10 === 0 || framesWritten === totalFrames) {
const frameProgress = framesWritten / totalFrames;
updateJobStatus(
job,
"rendering",
`Layered composite frame ${framesWritten}/${job.totalFrames}`,
Math.round(25 + frameProgress * 55),
onProgress,
);
}
};
const poolRef = shaderPool;
const workerTaskOf = async (w: number): Promise<void> => {
const session = sessions[w];
const canvas = workerCanvases[w];
const range = workerRanges[w];
const ring = workerTransitionRings[w];
if (!session || !canvas || !range) return;
// Per-ring-slot in-flight promise. When a slot is mid-blend, its
// promise is non-null; before reusing the slot for a new capture we
// await it so the buffer triple is free + the encoder has seen the
// earlier frame (writeEncoded gates ordering via the reorder buffer).
const ringInFlight: Array<Promise<void> | null> = ring ? ring.map(() => null) : [];
let nextRingIdx = 0;
for (let i = range.start; i < range.end; i++) {
assertNotAborted();
const time = (i * job.config.fps.den) / job.config.fps.num;
const activeTransition = transitionFramesSet.has(i)
? transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame)
: undefined;
if (activeTransition && ring) {
// Pick the next ring slot. If it's still in flight from an earlier
// capture, await it to drain before reusing its buffer triple.
const slot = nextRingIdx;
nextRingIdx = (nextRingIdx + 1) % TRANSITION_RING_DEPTH;
const prev = ringInFlight[slot];
if (prev) await prev;
const buffers = ring[slot];
if (!buffers) continue;
// CAPTURE on the DOM worker (this thread). Fills bufferA/bufferB
// synchronously w.r.t. this loop — DOM work can't be pipelined
// because the per-worker browser session is single-threaded.
await captureTransitionFrameOnWorker({
session,
frameIdx: i,
time,
transition: activeTransition,
buffers,
nativeHdrIds,
nativeHdrImageIds,
sceneElements,
hdrCompositeCtx,
width,
height,
compositeTransfer,
hdrTargetTransfer,
hdrPerf,
log,
});
const progress =
activeTransition.endFrame === activeTransition.startFrame
? 1
: (i - activeTransition.startFrame) /
(activeTransition.endFrame - activeTransition.startFrame);
// BLEND + ENCODE without awaiting. The promise drains back into
// `ringInFlight[slot]`; the next iteration that picks `slot`
// awaits it. The encoder reorder buffer fences ordering so out-
// of-order blend completion is fine.
const frameIdx = i;
// When the @hyperframes/shader-transitions composition omits the
// shader on a transition entry, it requests a CSS crossfade. The
// engine-side path uses applyFallbackTransition() on the page; the
// producer's Node-side layered pipeline runs the equivalent here
// by routing the blend through `crossfade`.
const shaderName = activeTransition.shader;
const dispatch: Promise<void> = (async () => {
if (poolRef && shaderName) {
const blendStart = Date.now();
const result = await poolRef.run({
shader: shaderName,
bufferA: buffers.bufferA,
bufferB: buffers.bufferB,
output: buffers.output,
width,
height,
progress,
});
buffers.bufferA = result.bufferA;
buffers.bufferB = result.bufferB;
buffers.output = result.output;
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
} else {
const transitionFn: TransitionFn = shaderName
? (TRANSITIONS[shaderName] ?? crossfade)
: crossfade;
const blendStart = Date.now();
transitionFn(
buffers.bufferA,
buffers.bufferB,
buffers.output,
width,
height,
progress,
);
addHdrTiming(hdrPerf, "transitionCompositeMs", blendStart);
}
await writeEncoded(frameIdx, buffers.output);
})();
// Catch on a separate handle so an unhandled-rejection can't fire
// if no one awaits this slot before the worker exits. The error
// is re-thrown on the next await (slot reuse OR end-of-task drain).
ringInFlight[slot] = dispatch.catch((err: unknown) => {
throw err instanceof Error ? err : new Error(String(err));
});
} else {
const stackingInfo = await seekInjectAndQueryStacking(
session.page,
time,
session.onBeforeCapture,
nativeHdrIds,
hdrPerf,
"frameSeekMs",
"frameInjectMs",
"stackingQueryMs",
);
canvas.fill(0);
// Rebind ctx to this worker's session for per-layer captures
const wctx: HdrCompositeContext = { ...hdrCompositeCtx, domSession: session };
await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () =>
compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i),
);
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
writeFileExclusiveSync(
join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
canvas,
);
}
await writeEncoded(i, canvas);
}
}
// Drain any pipelined blends still in flight on this worker before
// returning. If any rejected, the rejection bubbles here so
// `Promise.all` over `workerTaskOf` sees the failure.
for (const pending of ringInFlight) {
if (pending) await pending;
}
};
await Promise.all(sessions.map((_, w) => workerTaskOf(w)));
await reorderBuffer.waitForAllDone();
} finally {
for (const s of workerSessions) {
await closeCaptureSession(s).catch((err) => {
log.warn("Hybrid worker session close failed", {
err: err instanceof Error ? err.message : String(err),
});
});
}
if (shaderPool) {
await shaderPool.terminate().catch((err) => {
log.warn("Shader-blend worker pool terminate failed", {
err: err instanceof Error ? err.message : String(err),
});
});
}
}
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { estimateHdrExtractionBytes } from "./captureHdrResources.js";
describe("estimateHdrExtractionBytes", () => {
it("sums 6 bytes per pixel per frame across videos", () => {
// 10s @ 30fps of 1920x1080 = 300 frames * 1920*1080*6
expect(
estimateHdrExtractionBytes([{ durationSeconds: 10, width: 1920, height: 1080 }], 30),
).toBe(300 * 1920 * 1080 * 6);
});
it("accumulates multiple videos and rounds frame counts up", () => {
const bytes = estimateHdrExtractionBytes(
[
{ durationSeconds: 1.5, width: 100, height: 100 },
{ durationSeconds: 0.05, width: 100, height: 100 },
],
30,
);
expect(bytes).toBe((45 + 2) * 100 * 100 * 6);
});
it("treats negative durations as empty", () => {
expect(estimateHdrExtractionBytes([{ durationSeconds: -3, width: 100, height: 100 }], 30)).toBe(
0,
);
});
});
@@ -0,0 +1,386 @@
/**
* captureHdrResources — HDR resource setup helpers for the HDR layered
* composite stage. Extracted from `captureHdrStage.ts` so the orchestrator
* stays under the project's 500-line ceiling.
*
* Responsibilities (in order, called by `captureHdrStage.ts`):
* 1. Probe per-element HDR extraction dimensions at the elements' own
* start times (so GSAP-driven `data-start > 0` images don't fall out).
* 2. Pre-extract every HDR video into a raw rgb48le frame file via a
* single FFmpeg pass per video.
* 3. Pre-decode every HDR image into rgb48le buffers, resampled to the
* element's layout box using CSS `object-fit` / `object-position`
* semantics.
*
* All helpers are SDR-content-safe: they no-op cleanly when no HDR layers
* exist, leaving the caller with empty maps that the hot loop tolerates.
*/
import {
closeSync,
constants,
fstatSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
statfsSync,
} from "node:fs";
import { join } from "node:path";
import {
type CaptureSession,
decodePngToRgb48le,
normalizeObjectFit,
queryElementStacking,
resampleRgb48leObjectFit,
runFfmpeg,
} from "@hyperframes/engine";
import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js";
import type { HdrImageBuffer, HdrVideoFrameSource } from "../../hdrCompositor.js";
import type { HdrDiagnostics, RenderJob } from "../../renderOrchestrator.js";
import type { CompositionMetadata } from "../shared.js";
const NO_FOLLOW_FLAG = constants.O_NOFOLLOW ?? 0;
function tempDirSafePrefix(id: string): string {
const safe = id.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80);
return safe || "video";
}
export interface HdrResourcePrep {
hdrVideoIds: string[];
hdrVideoSrcPaths: Map<string, string>;
hdrVideoStartTimes: Map<string, number>;
hdrImageStartTimes: Map<string, number>;
hdrExtractionDims: Map<string, { width: number; height: number }>;
hdrImageFitInfo: Map<string, { fit: string; position: string }>;
}
/**
* Build the maps the resource-extraction helpers below need. Pure data
* transformation against `composition` + the native-HDR ID sets.
*/
export function planHdrResources(args: {
composition: CompositionMetadata;
nativeHdrVideoIds: Set<string>;
nativeHdrImageIds: Set<string>;
projectDir: string;
compiledDir: string;
existsSync: (p: string) => boolean;
}): HdrResourcePrep {
const { composition, nativeHdrVideoIds, nativeHdrImageIds, projectDir, compiledDir } = args;
const hdrVideoIds = composition.videos
.filter((v) => nativeHdrVideoIds.has(v.id))
.map((v) => v.id);
const hdrVideoSrcPaths = new Map<string, string>();
for (const v of composition.videos) {
if (!hdrVideoIds.includes(v.id)) continue;
let srcPath = v.src;
if (!srcPath.startsWith("/")) {
const fromCompiled = join(compiledDir, srcPath);
srcPath = args.existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath);
}
hdrVideoSrcPaths.set(v.id, srcPath);
}
const hdrVideoStartTimes = new Map<string, number>();
for (const v of composition.videos) {
if (hdrVideoIds.includes(v.id)) hdrVideoStartTimes.set(v.id, v.start);
}
const hdrImageStartTimes = new Map<string, number>();
for (const img of composition.images) {
if (nativeHdrImageIds.has(img.id)) hdrImageStartTimes.set(img.id, img.start);
}
return {
hdrVideoIds,
hdrVideoSrcPaths,
hdrVideoStartTimes,
hdrImageStartTimes,
hdrExtractionDims: new Map(),
hdrImageFitInfo: new Map(),
};
}
/**
* Probe per-element layout dimensions at each unique start time, populating
* `hdrExtractionDims` and `hdrImageFitInfo` in place. Also runs a fallback
* probe for HDR images whose `data-start` instant reports zero dims (GSAP
* `from` tweens animate the element in slightly later).
*/
export async function probeHdrExtractionDims(args: {
domSession: CaptureSession;
nativeHdrIds: Set<string>;
nativeHdrImageIds: Set<string>;
composition: CompositionMetadata;
prep: HdrResourcePrep;
}): Promise<void> {
const { domSession, nativeHdrIds, nativeHdrImageIds, composition, prep } = args;
const uniqueStartTimes = [
...new Set([...prep.hdrVideoStartTimes.values(), ...prep.hdrImageStartTimes.values()]),
].sort((a, b) => a - b);
for (const seekTime of uniqueStartTimes) {
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, seekTime);
if (domSession.onBeforeCapture) {
await domSession.onBeforeCapture(domSession.page, seekTime);
}
const stacking = await queryElementStacking(domSession.page, nativeHdrIds);
for (const el of stacking) {
if (
el.isHdr &&
el.layoutWidth > 0 &&
el.layoutHeight > 0 &&
!prep.hdrExtractionDims.has(el.id)
) {
prep.hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight });
}
if (el.isHdr && nativeHdrImageIds.has(el.id) && !prep.hdrImageFitInfo.has(el.id)) {
prep.hdrImageFitInfo.set(el.id, { fit: el.objectFit, position: el.objectPosition });
}
}
}
for (const [imageId, startTime] of prep.hdrImageStartTimes) {
if (prep.hdrExtractionDims.has(imageId)) continue;
const img = composition.images.find((i) => i.id === imageId);
if (!img) continue;
const duration = img.end - img.start;
const retryTime = startTime + Math.min(0.5, duration * 0.1);
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, retryTime);
if (domSession.onBeforeCapture) {
await domSession.onBeforeCapture(domSession.page, retryTime);
}
const retryStacking = await queryElementStacking(domSession.page, nativeHdrIds);
for (const el of retryStacking) {
if (el.id === imageId && el.isHdr && el.layoutWidth > 0 && el.layoutHeight > 0) {
prep.hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight });
if (!prep.hdrImageFitInfo.has(el.id)) {
prep.hdrImageFitInfo.set(el.id, { fit: el.objectFit, position: el.objectPosition });
}
break;
}
}
}
}
/**
* Total bytes the raw rgb48le pre-extraction below will write: 6 bytes/px ×
* frame area × frame count, summed across HDR videos. Exposed for the
* disk-headroom gate — a plain iPhone HLG clip auto-detected as HDR filled a
* near-full disk with 16-bit raw frames before the user found --sdr (wild
* report, CLI 0.7.52), so the commitment must be checked and surfaced BEFORE
* FFmpeg starts writing.
*/
export function estimateHdrExtractionBytes(
videos: Array<{ durationSeconds: number; width: number; height: number }>,
fps: number,
): number {
let total = 0;
for (const v of videos) {
const frames = Math.ceil(Math.max(0, v.durationSeconds) * fps);
total += frames * v.width * v.height * 6;
}
return total;
}
const HDR_EXTRACTION_HEADROOM_FRACTION = 0.9;
const HDR_EXTRACTION_WARN_BYTES = 10e9;
/**
* Disk-headroom gate for raw rgb48le pre-extraction: throws if the planned
* writes would consume more than 90% of free space at `framesDir`, warns
* above 10 GB either way. Fails fast with the --sdr escape hatch instead of
* running the disk to zero mid-extraction ("No space left on device", wild
* report: a plain iPhone HLG clip auto-detected as HDR). `statfsSync` errors
* (unsupported platform/filesystem) skip the gate silently — only the
* headroom violation itself is rethrown.
*/
function assertHdrExtractionDiskHeadroom(
framesDir: string,
plannedVideos: Array<{ durationSeconds: number; width: number; height: number }>,
fps: number,
log: ProducerLogger,
): void {
const estimatedBytes = estimateHdrExtractionBytes(plannedVideos, fps);
let freeBytes: number;
try {
const stat = statfsSync(framesDir);
freeBytes = stat.bavail * stat.bsize;
} catch {
// statfs unsupported on this platform/filesystem — skip the gate.
return;
}
const estimatedGb = (estimatedBytes / 1e9).toFixed(1);
if (estimatedBytes > freeBytes * HDR_EXTRACTION_HEADROOM_FRACTION) {
throw new Error(
`HDR pre-extraction needs ~${estimatedGb} GB of raw 16-bit frames but only ` +
`${(freeBytes / 1e9).toFixed(1)} GB is free at ${framesDir}. ` +
`If the composition doesn't need HDR output, re-run with --sdr; ` +
`otherwise free up disk space and retry.`,
);
}
if (estimatedBytes > HDR_EXTRACTION_WARN_BYTES) {
log.warn(
`HDR pre-extraction will write ~${estimatedGb} GB of raw 16-bit frames ` +
`(pass --sdr to skip if HDR output isn't needed)`,
{ estimatedBytes, freeBytes },
);
}
}
/**
* Extract each HDR video into a raw rgb48le frame file via a single FFmpeg
* pass per video, and open a file descriptor for each. Returns a map keyed
* by video id. Caller owns lifecycle teardown (closing fds + rm-rf).
*/
export async function extractHdrVideoFrames(args: {
job: RenderJob;
log: ProducerLogger;
framesDir: string;
composition: CompositionMetadata;
prep: HdrResourcePrep;
width: number;
height: number;
abortSignal: AbortSignal | undefined;
hdrDiagnostics: HdrDiagnostics;
}): Promise<Map<string, HdrVideoFrameSource>> {
const { job, log, framesDir, composition, prep, width, height, abortSignal, hdrDiagnostics } =
args;
const out = new Map<string, HdrVideoFrameSource>();
mkdirSync(framesDir, { recursive: true });
const plannedVideos: Array<{ durationSeconds: number; width: number; height: number }> = [];
for (const [videoId] of prep.hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
const dims = prep.hdrExtractionDims.get(videoId) ?? { width, height };
plannedVideos.push({
durationSeconds: video.end - video.start,
width: dims.width,
height: dims.height,
});
}
assertHdrExtractionDiskHeadroom(framesDir, plannedVideos, fpsToNumber(job.config.fps), log);
for (const [videoId, srcPath] of prep.hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
mkdirSync(framesDir, { recursive: true });
const frameDir = mkdtempSync(join(framesDir, `hdr_${tempDirSafePrefix(videoId)}-`));
const duration = video.end - video.start;
const dims = prep.hdrExtractionDims.get(videoId) ?? { width, height };
const rawPath = join(frameDir, "frames.rgb48le");
const ffmpegArgs = [
"-ss",
String(video.mediaStart),
"-i",
srcPath,
"-t",
String(duration),
"-r",
fpsToFfmpegArg(job.config.fps),
"-vf",
`scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`,
"-pix_fmt",
"rgb48le",
"-f",
"rawvideo",
"-y",
rawPath,
];
const result = await runFfmpeg(ffmpegArgs, { signal: abortSignal });
if (!result.success) {
hdrDiagnostics.videoExtractionFailures += 1;
log.error("HDR frame pre-extraction failed; aborting render", {
videoId,
srcPath,
stderr: result.stderr.slice(-400),
});
throw new Error(
`HDR frame extraction failed for video "${videoId}". ` +
`Aborting render to avoid shipping black HDR layers.`,
);
}
const frameSize = dims.width * dims.height * 6;
const fd = openSync(rawPath, constants.O_RDONLY | NO_FOLLOW_FLAG);
let handedOff = false;
try {
const frameCount = Math.floor(fstatSync(fd).size / frameSize);
if (frameCount < 1) {
hdrDiagnostics.videoExtractionFailures += 1;
throw new Error(
`HDR frame extraction produced no frames for video "${videoId}". ` +
`Aborting render to avoid shipping black HDR layers.`,
);
}
out.set(videoId, {
dir: frameDir,
rawPath,
fd,
width: dims.width,
height: dims.height,
frameSize,
frameCount,
scratch: Buffer.allocUnsafe(frameSize),
});
handedOff = true;
} finally {
if (!handedOff) closeSync(fd);
}
}
return out;
}
/**
* Decode each HDR image into an rgb48le buffer, resampling to the element's
* layout box if known. Failures abort the render to avoid shipping missing
* layers (the hot loop has no fallback for a missing HDR layer that the
* composition expects to see).
*/
export function decodeHdrImageBuffers(args: {
log: ProducerLogger;
hdrImageSrcPaths: Map<string, string>;
prep: HdrResourcePrep;
hdrDiagnostics: HdrDiagnostics;
}): Map<string, HdrImageBuffer> {
const { log, hdrImageSrcPaths, prep, hdrDiagnostics } = args;
const out = new Map<string, HdrImageBuffer>();
for (const [imageId, srcPath] of hdrImageSrcPaths) {
try {
const decoded = decodePngToRgb48le(readFileSync(srcPath));
const layout = prep.hdrExtractionDims.get(imageId);
const fitInfo = prep.hdrImageFitInfo.get(imageId);
if (layout && (layout.width !== decoded.width || layout.height !== decoded.height)) {
const fit = normalizeObjectFit(fitInfo?.fit);
const resampled = resampleRgb48leObjectFit(
decoded.data,
decoded.width,
decoded.height,
layout.width,
layout.height,
fit,
fitInfo?.position,
);
out.set(imageId, { data: resampled, width: layout.width, height: layout.height });
} else {
out.set(imageId, {
data: Buffer.from(decoded.data),
width: decoded.width,
height: decoded.height,
});
}
} catch (err) {
hdrDiagnostics.imageDecodeFailures += 1;
log.error("HDR image decode failed; aborting render", {
imageId,
srcPath,
error: err instanceof Error ? err.message : String(err),
});
throw new Error(
`HDR image decode failed for image "${imageId}". ` +
`Aborting render to avoid shipping missing HDR image layers.`,
);
}
}
return out;
}

Some files were not shown because too many files have changed in this diff Show More