Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5663f3e
feat(engine,cli): drawElementImage fast-capture behind --experimental…
vanceingalls Jun 9, 2026
168b4dc
chore(ci): add fast-capture video validation workflow
vanceingalls Jun 9, 2026
88942d3
ci: temporarily trigger fast-video-validation on branch push
vanceingalls Jun 9, 2026
1baff51
fix(ci): default validate-fast-video env vars when empty (push trigger)
vanceingalls Jun 9, 2026
aa830ef
fix(engine): route ALL video to screenshot fallback — fast video unsu…
vanceingalls Jun 9, 2026
9aa2b73
test(producer): add fast-capture regression guard (fast-capture-gsap)
vanceingalls Jun 9, 2026
591dbb4
fix(engine): paint-event-synced drawElement capture + data-no-timelin…
vanceingalls Jun 9, 2026
54c6d71
docs(engine): root-cause the video fast-capture gate + R&D escape hatch
vanceingalls Jun 9, 2026
0d1c5c7
docs(engine): correct the video-gate root cause — caption-pattern opa…
vanceingalls Jun 10, 2026
aef5777
fix(engine): paint ancestor background before drawElementImage capture
vanceingalls Jun 10, 2026
f47ca2a
fix(engine): composite GPU and 2d canvases in fast capture — paint re…
vanceingalls Jun 10, 2026
fbe0f4f
fix(engine): white-fill jpeg fast capture when no author background e…
vanceingalls Jun 10, 2026
e5eed65
fix(engine): honor forceScreenshot compat hints in fast capture — alp…
vanceingalls Jun 10, 2026
d4e7f3a
fix(producer): compile-time video gate + BeginFrame liveness probe
vanceingalls Jun 10, 2026
2627037
feat(producer): rewrite opacity to autoAlpha at render time on fast path
vanceingalls Jun 10, 2026
a1701f8
fix(producer): video gate disables drawElement instead of forcing scr…
vanceingalls Jun 10, 2026
41194b0
fix(producer): add data-no-timeline to css-spinner test comp
vanceingalls Jun 11, 2026
0a796c4
fix(producer): data-no-timeline on CSS-only test comps + fix benchmar…
vanceingalls Jun 11, 2026
b3d3749
fix(producer): visibility-hide inline-opacity-0 autoAlpha targets at …
vanceingalls Jun 11, 2026
2c400d2
fix(producer): compile-time 3D-transform gate for fast capture
vanceingalls Jun 11, 2026
13af376
feat(engine): WebGL 3D-context projection for fast capture
vanceingalls Jun 11, 2026
2a606bc
fix(producer): crossfade + software-GL gates for fast capture
vanceingalls Jun 11, 2026
0b53193
fix(engine): mechanism-based stacked-fade gate, drop hf-tx markup gate
vanceingalls Jun 11, 2026
606b033
fix(engine,producer,core): fast-capture correctness + lint rule fixes
vanceingalls Jun 12, 2026
612ec5a
fix(engine): move routeToFallback inside useDrawElement scope
vanceingalls Jun 12, 2026
05d319e
fix(engine): block drawElement fast capture on SwiftShader (no GPU eg…
vanceingalls Jun 13, 2026
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
12 changes: 12 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"packages/producer/src/runtime-conformance.ts",
"packages/producer/src/benchmark.ts",
"packages/producer/scripts/generate-font-data.ts",
"packages/producer/scripts/validate-fast-video.ts",
"packages/cli/scripts/generate-font-data.ts",
"packages/engine/scripts/test-fitTextFontSize-browser.ts",
"packages/aws-lambda/scripts/*.ts",
Expand All @@ -38,6 +39,11 @@
],
"ignorePatterns": [
"docs/**",
// Chrome browser binaries downloaded by puppeteer — not project source.
"chrome/**",
// Standalone spike scripts and benchmark runners used during R&D.
"packages/engine/spikes/**",
"packages/producer/de-*.mjs",
"packages/producer/tests/**",
"packages/player/tests/**",
"packages/engine/tests/**",
Expand Down Expand Up @@ -104,6 +110,12 @@
"file": "packages/cli/src/commands/render.ts",
"exports": ["resolveBrowserGpuForCli", "renderLocal"],
},
// initThreeDProjectionInPage: passed to page.evaluate() for browser-side execution —
// Puppeteer serializes it at runtime, not an import-graph consumer.
{
"file": "packages/engine/src/services/threeDProjection.ts",
"exports": ["initThreeDProjectionInPage"],
},
// captureCost.ts: constants and helpers consumed by the runCaptureCalibration
// orchestration function and tests, but the entry-point graph doesn't
// reach them because the orchestrator's caller resolves them dynamically.
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/fast-video-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Validates the experimental fast-capture (drawElementImage) VIDEO path on a
# native amd64 Linux runner — where chrome-headless-shell's per-frame BeginFrame
# drives a real paint each frame, so drawElementImage's snapshot is fresh and
# video captures correctly. This is the one part of the feature that could not be
# validated locally (macOS has no BeginFrame; Docker-on-rosetta hung).
# See docs/fast-capture-limitations.md (Limitation 2).
#
# Manual trigger: Actions → "Fast-capture video validation" → Run workflow.
name: Fast-capture video validation

on:
# Manual only. NOTE: currently fails by design — fast capture cannot capture
# video on any platform yet (see docs/fast-capture-limitations.md, Limitation 2).
# This is the regression gate for if/when fast video is implemented.
workflow_dispatch:
inputs:
composition:
description: Test composition to render (must contain <video>)
default: sub-composition-video
min_psnr:
description: Min fast-vs-baseline PSNR (dB) to pass
default: "25"

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Build test Docker image (cached)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile.test
load: true
tags: hyperframes-producer:test
cache-from: type=gha,scope=regression-test-image
cache-to: type=gha,mode=max,scope=regression-test-image

- name: Validate fast-capture video (drawElement + BeginFrame)
run: |
docker run --rm \
--security-opt seccomp=unconfined \
--shm-size=4g \
-e PRODUCER_VALIDATE_COMP='${{ inputs.composition }}' \
-e PRODUCER_VALIDATE_MIN_PSNR='${{ inputs.min_psnr }}' \
--workdir /app/packages/producer \
--entrypoint bunx \
hyperframes-producer:test tsx scripts/validate-fast-video.ts

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +26 to +52
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"coverage/",
"node_modules/",
"playground/",
"registry/blocks/**/lib/*.iife.js"
"registry/blocks/**/lib/*.iife.js",
"chrome/"
]
}
23 changes: 23 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,18 @@ export default defineCommand({
"memory thrash on constrained machines. Default: auto-detected from " +
"total RAM (<= 8 GB). Env: PRODUCER_LOW_MEMORY_MODE.",
},
"experimental-fast-capture": {
type: "boolean",
description:
"EXPERIMENTAL. Capture frames via Chrome's drawElementImage API " +
"instead of Page.captureScreenshot — reads DOM paint records directly, " +
"~46% faster on GPU. Transparent (PNG) renders on SwiftShader (Docker) " +
"auto-fall back to screenshot capture. Incompatible with page-side " +
"shader compositing. Default: false. Env: PRODUCER_EXPERIMENTAL_FAST_CAPTURE.",
// No `default` — an omitted flag must stay `undefined` so the `!= null`
// guard below leaves PRODUCER_EXPERIMENTAL_FAST_CAPTURE untouched and the
// env fallback survives (matches the --low-memory-mode idiom).
},
},
// `run` is the citty handler for `hyperframes render` — sequential flag
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
Expand Down Expand Up @@ -427,6 +439,13 @@ export default defineCommand({
process.env.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false";
}

// ── Override: experimental fast capture (drawElementImage) ───────────
if (args["experimental-fast-capture"] != null) {
process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = args["experimental-fast-capture"]
? "true"
: "false";
}

// ── Validate max-concurrent-renders ─────────────────────────────────
if (args["max-concurrent-renders"] != null) {
const parsed = parseInt(args["max-concurrent-renders"], 10);
Expand Down Expand Up @@ -629,6 +648,7 @@ export default defineCommand({
entryFile,
outputResolution,
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
Expand Down Expand Up @@ -684,6 +704,8 @@ interface RenderOptions {
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture (--experimental-fast-capture). */
experimentalFastCapture?: boolean;
/**
* Puppeteer `page.goto()` timeout for the entry HTML, in milliseconds.
* When omitted, the engine default (60s) applies. Surfaced as
Expand Down Expand Up @@ -919,6 +941,7 @@ async function renderDocker(
entryFile: options.entryFile,
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
experimentalFastCapture: options.experimentalFastCapture,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
},
});
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ describe("buildDockerRunArgs", () => {
videoBitrate: undefined,
quiet: true,
entryFile: "compositions/intro.html",
experimentalFastCapture: true,
},
});
// Each value must reach the container exactly once. If a future option
Expand All @@ -187,6 +188,24 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("--hdr");
expect(args).toContain("--composition");
expect(args).toContain("compositions/intro.html");
expect(args).toContain("--experimental-fast-capture");
});

it("forwards --experimental-fast-capture only when enabled", () => {
const on = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, experimentalFastCapture: true },
});
expect(on).toContain("--experimental-fast-capture");

const off = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, experimentalFastCapture: false },
});
expect(off).not.toContain("--experimental-fast-capture");

const absent = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(absent).not.toContain("--experimental-fast-capture");
});

it("forwards --format png-sequence to the container", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface DockerRenderOptions {
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
outputResolution?: string;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture; forwarded as `--experimental-fast-capture`. */
experimentalFastCapture?: boolean;
/**
* Puppeteer page-navigation timeout, in milliseconds. Forwarded to the
* in-container CLI as `--browser-timeout <seconds>` (the CLI takes
Expand Down Expand Up @@ -132,6 +134,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.entryFile ? ["--composition", options.entryFile] : []),
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
...(options.pageSideCompositing === false ? ["--no-page-side-compositing"] : []),
...(options.experimentalFastCapture ? ["--experimental-fast-capture"] : []),
...(options.pageNavigationTimeoutMs != null
? ["--browser-timeout", String(options.pageNavigationTimeoutMs / 1000)]
: []),
Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/lint/rules/composition.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

Expand Down Expand Up @@ -591,6 +592,78 @@ describe("composition rules", () => {
});
});

describe("missing_data_no_timeline", () => {
it("warns when root has no timeline registration and no data-no-timeline", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_data_no_timeline");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});

it("does not warn when data-no-timeline is present (boolean form)", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-no-timeline data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});

it("does not warn when a script registers window.__timelines[id]", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});

it("does not warn when there is no root composition-id", async () => {
const html = `<!DOCTYPE html><html><body><p>hello</p></body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});

it("does not false-positive when data-no-timeline appears only inside an attribute value", async () => {
// Regression: /\bdata-no-timeline\b/ matched substrings inside values
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" title="add data-no-timeline here" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
});

it("does not suppress when a hyphenated variant like data-no-timeline-start is present", async () => {
// Regression: /\bdata-no-timeline\b/ matched data-no-timeline-start because
// hyphen is a non-word char and \b fires between 'e' and '-'
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-no-timeline-start="0" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
});

it("does not warn for sub-compositions", async () => {
const html = `<template><div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div></template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});

it("does not warn when composition has external scripts (cannot scan for timeline registration)", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
<script src="app.js"></script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
});

describe("root_composition_missing_data_duration (removed)", () => {
// The rule was a static proxy for the runtime's loop-inflation Infinity
// emission, but lint cannot observe GSAP timeline duration statically and
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/lint/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,42 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},

// missing_data_no_timeline
// The producer polls window.__timelines[id] with a 45-second timeout waiting
// for GSAP timeline registration. Compositions that never call
// window.__timelines[id] = tl stall for 45 s every render. Adding
// data-no-timeline to the root element tells the producer to skip the poll.
({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
if (options.isSubComposition) return [];
if (!rootCompositionId || !rootTag) return [];
// readAttr only matches valued attrs (attr="..."); data-no-timeline is
// typically boolean (no value). Strip quoted attribute values first to
// avoid matching attr names that appear inside other values
// (e.g. title="add data-no-timeline here"), then check with a boundary
// that rejects hyphenated variants (data-no-timeline-start has '-' next,
// not a word-break char).
const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
// Can't scan external script files for timeline registration; skip to avoid
// false positives on compositions that register via a bundled JS file.
if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
if (registersTimeline) return [];
return [
{
code: "missing_data_no_timeline",
severity: "warning",
message:
"This composition has no `window.__timelines` registration but is missing `data-no-timeline`. " +
"The producer polls for timeline registration for up to 45 seconds before timing out, " +
"adding 45 s to every render.",
fixHint:
'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
snippet: truncateSnippet(rootTag.raw),
},
];
},

// requestanimationframe_in_composition
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down
47 changes: 47 additions & 0 deletions packages/engine/spikes/de-3d-canvas-sibling-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Does a WebGL canvas inside the captured root make drawElementImage drop
// the canvas's EARLIER DOM siblings?
import puppeteer from "puppeteer";
import { writeFileSync } from "node:fs";
const W=400,H=300;
const HTML=`<!doctype html><meta charset=utf-8>
<style>*{margin:0;padding:0}html,body{width:${W}px;height:${H}px;background:#ece8dd}
#root{position:relative;width:${W}px;height:${H}px;background:#ece8dd}
.headline{position:absolute;top:20px;left:0;width:100%;text-align:center;font:700 28px serif;color:#1a2640}
#footer{position:absolute;top:220px;left:0;width:100%;text-align:center;font:700 20px serif;color:#406080}</style>
<div id=root><div class=headline>HEADLINE</div><div id=footer>FOOTER</div></div>
<script>
const root=document.getElementById("root");
window.__addGl=(hidden)=>{
const c=document.createElement("canvas");
c.width=200;c.height=100;
c.style.cssText="position:absolute;left:50px;top:10px;width:200px;height:100px;"+(hidden?"visibility:hidden;":"");
root.appendChild(c);
const gl=c.getContext("webgl",{alpha:true,preserveDrawingBuffer:true});
gl.clearColor(0,0.5,0,0.5);gl.clear(gl.COLOR_BUFFER_BIT);
return !!gl;
};
const canvas=document.createElement("canvas");
canvas.setAttribute("layoutsubtree","");canvas.width=${W};canvas.height=${H};
canvas.style.cssText="display:block;position:absolute;top:0;left:0";
root.parentNode.insertBefore(canvas,root);canvas.appendChild(root);
window.__cap=()=>{
const ctx=canvas.getContext("2d");
return new Promise(r=>requestAnimationFrame(()=>setTimeout(()=>{
try{ctx.clearRect(0,0,${W},${H});ctx.drawElementImage(root,0,0);}catch(e){return r({err:String(e)});}
const url=canvas.toDataURL("image/png");ctx.clearRect(0,0,${W},${H});r({url});
},30)));
};
</script>`;
const b=await puppeteer.launch({headless:true,args:["--no-sandbox","--enable-features=CanvasDrawElement","--use-gl=angle",`--window-size=${W},${H}`]});
const p=await b.newPage();await p.setViewport({width:W,height:H});
await p.setContent(HTML,{waitUntil:"load"});
let r=await p.evaluate(()=>window.__cap());
writeFileSync("/tmp/cs-none.png",Buffer.from(r.url.split(",")[1],"base64"));

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.
await p.evaluate(()=>window.__addGl(false));
r=await p.evaluate(()=>window.__cap());
writeFileSync("/tmp/cs-visible.png",Buffer.from(r.url.split(",")[1],"base64"));

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.
await p.evaluate(()=>{document.querySelectorAll("#root canvas").forEach(c=>c.style.visibility="hidden");});
r=await p.evaluate(()=>window.__cap());
writeFileSync("/tmp/cs-hidden.png",Buffer.from(r.url.split(",")[1],"base64"));

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.
console.log("done");
await b.close();
Loading
Loading