Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/adapter-hyperframes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@
"playwright": "^1.49.0"
},
"peerDependencies": {
"hyperframes": "^0.4.0"
"hyperframes": "^0.4.0",
"@hyperframes/producer": ">=0.7.0"
},
"peerDependenciesMeta": {
"hyperframes": {
"optional": true
},
"@hyperframes/producer": {
"optional": true
}
},
"devDependencies": {
Expand Down
16 changes: 16 additions & 0 deletions packages/adapter-hyperframes/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,27 @@ import type {
RenderOutput,
} from '@html-video/core';
import { HtmlVideoError } from '@html-video/core';
import { tryDeterministicRender } from './renderDeterministic.js';

const ADAPTER_VERSION = '0.2.0-playwright';

/** Real render: chromium records the page, ffmpeg transcodes to MP4. */
export async function render(input: RenderInput, ctx: RenderContext): Promise<RenderOutput> {
// Prefer the real upstream Hyperframes pipeline (paused timeline, per-frame
// seek, BeginFrame capture) when `@hyperframes/producer` is installed and
// the template is a Hyperframes composition. Frame-perfect and faster than
// realtime; see renderDeterministic.ts. Falls through to the realtime
// capture below when the deterministic path doesn't apply or fails.
try {
const deterministic = await tryDeterministicRender(input, ctx);
if (deterministic) return deterministic;
} catch (err) {
if (ctx.signal?.aborted) throw err;
ctx.onProgress?.(
5,
`deterministic render failed (${err instanceof Error ? err.message : err}) — falling back to realtime capture`,
);
}
Comment on lines +40 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tryDeterministicRender() already returns null for the two non-applicable cases (@hyperframes/producer missing or the template not being a Hyperframes composition), and its docstring says it throws only for real render failures once the deterministic path has been entered. Catching every non-abort error here converts those real failures into a successful realtime export instead. That means a broken producer install, a render job error, or a deterministic pipeline regression will quietly produce a different video with different duration/audio semantics while still reporting success, which violates the fail-fast rule in AGENTS.md and makes this path very hard to trust in production. Please only fall back on the explicit null return, and let actual deterministic render failures propagate as render-failed errors instead of suppressing them.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

const t0 = Date.now();
ctx.onProgress?.(5, 'preparing');
const outDir = dirname(input.config.outputPath);
Expand Down
148 changes: 148 additions & 0 deletions packages/adapter-hyperframes/src/renderDeterministic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Deterministic Hyperframes render path — real upstream integration.
*
* Instead of recording the page in wall-clock time (playwright recordVideo →
* webm → ffmpeg), this drives the actual Hyperframes render pipeline
* (`@hyperframes/producer`): the composition's master timeline is held paused,
* seeked frame-by-frame, and each frame is captured via Chrome's BeginFrame
* API, then encoded. Consequences vs the realtime capture path:
*
* - Frame-perfect: no dropped frames under load, output is bit-identical
* across runs and machines (same-browser).
* - Faster than realtime on most content (parallel workers seek+capture
* concurrently; no need to wait `durationSec` of wall-clock).
* - No freeze/unfreeze + lead-in-trim heuristics: fonts are localized
* deterministically by the producer's compile stage before frame 0.
* - True alpha (webm/mov) and audio muxing come from the same pipeline,
* unlocking the `alpha`/`audio` capabilities this adapter already
* advertises but the realtime path cannot honor.
*
* The producer is an OPTIONAL dependency (mirroring the existing optional
* `hyperframes` peer): when `@hyperframes/producer` is not installed, or the
* template does not look like a Hyperframes composition, callers fall back to
* the realtime capture path unchanged.
*/

import { existsSync } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import { basename, dirname } from 'node:path';
import type { RenderContext, RenderInput, RenderOutput } from '@html-video/core';

const ADAPTER_VERSION = '0.3.0-producer';

/**
* A template qualifies for the deterministic path when it carries Hyperframes
* composition markers. Plain single-file CSS-keyframe templates (no timing
* contract) stay on the realtime capture path — the producer needs a seekable
* timeline (`data-*` timing attributes / registered GSAP timelines / the
* window.__hf protocol) to drive frames.
*/
export function isHyperframesComposition(html: string): boolean {
return (
/data-composition-id=/.test(html) ||
/data-composition-src=/.test(html) ||
/\bdata-duration=/.test(html) ||
/window\.__hf\b/.test(html) ||
/window\.__timelines\b/.test(html)
);
}

/** Minimal structural types for the producer API so this file typechecks
* without `@hyperframes/producer` installed (it is optional). Field shapes
* mirror `RenderConfigInput` / `executeRenderJob` in the upstream repo. */
interface ProducerModule {
createRenderJob(config: {
fps: number | { num: number; den: number };
quality: 'draft' | 'standard' | 'high';
format?: 'mp4' | 'webm' | 'mov' | 'png-sequence' | 'gif';
entryFile?: string;
variables?: Record<string, unknown>;
strictness?: string;
}): { id: string; warnings: unknown[] };
executeRenderJob(
job: unknown,
projectDir: string,
outputPath: string,
progressSink?: (progress: number, stage: string) => void,
abortSignal?: AbortSignal,
): Promise<void>;
}

/**
* Attempt the deterministic render. Returns null when the path does not
* apply (producer not installed / template not a Hyperframes composition) so
* the caller can fall back to realtime capture. Throws only for real render
* failures once the deterministic path has been entered.
*/
export async function tryDeterministicRender(
input: RenderInput,
ctx: RenderContext,
): Promise<RenderOutput | null> {
const sourcePath = input.template.sourcePath;
if (!existsSync(sourcePath)) return null;

const html = await readFile(sourcePath, 'utf8');
if (!isHyperframesComposition(html)) return null;

let producer: ProducerModule;
try {
// Specifier via variable: keeps tsc from requiring the optional module's
// type declarations at build time (it is an optional peer dependency).
const specifier = '@hyperframes/producer';
producer = (await import(specifier)) as unknown as ProducerModule;
} catch {
return null; // optional dep absent — realtime capture handles it
}

const t0 = Date.now();
const fps = input.config.fps || 30;
ctx.onProgress?.(5, 'deterministic render (hyperframes producer)');

// Hyperframes projects are directories with an entry HTML; templates already
// live in their own directory with sibling assets, so the template dir IS
// the project dir and the source file is the entry.
const projectDir = dirname(sourcePath);
const entryFile = basename(sourcePath);

const job = producer.createRenderJob({
fps,
quality: 'standard',
format: 'mp4',
entryFile,
// Hyperframes resolves `data-var` / var() substitutions natively — the
// same variables the orchestrator baked into the HTML flow through here
// for compositions that declare them.
variables: input.variables,
});

// Producer progress arrives as (0-100, stage); reserve the last 5% for stat.
await producer.executeRenderJob(
job,
projectDir,
input.config.outputPath,
(progress, stage) => ctx.onProgress?.(Math.min(95, Math.max(5, progress)), stage),
ctx.signal,
);

const st = await stat(input.config.outputPath);
const durationSec =
input.config.duration === 'auto' ? undefined : Number(input.config.duration);
ctx.onProgress?.(100, 'done');
return {
outputPath: input.config.outputPath,
meta: {
// The composition's own data-duration governs length in this path; fall
// back to the requested duration for reporting when it was explicit.
durationSec: durationSec ?? 0,
fileSizeBytes: st.size,
actualResolution: input.config.resolution,
fps,
renderedFrames: durationSec ? Math.round(durationSec * fps) : 0,
Comment on lines +128 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns incorrect metadata for every successful deterministic render with config.duration === 'auto': durationSec becomes 0 and renderedFrames becomes 0, even though the producer just emitted a non-empty file. The changed lines make that explicit by deriving both fields only from the request config, not from the produced composition duration. RenderOutput.meta is the adapter’s contract back to callers, and the existing Playwright path reports actual values here, so this creates a silent contract break for any consumer that shows render duration, frame count, or uses those values for later processing. Please compute these fields from the deterministic output instead of defaulting to zero, for example by reading the produced file with ffprobe or by pulling the resolved duration/frame count from the producer job result if that API exposes it.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

renderWallClockSec: (Date.now() - t0) / 1000,
engineVersion: `hyperframes-producer@${ADAPTER_VERSION}`,
},
diagnostics: [
'rendered deterministically via @hyperframes/producer (paused timeline, per-frame seek + BeginFrame capture)',
],
};
}