chore: merge main into next

This commit is contained in:
Miguel Ángel
2026-04-28 23:41:36 +00:00
20 changed files with 1413 additions and 95 deletions
+97
View File
@@ -217,6 +217,103 @@ jobs:
echo "PASS: global install smoke test succeeded"
cli-smoke-required:
name: "CLI smoke (required)"
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install FFmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build monorepo
run: bun run build
- name: Create smoke input video
run: |
set -euo pipefail
ffmpeg -hide_banner -loglevel error \
-f lavfi -i testsrc2=size=640x360:rate=30 \
-f lavfi -i sine=frequency=880:sample_rate=48000 \
-t 3 \
-c:v libx264 \
-pix_fmt yuv420p \
-c:a aac \
-shortest \
-y /tmp/hf-cli-input.mp4
test -s /tmp/hf-cli-input.mp4
- name: Smoke-test CLI from monorepo source
run: |
set -euo pipefail
rm -rf /tmp/hf-cli-inside
bun run --filter @hyperframes/cli dev -- init /tmp/hf-cli-inside \
--example warm-grain \
--video /tmp/hf-cli-input.mp4 \
--skip-transcribe \
--non-interactive \
--skip-skills
bun run --filter @hyperframes/cli dev -- lint /tmp/hf-cli-inside
bun run --filter @hyperframes/cli dev -- validate /tmp/hf-cli-inside --timeout 3000
bun run --filter @hyperframes/cli dev -- render /tmp/hf-cli-inside \
--quality standard \
--workers auto \
--strict \
--output /tmp/hf-cli-inside/renders/inside.mp4
test -s /tmp/hf-cli-inside/renders/inside.mp4
- name: Pack CLI tarball
run: |
set -euo pipefail
mkdir -p /tmp/hf-cli-pack
cd packages/cli
PACKED_TARBALL="$(npm pack --pack-destination /tmp/hf-cli-pack | tail -n 1)"
test -n "$PACKED_TARBALL"
test -f "/tmp/hf-cli-pack/$PACKED_TARBALL"
echo "HF_CLI_TARBALL=/tmp/hf-cli-pack/$PACKED_TARBALL" >> "$GITHUB_ENV"
- name: Install packed CLI outside monorepo
run: |
set -euo pipefail
npm install -g --prefix /tmp/hf-cli-global "$HF_CLI_TARBALL"
- name: Smoke-test packed CLI outside monorepo
run: |
set -euo pipefail
export PATH="/tmp/hf-cli-global/bin:$PATH"
rm -rf /tmp/hf-cli-outside
hyperframes init /tmp/hf-cli-outside \
--example warm-grain \
--video /tmp/hf-cli-input.mp4 \
--skip-transcribe \
--non-interactive \
--skip-skills
hyperframes lint /tmp/hf-cli-outside
hyperframes validate /tmp/hf-cli-outside --timeout 3000
hyperframes render /tmp/hf-cli-outside \
--quality standard \
--workers auto \
--strict \
--output /tmp/hf-cli-outside/renders/outside.mp4
test -s /tmp/hf-cli-outside/renders/outside.mp4
semantic-pr-title:
name: Semantic PR title
if: github.event_name == 'pull_request'
+5
View File
@@ -82,6 +82,11 @@ async function main() {
cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js"));
}
const contrastAuditScript = join(CLI_ROOT, "src", "commands", "contrast-audit.browser.js");
if (existsSync(contrastAuditScript)) {
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
}
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
console.log("[build-copy] done");
+6 -11
View File
@@ -2,15 +2,11 @@ import { spawn } from "node:child_process";
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join, dirname, relative, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import { resolve, join, relative, isAbsolute } from "node:path";
import { resolveProject } from "../utils/project.js";
import { c } from "../ui/colors.js";
import type { Example } from "./_examples.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
* default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological
* clip (corrupt media, stalled network mount, codec edge case) cannot wedge
@@ -103,12 +99,11 @@ async function captureSnapshots(
let html = await bundleToSingleHtml(projectDir);
// Inject local runtime if available.
// The runtime IIFE is copied into the CLI dist during build (build:runtime),
// so it lives alongside cli.js. The old ../../../core/dist/ path only worked
// in the monorepo dev layout and broke for published/npx installs.
const runtimePath = resolve(__dirname, "hyperframe.runtime.iife.js");
if (existsSync(runtimePath)) {
const runtimeSource = readFileSync(runtimePath, "utf-8");
// Uses the same multi-strategy resolver as the studio preview server
// (runtimeSource.ts) so snapshot works in dev (tsx), built CLI, and npx.
const { loadRuntimeSource } = await import("../server/runtimeSource.js");
const runtimeSource = await loadRuntimeSource();
if (runtimeSource) {
html = html.replace(
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
+14 -5
View File
@@ -27,10 +27,6 @@ interface ContrastEntry {
bg: string;
}
// esbuild's text loader inlines this at build time — no runtime file read.
// @ts-expect-error — .browser.js files use esbuild text loader, not TS module resolution
import CONTRAST_AUDIT_SCRIPT from "./contrast-audit.browser.js";
const CONTRAST_SAMPLES = 5;
const SEEK_SETTLE_MS = 150;
@@ -64,7 +60,7 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
const duration = await getCompositionDuration(page);
if (duration <= 0) return [];
await page.addScriptTag({ content: CONTRAST_AUDIT_SCRIPT });
await page.addScriptTag({ content: loadContrastAuditScript() });
const results: ContrastEntry[] = [];
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
@@ -86,6 +82,19 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
return results;
}
function loadContrastAuditScript(): string {
const candidates = [
join(__dirname, "contrast-audit.browser.js"),
join(__dirname, "commands", "contrast-audit.browser.js"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
}
throw new Error("Missing contrast audit browser script");
}
async function validateInBrowser(
projectDir: string,
opts: { timeout?: number; contrast?: boolean },
@@ -2,6 +2,148 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("composition rules", () => {
describe("subcomposition guidance", () => {
it("warns when any HTML composition file is over 300 lines", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when an HTML composition file is exactly 300 lines", () => {
const html = Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("does not count a final trailing newline as an extra physical line", () => {
const html =
Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n") + "\n";
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("warns on large HTML files regardless of path", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, {
filePath: "/project/registry/blocks/data-chart.html",
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("uses nested split copy for large sub-composition files", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
isSubComposition: true,
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding?.fixHint).toContain("Split this sub-composition further");
});
it("warns when more than 3 timed elements share the same track", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
<div class="clip" data-start="3" data-duration="1" data-track-index="0">D</div>
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain("Track 0 has 4 timed elements");
});
it("does not warn when 3 timed elements share the same track", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not warn when timed elements are split across tracks", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="1">C</div>
<div class="clip" data-start="3" data-duration="1" data-track-index="1">D</div>
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not count timed media or script/style tags as dense track elements", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<audio data-start="0" data-duration="1" data-track-index="0"></audio>
<audio data-start="1" data-duration="1" data-track-index="0"></audio>
<video muted data-start="2" data-duration="1" data-track-index="0"></video>
<script data-start="3" data-duration="1" data-track-index="0"></script>
<style data-start="4" data-duration="1" data-track-index="0"></style>
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not count root composition or mounted sub-compositions as dense elements", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-track-index="0">
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="1" data-track-index="0"></div>
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="1" data-duration="1" data-track-index="0"></div>
<div data-composition-id="c" data-composition-src="compositions/c.html" data-start="2" data-duration="1" data-track-index="0"></div>
<div data-composition-id="d" data-composition-src="compositions/d.html" data-start="3" data-duration="1" data-track-index="0"></div>
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
});
it("reports info for composition with external CDN script dependency", () => {
const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080">
@@ -1,7 +1,76 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet } from "../utils";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);
function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;
const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
return withoutFinalNewline.split("\n").length;
}
function isCompositionRootOrMount(rawTag: string): boolean {
return Boolean(
readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
);
}
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// composition_file_too_large
({ rawSource, options }) => {
const lineCount = countPhysicalLines(rawSource);
if (lineCount <= MAX_COMPOSITION_LINES) return [];
const splitTarget = options.isSubComposition
? "Split this sub-composition further into smaller .html files"
: "Split coherent scenes or layers into separate .html files under compositions/";
return [
{
code: "composition_file_too_large",
severity: "warning",
message: `This HTML composition file has ${lineCount} lines. Agents produce better results when large scenes are split into smaller sub-compositions.`,
fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
},
];
},
// timeline_track_too_dense
({ tags, options }) => {
const trackCounts = new Map<string, number>();
for (const tag of tags) {
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
if (isCompositionRootOrMount(tag.raw)) continue;
if (!readAttr(tag.raw, "data-start")) continue;
const track = readAttr(tag.raw, "data-track-index");
if (!track) continue;
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
}
const findings: HyperframeLintFinding[] = [];
for (const [track, count] of trackCounts) {
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
const splitTarget = options.isSubComposition
? "Move coherent scene groups into smaller .html files"
: "Move coherent scene groups into separate .html files under compositions/";
findings.push({
code: "timeline_track_too_dense",
severity: "warning",
message: `Track ${track} has ${count} timed elements in this HTML file. Agents produce better timelines when dense tracks are split into smaller sub-compositions.`,
fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
});
}
return findings;
},
// timed_element_missing_visibility_hidden
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
@@ -402,6 +402,92 @@ describe("collectRuntimeTimelinePayload", () => {
expect(s2?.duration).toBe(3);
});
it("does not offset GSAP scene clips by the master timeline start time", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.id = "scene-1";
root.appendChild(scene);
const masterTimeline = {
duration: () => 20,
time: () => 1.25,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
startTime: () => 1.25,
getChildren: () => [
{
targets: () => [scene],
startTime: () => 4,
duration: () => 3,
parent: masterTimeline,
},
],
};
(window as any).__timelines = {
main: masterTimeline,
};
const result = collectRuntimeTimelinePayload(defaultParams);
const clip = result.clips.find((c) => c.id === "scene-1");
expect(clip).toBeDefined();
expect(clip?.start).toBe(4);
expect(clip?.duration).toBe(3);
});
it("keeps nested GSAP timeline offsets below the master timeline", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.id = "scene-1";
root.appendChild(scene);
const masterTimeline = {
duration: () => 20,
time: () => 1.25,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
startTime: () => 1.25,
getChildren: () => [] as unknown[],
};
const nestedTimeline = {
startTime: () => 6,
parent: masterTimeline,
};
const nestedTween = {
targets: () => [scene],
startTime: () => 2,
duration: () => 3,
parent: nestedTimeline,
};
masterTimeline.getChildren = () => [nestedTween];
(window as any).__timelines = {
main: masterTimeline,
};
const result = collectRuntimeTimelinePayload(defaultParams);
const clip = result.clips.find((c) => c.id === "scene-1");
expect(clip).toBeDefined();
expect(clip?.start).toBe(8);
expect(clip?.duration).toBe(3);
});
it("bubbles child tween ranges up to scene-level ancestors", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+3 -3
View File
@@ -416,7 +416,7 @@ export function collectRuntimeTimelinePayload(params: {
targets?: () => Element[];
startTime?: () => number;
duration?: () => number;
parent?: { startTime?: () => number };
parent?: GsapTween;
};
const tlWithChildren = masterTimeline as typeof masterTimeline & {
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => GsapTween[];
@@ -452,9 +452,9 @@ export function collectRuntimeTimelinePayload(params: {
continue;
let tweenStart = tween.startTime();
let parent = tween.parent;
while (parent && typeof parent.startTime === "function") {
while (parent && parent !== masterTimeline && typeof parent.startTime === "function") {
tweenStart += parent.startTime();
parent = (parent as GsapTween).parent;
parent = parent.parent;
}
const tweenEnd = tweenStart + tween.duration();
if (!Number.isFinite(tweenStart) || !Number.isFinite(tweenEnd)) continue;
@@ -255,6 +255,13 @@ export interface ElementStackingInfo {
* Falls back to the CSS default `"50% 50%"` (center) when unset.
*/
objectPosition: string;
/**
* Clip rect from the nearest ancestor with `overflow: hidden` (or
* `clip`/`clip-path`). When set, the HDR compositor must scissor the
* element's blit to this viewport-relative rectangle. `null` means no
* clipping ancestor was found — render at full element bounds.
*/
clipRect: { x: number; y: number; width: number; height: number } | null;
}
/**
@@ -357,6 +364,39 @@ export async function queryElementStacking(
return [0, 0, 0, 0];
}
// Walk ancestors to find the tightest overflow:hidden clip rect.
// Returns null if no clipping ancestor exists.
function getClipRect(
node: Element,
): { x: number; y: number; width: number; height: number } | null {
let current: Element | null = node.parentElement;
let clip: { x: number; y: number; width: number; height: number } | null = null;
while (current) {
const cs = window.getComputedStyle(current);
if (cs.overflow === "hidden" || cs.overflow === "clip") {
const r = current.getBoundingClientRect();
const ancestor = {
x: Math.round(r.x),
y: Math.round(r.y),
width: Math.round(r.width),
height: Math.round(r.height),
};
if (!clip) {
clip = ancestor;
} else {
// Intersect with existing clip
const x1 = Math.max(clip.x, ancestor.x);
const y1 = Math.max(clip.y, ancestor.y);
const x2 = Math.min(clip.x + clip.width, ancestor.x + ancestor.width);
const y2 = Math.min(clip.y + clip.height, ancestor.y + ancestor.height);
clip = { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
}
}
current = current.parentElement;
}
return clip;
}
// Walk up the DOM multiplying each ancestor's opacity. GSAP animates
// opacity on wrapper divs, not directly on the video element, so the
// element's own opacity is often 1.0. Multiplying ancestors gives the
@@ -472,6 +512,7 @@ export async function queryElementStacking(
// can rely on a populated value.
objectFit: style.objectFit || "fill",
objectPosition: style.objectPosition || "50% 50%",
clipRect: isHdrEl ? getClipRect(el) : null,
});
}
return results;
@@ -775,6 +775,25 @@ describe("blitRgb48leRegion", () => {
expect(canvas.readUInt16LE(0)).toBe(20000);
});
it("blends opacity over existing destination pixels", () => {
const canvas = makeHdrFrame(2, 1, 10000, 20000, 30000);
const source = makeHdrFrame(2, 1, 50000, 10000, 60000);
blitRgb48leRegion(canvas, source, 0, 0, 2, 1, 2, 1, 0.25);
expect(canvas.readUInt16LE(0)).toBe(20000);
expect(canvas.readUInt16LE(2)).toBe(17500);
expect(canvas.readUInt16LE(4)).toBe(37500);
expect(canvas.readUInt16LE(6)).toBe(20000);
});
it("skips exact-zero opacity without mutating the destination", () => {
const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
const source = makeHdrFrame(1, 1, 50000, 50000, 50000);
blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0);
expect(canvas.readUInt16LE(0)).toBe(10000);
expect(canvas.readUInt16LE(2)).toBe(20000);
expect(canvas.readUInt16LE(4)).toBe(30000);
});
it("no-op for zero-size region", () => {
const canvas = Buffer.alloc(4 * 4 * 6);
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
+29
View File
@@ -423,6 +423,7 @@ export function blitRgb48leRegion(
if (sw <= 0 || sh <= 0) return;
const op = opacity ?? 1.0;
if (op <= 0) return;
const x0 = Math.max(0, dx);
const y0 = Math.max(0, dy);
@@ -442,6 +443,33 @@ export function blitRgb48leRegion(
const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6;
source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6);
}
} else if (!hasMask) {
const invOp = 1 - op;
for (let y = 0; y < y1 - y0; y++) {
let srcOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
let dstOff = ((y0 + y) * canvasWidth + x0) * 6;
for (let x = 0; x < clippedW; x++) {
const sr = source[srcOff]! | (source[srcOff + 1]! << 8);
const sg = source[srcOff + 2]! | (source[srcOff + 3]! << 8);
const sb = source[srcOff + 4]! | (source[srcOff + 5]! << 8);
const dr = canvas[dstOff]! | (canvas[dstOff + 1]! << 8);
const dg = canvas[dstOff + 2]! | (canvas[dstOff + 3]! << 8);
const db = canvas[dstOff + 4]! | (canvas[dstOff + 5]! << 8);
const r = (sr * op + dr * invOp + 0.5) | 0;
const g = (sg * op + dg * invOp + 0.5) | 0;
const b = (sb * op + db * invOp + 0.5) | 0;
canvas[dstOff] = r & 0xff;
canvas[dstOff + 1] = r >>> 8;
canvas[dstOff + 2] = g & 0xff;
canvas[dstOff + 3] = g >>> 8;
canvas[dstOff + 4] = b & 0xff;
canvas[dstOff + 5] = b >>> 8;
srcOff += 6;
dstOff += 6;
}
}
} else {
for (let y = 0; y < y1 - y0; y++) {
for (let x = 0; x < clippedW; x++) {
@@ -528,6 +556,7 @@ export function blitRgb48leAffine(
const invTy = -(invB * tx + invD * ty);
const op = opacity ?? 1.0;
if (op <= 0) return;
const hasMask = borderRadius !== undefined;
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { groupIntoLayers } from "./layerCompositor.js";
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
function makeEl(
id: string,
zIndex: number,
isHdr: boolean,
overrides?: Partial<ElementStackingInfo>,
): ElementStackingInfo {
return {
id,
zIndex,
x: 0,
y: 0,
width: 1920,
height: 1080,
layoutWidth: 1920,
layoutHeight: 1080,
opacity: 1,
visible: true,
isHdr,
transform: "none",
borderRadius: [0, 0, 0, 0],
objectFit: "cover",
objectPosition: "50% 50%",
clipRect: null,
...overrides,
};
}
describe("HDR compositing — opacity filtering", () => {
it("zero-opacity elements remain in groupIntoLayers for hide-list correctness", () => {
const elements = [
makeEl("bg", 0, false),
makeEl("v-hdr", 1, true),
makeEl("overlay", 2, false, { opacity: 0 }),
];
// Elements stay in layers for correct DOM screenshot hide-lists.
// The compositor skips zero-opacity HDR layers during blit.
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(3);
expect(layers[0]!.type).toBe("dom");
expect(layers[1]!.type).toBe("hdr");
expect(layers[2]!.type).toBe("dom");
});
it("zero-opacity HDR element should be skipped during blit", () => {
const el = makeEl("v-hdr", 1, true, { opacity: 0 });
// The compositor checks: if (layer.element.opacity <= 0) continue;
expect(el.opacity).toBe(0);
expect(el.opacity <= 0).toBe(true);
});
it("low but non-zero opacity HDR elements are NOT skipped", () => {
const el = makeEl("v-hdr", 1, true, { opacity: 0.1 });
expect(el.opacity > 0).toBe(true);
});
it("child data-start element with parent opacity 0 has effective opacity 0", () => {
const childOverlay = makeEl("s6-text-wrap", 10, false, { opacity: 0 });
expect(childOverlay.opacity).toBe(0);
});
it("DOM overlay above HDR video is in a separate layer when both visible", () => {
const elements = [makeEl("bg", 0, false), makeEl("v-hdr", 1, true), makeEl("badge", 10, false)];
const layers = groupIntoLayers(elements);
expect(layers).toHaveLength(3);
expect(layers[0]!.type).toBe("dom");
expect(layers[1]!.type).toBe("hdr");
expect(layers[2]!.type).toBe("dom");
if (layers[2]!.type === "dom") {
expect(layers[2]!.elementIds).toEqual(["badge"]);
}
});
});
describe("HDR compositing — clip rect", () => {
it("clipRect is null when no overflow:hidden ancestor", () => {
const el = makeEl("video", 0, true);
expect(el.clipRect).toBeNull();
});
it("clipRect constrains element bounds for split-screen", () => {
const el = makeEl("video-left", 0, true, {
x: 0,
y: 0,
width: 1920,
height: 1080,
clipRect: { x: 0, y: 0, width: 960, height: 1080 },
});
const cr = el.clipRect!;
const cx1 = Math.max(el.x, cr.x);
const cy1 = Math.max(el.y, cr.y);
const cx2 = Math.min(el.x + el.width, cr.x + cr.width);
const cy2 = Math.min(el.y + el.height, cr.y + cr.height);
expect(cx2 - cx1).toBe(960);
expect(cy2 - cy1).toBe(1080);
});
it("fully clipped element produces zero-size intersection", () => {
const el = makeEl("offscreen", 0, true, {
x: 1000,
y: 0,
width: 920,
height: 1080,
clipRect: { x: 0, y: 0, width: 960, height: 1080 },
});
const cr = el.clipRect!;
const cx2 = Math.min(el.x + el.width, cr.x + cr.width);
const cx1 = Math.max(el.x, cr.x);
expect(Math.max(0, cx2 - cx1)).toBe(0);
});
it("right-half clip produces correct source crop offset", () => {
const el = makeEl("video-right", 0, true, {
x: 960,
y: 0,
width: 1920,
height: 1080,
clipRect: { x: 960, y: 0, width: 960, height: 1080 },
});
const cr = el.clipRect!;
const cx1 = Math.max(el.x, cr.x);
const blitSrcX = cx1 - el.x;
expect(blitSrcX).toBe(0);
const blitW = Math.min(el.x + el.width, cr.x + cr.width) - cx1;
expect(blitW).toBe(960);
});
});
@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
import { groupIntoLayers } from "./layerCompositor.js";
import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo {
function makeEl(
id: string,
zIndex: number,
isHdr: boolean,
overrides?: Partial<ElementStackingInfo>,
): ElementStackingInfo {
return {
id,
zIndex,
@@ -17,6 +22,10 @@ function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo
isHdr,
transform: "none",
borderRadius: [0, 0, 0, 0],
objectFit: "cover",
objectPosition: "50% 50%",
clipRect: null,
...overrides,
};
}
@@ -494,6 +494,7 @@ describe("template-wrapped sub-composition media offsets", () => {
function writeTemplateWrappedProject(
hostAttrs: string,
mediaAttrs: string = 'data-start="0" data-duration="4"',
extraMediaMarkup: string = "",
): {
projectDir: string;
indexPath: string;
@@ -547,6 +548,7 @@ describe("template-wrapped sub-composition media offsets", () => {
${mediaAttrs}
data-track-index="0"
></video>
${extraMediaMarkup}
<script>
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = { duration: () => 4 };
@@ -630,6 +632,30 @@ describe("template-wrapped sub-composition media offsets", () => {
});
});
it("includes explicit audio from template-wrapped sub-compositions", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="5" data-duration="6" data-width="640" data-height="360"',
'data-start="1" data-duration="4"',
`<audio
id="scene-audio"
src="../assets/narration.wav"
data-start="2"
data-duration="3"
data-track-index="1"
></audio>`,
);
const compiled = await compileForRender(projectDir, indexPath, projectDir);
expect(compiled.audios).toContainEqual(
expect.objectContaining({
id: "scene-audio",
start: 7,
end: 10,
}),
);
});
it("flattens the sub-composition root onto the host in compiled render HTML", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="20" data-duration="6" data-width="640" data-height="360"',
@@ -18,6 +18,9 @@ import {
mkdirSync,
rmSync,
readFileSync,
openSync,
readSync,
closeSync,
readdirSync,
statSync,
writeFileSync,
@@ -103,7 +106,6 @@ import {
} from "./htmlCompiler.js";
import { defaultLogger, type ProducerLogger } from "../logger.js";
import { isPathInside } from "../utils/paths.js";
import { clearMaxFrameIndex, getMaxFrameIndex } from "./frameDirCache.js";
import {
type HdrImageTransferCache,
createHdrImageTransferCache,
@@ -310,6 +312,7 @@ export interface RenderPerfSummary {
*/
peakHeapUsedMb?: number;
hdrDiagnostics?: HdrDiagnostics;
hdrPerf?: HdrPerfSummary;
}
export interface HdrDiagnostics {
@@ -317,6 +320,144 @@ export interface HdrDiagnostics {
imageDecodeFailures: number;
}
export interface HdrPerfSummary {
frames: number;
normalFrames: number;
transitionFrames: number;
domLayerCaptures: number;
hdrVideoLayerBlits: number;
hdrImageLayerBlits: number;
timings: Record<string, number>;
avgMs: Record<string, number>;
}
type HdrPerfTimingKey =
| "frameSeekMs"
| "frameInjectMs"
| "stackingQueryMs"
| "canvasClearMs"
| "normalCompositeMs"
| "transitionCompositeMs"
| "encoderWriteMs"
| "hdrVideoReadDecodeMs"
| "hdrVideoTransferMs"
| "hdrVideoBlitMs"
| "hdrImageTransferMs"
| "hdrImageBlitMs"
| "domLayerSeekMs"
| "domLayerInjectMs"
| "domMaskApplyMs"
| "domScreenshotMs"
| "domMaskRemoveMs"
| "domPngDecodeMs"
| "domBlitMs";
interface HdrPerfCollector {
frames: number;
normalFrames: number;
transitionFrames: number;
domLayerCaptures: number;
hdrVideoLayerBlits: number;
hdrImageLayerBlits: number;
timings: Record<HdrPerfTimingKey, number>;
}
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,
},
};
}
function addHdrTiming(perf: HdrPerfCollector | undefined, key: HdrPerfTimingKey, startMs: number) {
if (!perf) return;
perf.timings[key] += Date.now() - startMs;
}
function averageTiming(totalMs: number, count: number): number {
return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
}
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,
};
}
export interface CaptureCostEstimate {
multiplier: number;
reasons: string[];
@@ -914,53 +1055,111 @@ async function executeDiskCaptureWithAdaptiveRetry(options: {
}
}
/**
* 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 BPP = 6;
const dst = Buffer.alloc(cropW * cropH * BPP);
for (let row = 0; row < cropH; row++) {
const srcRow = cropY + row;
if (srcRow < 0 || srcRow >= srcH) continue;
const srcOff = (srcRow * srcW + cropX) * BPP;
const dstOff = row * cropW * BPP;
const copyLen = Math.min(cropW, srcW - cropX) * BPP;
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, fallback, decode, transform, and blit logic.
* the frame lookup, raw read, transfer, transform, and blit logic.
*/
interface HdrVideoFrameSource {
dir: string;
rawPath: string;
fd: number;
width: number;
height: number;
frameSize: number;
frameCount: number;
scratch: Buffer;
}
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),
});
}
}
function blitHdrVideoLayer(
canvas: Buffer,
el: ElementStackingInfo,
time: number,
fps: number,
hdrFrameDirs: Map<string, string>,
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>,
hdrStartTimes: Map<string, number>,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const frameDir = hdrFrameDirs.get(el.id);
const frameSource = hdrVideoFrameSources.get(el.id);
const startTime = hdrStartTimes.get(el.id);
if (!frameDir || startTime === undefined) {
if (!frameSource || startTime === undefined || el.opacity <= 0) {
return;
}
// Frame index within the video (1-based for FFmpeg image2 output).
// Clamp against the highest extracted frame in the directory so that when
// the composition outlives the source clip we freeze on the last frame
// (matching Chrome's <video> behavior) without issuing an O(N) iterative
// existsSync sweep per requested time.
// 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 maxIndex = getMaxFrameIndex(frameDir);
const effectiveIndex = maxIndex > 0 ? Math.min(videoFrameIndex, maxIndex) : videoFrameIndex;
const framePath = join(frameDir, `frame_${String(effectiveIndex).padStart(4, "0")}.png`);
if (!existsSync(framePath)) {
return;
}
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
if (effectiveIndex < 1) return;
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
try {
const { data: hdrRgb, width: srcW, height: srcH } = decodePngToRgb48le(readFileSync(framePath));
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;
let timingStart = Date.now();
const bytesRead = 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;
addHdrTiming(hdrPerf, "hdrVideoReadDecodeMs", timingStart);
// Convert between HDR transfer functions if source doesn't match output
if (sourceTransfer && targetTransfer && sourceTransfer !== targetTransfer) {
timingStart = Date.now();
convertTransfer(hdrRgb, sourceTransfer, targetTransfer);
addHdrTiming(hdrPerf, "hdrVideoTransferMs", timingStart);
}
const viewportMatrix = parseTransformMatrix(el.transform);
@@ -970,8 +1169,57 @@ function blitHdrVideoLayer(
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
if (viewportMatrix) {
// Use the full viewport transform (handles scale, rotation, translate)
// Apply ancestor overflow:hidden clip rect by constraining the blit
// bounds. For the no-transform (region) path, we crop the source
// image and adjust the destination position. For the affine path,
// clip rect support is not yet implemented (would require per-pixel
// scissor in the affine blit); log a warning and skip clipping.
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; // fully clipped
blitSrcX = cx1 - blitX;
blitSrcY = cy1 - blitY;
blitW = cx2 - cx1;
blitH = cy2 - cy1;
blitX = cx1;
blitY = cy1;
clipped = true;
}
// Detect translation-only matrix (no scale/rotation) — route through the
// region path which supports clip rects. Chrome reports a viewport matrix
// for all HDR elements, even untransformed ones or those with only layout
// translation (e.g. `left: 960px` → `matrix(1,0,0,1,960,0)`). The region
// blit handles translation via el.x/el.y, so we only need the affine path
// for actual scale/rotation transforms.
// parseTransformMatrix returns a 6-element array or null — length check unnecessary.
const isTranslationOnly = !!(
viewportMatrix &&
Math.abs(viewportMatrix[0]! - 1) < 0.001 &&
Math.abs(viewportMatrix[1]!) < 0.001 &&
Math.abs(viewportMatrix[2]!) < 0.001 &&
Math.abs(viewportMatrix[3]! - 1) < 0.001
);
timingStart = Date.now();
if (viewportMatrix && !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,
@@ -983,8 +1231,22 @@ function blitHdrVideoLayer(
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else if (clipped) {
// Crop the source buffer to the clipped region before blitting
const croppedBuf = cropRgb48le(hdrRgb, srcW, srcH, blitSrcX, blitSrcY, blitW, blitH);
blitRgb48leRegion(
canvas,
croppedBuf,
blitX,
blitY,
blitW,
blitH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else {
// No transform — identity position, use fast region blit
blitRgb48leRegion(
canvas,
hdrRgb,
@@ -998,6 +1260,7 @@ function blitHdrVideoLayer(
borderRadiusParam,
);
}
addHdrTiming(hdrPerf, "hdrVideoBlitMs", timingStart);
} catch (err) {
if (log) {
log.debug(`HDR blit failed for ${el.id}`, {
@@ -1035,20 +1298,27 @@ function blitHdrImageLayer(
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const buf = hdrImageBuffers.get(el.id);
if (!buf) {
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.
let timingStart = Date.now();
const hdrRgb =
sourceTransfer && targetTransfer
? hdrImageTransferCache.getConverted(el.id, sourceTransfer, targetTransfer, buf.data)
: buf.data;
addHdrTiming(hdrPerf, "hdrImageTransferMs", timingStart);
const viewportMatrix = parseTransformMatrix(el.transform);
@@ -1056,6 +1326,7 @@ function blitHdrImageLayer(
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
timingStart = Date.now();
if (viewportMatrix) {
blitRgb48leAffine(
canvas,
@@ -1082,6 +1353,7 @@ function blitHdrImageLayer(
borderRadiusParam,
);
}
addHdrTiming(hdrPerf, "hdrImageBlitMs", timingStart);
} catch (err) {
if (log) {
log.debug(`HDR image blit failed for ${el.id}`, {
@@ -1110,12 +1382,13 @@ interface HdrCompositeContext {
nativeHdrImageIds: Set<string>;
hdrImageBuffers: Map<string, HdrImageBuffer>;
hdrImageTransferCache: HdrImageTransferCache;
hdrFrameDirs: Map<string, string>;
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>;
hdrVideoStartTimes: Map<string, number>;
imageTransfers: Map<string, HdrTransfer>;
videoTransfers: Map<string, HdrTransfer>;
debugDumpEnabled: boolean;
debugDumpDir: string | null;
hdrPerf?: HdrPerfCollector;
}
/**
@@ -1164,18 +1437,22 @@ async function compositeHdrFrame(
nativeHdrImageIds,
hdrImageBuffers,
hdrImageTransferCache,
hdrFrameDirs,
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 shouldLog = debugDumpEnabled && debugFrameIndex >= 0;
@@ -1204,6 +1481,8 @@ async function compositeHdrFrame(
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);
if (isHdrImage) {
@@ -1217,6 +1496,7 @@ async function compositeHdrFrame(
log,
imageTransfers.get(layer.element.id),
effectiveHdr.transfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
@@ -1224,13 +1504,14 @@ async function compositeHdrFrame(
layer.element,
time,
fps,
hdrFrameDirs,
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(layer.element.id),
effectiveHdr.transfer,
hdrPerf,
);
}
if (shouldLog) {
@@ -1248,13 +1529,10 @@ async function compositeHdrFrame(
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
});
} else {
const frameDir = hdrFrameDirs.get(layer.element.id);
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;
const expectedFrame = frameDir
? join(frameDir, `frame_${String(frameNum).padStart(4, "0")}.png`)
: null;
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
@@ -1265,8 +1543,8 @@ async function compositeHdrFrame(
startTime,
localTime: localTime.toFixed(3),
hdrFrameNum: frameNum,
expectedFrame,
expectedFrameExists: expectedFrame ? existsSync(expectedFrame) : false,
rawPath: frameSource?.rawPath ?? null,
frameCount: frameSource?.frameCount ?? null,
});
}
}
@@ -1295,31 +1573,46 @@ async function compositeHdrFrame(
const allElementIds = fullStacking.map((e) => e.id);
const layerIds = new Set(layer.elementIds);
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
// 1. Seek GSAP to restore all animated properties from clean state
let timingStart = Date.now();
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart);
// 2. Run frame injector to set correct SDR video visibility
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
}
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
timingStart = Date.now();
await applyDomLayerMask(domSession.page, layer.elementIds, hideIds);
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart);
// 4. Screenshot
timingStart = Date.now();
const domPng = await captureAlphaPng(domSession.page, width, height);
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart);
// 5. Tear down the mask
timingStart = Date.now();
await removeDomLayerMask(domSession.page, hideIds);
addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart);
try {
timingStart = Date.now();
const { data: domRgba } = decodePng(domPng);
addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart);
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
timingStart = Date.now();
blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer);
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
if (shouldLog && debugDumpDir) {
const after = countNonZeroRgb48(canvas);
const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`;
@@ -1442,6 +1735,7 @@ export async function executeRenderJob(
videoExtractionFailures: 0,
imageDecodeFailures: 0,
};
let hdrPerf: HdrPerfCollector | undefined;
const perfOutputPath = join(workDir, "perf-summary.json");
const cfg = { ...(job.config.producerConfig ?? resolveConfig()) };
const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov" | "png-sequence";
@@ -2220,6 +2514,7 @@ export async function executeRenderJob(
// with sRGB→HLG conversion. Video position/opacity applied via queried bounds.
if (hasHdrContent) {
log.info("[Render] HDR layered composite: z-ordered DOM + native HLG video layers");
hdrPerf = createHdrPerfCollector();
// HDR layered compositing relies on captureAlphaPng (Page.captureScreenshot
// with a transparent background) for the SDR DOM overlay layer. That CDP
@@ -2279,10 +2574,9 @@ export async function executeRenderJob(
let hdrEncoder: StreamingEncoder | null = null;
let hdrEncoderClosed = false;
let domSessionClosed = false;
// Track HDR video frame directories at this scope so the outer finally
// can clear their entries from the shared frameDirCache module.
// Without this, the cache leaks one entry per HDR video per render.
const hdrFrameDirs = new Map<string, string>();
// Open raw HDR frame files at this scope so cleanup can close descriptors
// on both success and early failure paths.
const hdrVideoFrameSources = new Map<string, HdrVideoFrameSource>();
try {
await initializeSession(domSession);
assertNotAborted();
@@ -2445,8 +2739,8 @@ export async function executeRenderJob(
}
// ── Pre-extract all HDR video frames in a single FFmpeg pass ──────
// hdrFrameDirs is declared above the try block so the outer finally
// can clear matching frameDirCache entries on any exit path.
// Use raw rgb48le instead of PNG sequences so the hot loop can read a
// fixed byte range per frame and skip PNG decode entirely.
for (const [videoId, srcPath] of hdrVideoSrcPaths) {
const video = composition.videos.find((v) => v.id === videoId);
if (!video) continue;
@@ -2454,6 +2748,7 @@ export async function executeRenderJob(
mkdirSync(frameDir, { recursive: true });
const duration = video.end - video.start;
const dims = hdrExtractionDims.get(videoId) ?? { width, height };
const rawPath = join(frameDir, "frames.rgb48le");
const ffmpegArgs = [
"-ss",
String(video.mediaStart),
@@ -2467,10 +2762,10 @@ export async function executeRenderJob(
`scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`,
"-pix_fmt",
"rgb48le",
"-c:v",
"png",
"-f",
"rawvideo",
"-y",
join(frameDir, "frame_%04d.png"),
rawPath,
];
const result = await runFfmpeg(ffmpegArgs, { signal: abortSignal });
if (!result.success) {
@@ -2485,7 +2780,25 @@ export async function executeRenderJob(
`Aborting render to avoid shipping black HDR layers.`,
);
}
hdrFrameDirs.set(videoId, frameDir);
const frameSize = dims.width * dims.height * 6;
const frameCount = Math.floor(statSync(rawPath).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.`,
);
}
hdrVideoFrameSources.set(videoId, {
dir: frameDir,
rawPath,
fd: openSync(rawPath, "r"),
width: dims.width,
height: dims.height,
frameSize,
frameCount,
scratch: Buffer.allocUnsafe(frameSize),
});
}
// ── Pre-decode all HDR image buffers once ────────────────────────
@@ -2551,7 +2864,7 @@ export async function executeRenderJob(
// We call it manually since the HDR loop doesn't use captureFrame().
const beforeCaptureHook = domSession.onBeforeCapture;
// Track which HDR video frame directories have been cleaned up.
// Track which HDR video raw frame sources have been cleaned up.
// Once a video's last frame has been used (time > video.end), its
// extraction directory is deleted to free disk space. This prevents
// disk exhaustion on compositions with many HDR videos.
@@ -2559,7 +2872,7 @@ export async function executeRenderJob(
// Build a map of video end times for quick lookup
const hdrVideoEndTimes = new Map<string, number>();
for (const v of composition.videos) {
if (hdrFrameDirs.has(v.id)) {
if (hdrVideoFrameSources.has(v.id)) {
hdrVideoEndTimes.set(v.id, v.end);
}
}
@@ -2605,12 +2918,13 @@ export async function executeRenderJob(
nativeHdrImageIds,
hdrImageBuffers,
hdrImageTransferCache,
hdrFrameDirs,
hdrVideoFrameSources,
hdrVideoStartTimes,
imageTransfers,
videoTransfers,
debugDumpEnabled,
debugDumpDir,
hdrPerf,
};
// ── Pre-allocate transition buffers ─────────────────────────────────
@@ -2628,19 +2942,26 @@ export async function executeRenderJob(
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
if (hdrPerf) hdrPerf.frames += 1;
// Seek timeline
let timingStart = Date.now();
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
addHdrTiming(hdrPerf, "frameSeekMs", timingStart);
// Inject SDR video frames into the DOM
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "frameInjectMs", timingStart);
}
// Query ALL timed elements for z-order analysis
timingStart = Date.now();
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds);
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
// Find active transition for this frame (if any)
const activeTransition = transitionRanges.find(
@@ -2681,6 +3002,8 @@ export async function executeRenderJob(
}
if (activeTransition && transBufferA && transBufferB && transOutput) {
if (hdrPerf) hdrPerf.transitionFrames += 1;
const transitionTimingStart = Date.now();
// ── Transition frame: dual-scene compositing ──────────────────
const progress =
activeTransition.endFrame === activeTransition.startFrame
@@ -2693,8 +3016,10 @@ export async function executeRenderJob(
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
// Zero-fill scene buffers (transition function writes every output pixel)
timingStart = Date.now();
transBufferA.fill(0);
transBufferB.fill(0);
addHdrTiming(hdrPerf, "canvasClearMs", timingStart);
for (const [sceneBuf, sceneIds] of [
[transBufferA, sceneAIds],
@@ -2709,11 +3034,15 @@ export async function executeRenderJob(
// and discarded.
assertNotAborted();
// Fresh state: seek + inject
timingStart = Date.now();
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart);
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
}
// Blit all HDR videos/images for this scene
@@ -2730,6 +3059,7 @@ export async function executeRenderJob(
log,
imageTransfers.get(el.id),
effectiveHdr?.transfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
@@ -2737,13 +3067,14 @@ export async function executeRenderJob(
el,
time,
job.config.fps,
hdrFrameDirs,
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(el.id),
effectiveHdr?.transfer,
hdrPerf,
);
}
}
@@ -2759,18 +3090,28 @@ export async function executeRenderJob(
const hideIds = stackingInfo
.map((e) => e.id)
.filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id));
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
timingStart = Date.now();
await applyDomLayerMask(domSession.page, showIds, hideIds);
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart);
timingStart = Date.now();
const domPng = await captureAlphaPng(domSession.page, width, height);
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart);
timingStart = Date.now();
await removeDomLayerMask(domSession.page, hideIds);
addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart);
try {
timingStart = Date.now();
const { data: domRgba } = decodePng(domPng);
addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart);
// Invariant: `hasHdrVideo` requires `effectiveHdr` to be set (see line ~919).
if (!effectiveHdr) {
throw new Error(
"Invariant violation: effectiveHdr is undefined inside hasHdrVideo branch",
);
}
timingStart = Date.now();
blitRgba8OverRgb48le(
domRgba,
sceneBuf as Buffer,
@@ -2778,6 +3119,7 @@ export async function executeRenderJob(
height,
effectiveHdr.transfer,
);
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", {
frameIndex: i,
@@ -2794,11 +3136,18 @@ export async function executeRenderJob(
// for shader math since the shaders were designed for perceptual (sRGB) space.
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
transitionFn(transBufferA, transBufferB, transOutput, width, height, progress);
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
timingStart = Date.now();
hdrEncoder.writeFrame(transOutput);
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart);
} else {
if (hdrPerf) hdrPerf.normalFrames += 1;
// ── Normal frame: full layer composite (no transition) ─────────
timingStart = Date.now();
normalCanvas.fill(0);
addHdrTiming(hdrPerf, "canvasClearMs", timingStart);
timingStart = Date.now();
await compositeHdrFrame(
hdrCompositeCtx,
normalCanvas,
@@ -2807,6 +3156,7 @@ export async function executeRenderJob(
undefined,
i,
);
addHdrTiming(hdrPerf, "normalCompositeMs", timingStart);
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
const previewPath = join(
debugDumpDir,
@@ -2814,10 +3164,12 @@ export async function executeRenderJob(
);
writeFileSync(previewPath, normalCanvas);
}
timingStart = Date.now();
hdrEncoder.writeFrame(normalCanvas);
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart);
}
// Clean up HDR frame directories for videos that have ended.
// Clean up HDR raw frame sources for videos that have ended.
// Frees disk space during long renders with many HDR videos.
// Skip when KEEP_TEMP=1 so we can inspect intermediate state.
if (process.env.KEEP_TEMP !== "1") {
@@ -2829,23 +3181,20 @@ export async function executeRenderJob(
(sceneElements[activeTransition.fromScene]?.includes(videoId) ||
sceneElements[activeTransition.toScene]?.includes(videoId));
if (!stillNeeded) {
const frameDir = hdrFrameDirs.get(videoId);
if (frameDir) {
const frameSource = hdrVideoFrameSources.get(videoId);
if (frameSource) {
closeHdrVideoFrameSource(frameSource, log);
try {
rmSync(frameDir, { recursive: true, force: true });
rmSync(frameSource.dir, { recursive: true, force: true });
} catch (err) {
log.warn("Failed to clean up HDR frame directory", {
log.warn("Failed to clean up HDR raw frame directory", {
videoId,
frameDir,
frameDir: frameSource.dir,
rawPath: frameSource.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
// Drop the matching cache entry so we don't leak a stale
// max-frame-index reading for a directory that no longer
// exists. Without this, the shared cache grows
// monotonically across renders.
clearMaxFrameIndex(frameDir);
hdrFrameDirs.delete(videoId);
hdrVideoFrameSources.delete(videoId);
}
cleanedUpVideos.add(videoId);
}
@@ -2902,15 +3251,13 @@ export async function executeRenderJob(
});
});
}
// Drop frameDirCache entries for any HDR frame directories that
// survived the in-loop cleanup (early failures, KEEP_TEMP=1, videos
// still active when the render exits). The on-disk frames themselves
// are torn down with workDir; we just don't want the shared cache to
// leak entries across renders.
for (const frameDir of hdrFrameDirs.values()) {
clearMaxFrameIndex(frameDir);
// Close any raw frame files that survived in-loop cleanup (early
// failures, KEEP_TEMP=1, videos still active when the render exits).
// The on-disk frames themselves are torn down with workDir.
for (const frameSource of hdrVideoFrameSources.values()) {
closeHdrVideoFrameSource(frameSource, log);
}
hdrFrameDirs.clear();
hdrVideoFrameSources.clear();
}
} else // ── Standard capture paths (SDR or DOM-only HDR) ──────────────────
// Streaming encode mode: pipe frame buffers directly to FFmpeg stdin,
@@ -3321,6 +3668,7 @@ export async function executeRenderJob(
hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0
? { ...hdrDiagnostics }
: undefined,
hdrPerf: hdrPerf ? finalizeHdrPerf(hdrPerf) : undefined,
captureAvgMs:
totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : undefined,
peakRssMb: Math.round(peakRssBytes / (1024 * 1024)),
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { describe, expect, it, beforeEach } from "vitest";
import { resolveDimensions } from "./hyper-shader.js";
import { WIDTH, HEIGHT } from "./webgl.js";
function makeRoot(attrs: Record<string, string>): HTMLElement {
const el = document.createElement("div");
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
describe("resolveDimensions", () => {
beforeEach(() => {
// no DOM cleanup required — resolveDimensions is pure
});
it("falls back to defaults when there is no root element", () => {
expect(resolveDimensions(null)).toEqual({ width: WIDTH, height: HEIGHT });
});
it("falls back to defaults when the root has no data-width/height", () => {
const root = makeRoot({ "data-composition-id": "main" });
expect(resolveDimensions(root)).toEqual({ width: WIDTH, height: HEIGHT });
});
it("picks up data-width and data-height from the root element", () => {
const root = makeRoot({
"data-composition-id": "main",
"data-width": "1080",
"data-height": "1920",
});
expect(resolveDimensions(root)).toEqual({ width: 1080, height: 1920 });
});
it("prefers explicit config overrides over the root attributes", () => {
const root = makeRoot({ "data-width": "1080", "data-height": "1920" });
expect(resolveDimensions(root, 2160, 3840)).toEqual({ width: 2160, height: 3840 });
});
it("allows partial overrides — width from config, height from root", () => {
const root = makeRoot({ "data-width": "1000", "data-height": "2000" });
expect(resolveDimensions(root, 500, undefined)).toEqual({ width: 500, height: 2000 });
expect(resolveDimensions(root, undefined, 3000)).toEqual({ width: 1000, height: 3000 });
});
it("ignores non-finite or non-positive attribute values and uses defaults", () => {
const bad = makeRoot({ "data-width": "not-a-number", "data-height": "-10" });
expect(resolveDimensions(bad)).toEqual({ width: WIDTH, height: HEIGHT });
});
it("ignores an explicit zero override and uses the next source in the chain", () => {
// Number(0) is falsy so nullish-coalescing correctly keeps the next source.
// This test documents the chosen behaviour: zero → fall through, not error.
const root = makeRoot({ "data-width": "800", "data-height": "600" });
expect(resolveDimensions(root, 0, 0)).toEqual({ width: 0, height: 0 });
// The zero is passed through via `??`; callers that want to defer to the
// root attributes should omit the override rather than pass 0.
});
it("portrait dimensions round-trip exactly", () => {
const root = makeRoot({ "data-width": "1080", "data-height": "1920" });
const dims = resolveDimensions(root);
expect(dims.width).toBe(1080);
expect(dims.height).toBe(1920);
expect(dims.width).toBeLessThan(dims.height);
});
it("handles square dimensions", () => {
const root = makeRoot({ "data-width": "1440", "data-height": "1440" });
expect(resolveDimensions(root)).toEqual({ width: 1440, height: 1440 });
});
});
@@ -1,10 +1,12 @@
import { describe, it, expect } from "vitest";
import {
formatTimelineTickLabel,
generateTicks,
getDefaultDroppedTrack,
getTimelineCanvasHeight,
resolveTimelineAssetDrop,
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomAnchor,
getTimelineScrollLeftForZoomTransition,
shouldShowTimelineShortcutHint,
shouldHandleTimelineDeleteKey,
@@ -79,6 +81,20 @@ describe("generateTicks", () => {
expect(major[0]).toBe(0);
}
});
it("uses denser major labels as timeline zoom increases", () => {
const fitTicks = generateTicks(180, 10);
const zoomedTicks = generateTicks(180, 48);
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
expect(zoomedTicks.minor).toContain(1);
expect(zoomedTicks.minor).toContain(4);
});
it("keeps labels readable instead of placing one at every tiny tick", () => {
const { major } = generateTicks(180, 80);
expect(major[1] - major[0]).toBe(2);
});
});
describe("formatTime", () => {
@@ -119,6 +135,20 @@ describe("formatTime", () => {
});
});
describe("formatTimelineTickLabel", () => {
it("uses minute-second labels for normal timeline intervals", () => {
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
});
it("uses hour labels for long timelines", () => {
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
});
it("shows subsecond labels when the major ruler interval is below one second", () => {
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
});
});
describe("shouldAutoScrollTimeline", () => {
it("never auto-scrolls in fit mode", () => {
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
@@ -145,6 +175,48 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
});
});
describe("getTimelineScrollLeftForZoomAnchor", () => {
it("preserves the time under the pointer when zooming in", () => {
expect(
getTimelineScrollLeftForZoomAnchor({
pointerX: 300,
currentScrollLeft: 200,
gutter: 32,
currentPixelsPerSecond: 10,
nextPixelsPerSecond: 20,
duration: 120,
}),
).toBe(668);
});
it("clamps negative scroll targets", () => {
expect(
getTimelineScrollLeftForZoomAnchor({
pointerX: 300,
currentScrollLeft: 0,
gutter: 32,
currentPixelsPerSecond: 20,
nextPixelsPerSecond: 5,
duration: 120,
}),
).toBe(0);
});
it("preserves current scroll when inputs are invalid", () => {
expect(
getTimelineScrollLeftForZoomAnchor({
pointerX: 300,
currentScrollLeft: 120,
gutter: 32,
currentPixelsPerSecond: 0,
nextPixelsPerSecond: 20,
duration: 120,
}),
).toBe(120);
});
});
describe("getTimelinePlayheadLeft", () => {
it("converts time to a pixel offset from the gutter", () => {
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
@@ -26,7 +26,7 @@ import {
type TimelineTrackStyle,
type TimelineTheme,
} from "./timelineTheme";
import { getTimelinePixelsPerSecond } from "./timelineZoom";
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
/* ── Layout ─────────────────────────────────────────────────────── */
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
}
/* ── Tick Generation ────────────────────────────────────────────── */
export function generateTicks(duration: number): { major: number[]; minor: number[] } {
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 128;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
}
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
return durationIntervals.find((interval) => interval >= target) ?? 60;
}
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
let interval = majorInterval / 2;
if (majorInterval >= 30) interval = majorInterval / 6;
else if (majorInterval >= 15) interval = majorInterval / 3;
else if (majorInterval >= 5) interval = majorInterval / 5;
else if (majorInterval >= 1) interval = majorInterval / 4;
if (
Number.isFinite(pixelsPerSecond) &&
(pixelsPerSecond ?? 0) > 0 &&
interval * (pixelsPerSecond ?? 0) < 20
) {
return Math.max(0.25, majorInterval / 2);
}
return Math.max(0.25, interval);
}
export function generateTicks(
duration: number,
pixelsPerSecond?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
return { major: [], minor: [] };
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
const majorInterval = intervals.find((i) => i >= target) ?? 60;
const minorInterval = Math.max(0.25, majorInterval / 2);
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 500; // Safety cap to prevent infinite loop
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (
let t = 0;
t <= duration + 0.001 && major.length + minor.length < maxTicks;
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
return { major, minor };
}
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
if (!Number.isFinite(time)) return "0:00";
const safeTime = Math.max(0, time);
if (majorInterval < 1) {
const totalTenths = Math.round(safeTime * 10);
const wholeSeconds = Math.floor(totalTenths / 10);
const tenth = totalTenths % 10;
return `${formatTime(wholeSeconds)}.${tenth}`;
}
if (duration >= 3600 || safeTime >= 3600) {
const totalSeconds = Math.floor(safeTime);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return formatTime(safeTime);
}
export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
@@ -131,6 +181,32 @@ export function getTimelineScrollLeftForZoomTransition(
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
return currentScrollLeft;
}
export function getTimelineScrollLeftForZoomAnchor(input: {
pointerX: number;
currentScrollLeft: number;
gutter: number;
currentPixelsPerSecond: number;
nextPixelsPerSecond: number;
duration: number;
}): number {
const currentPps = Math.max(0, input.currentPixelsPerSecond);
const nextPps = Math.max(0, input.nextPixelsPerSecond);
if (
!Number.isFinite(input.pointerX) ||
!Number.isFinite(input.currentScrollLeft) ||
!Number.isFinite(input.duration) ||
input.duration <= 0 ||
currentPps <= 0 ||
nextPps <= 0
) {
return Math.max(0, input.currentScrollLeft);
}
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
}
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
@@ -312,6 +388,8 @@ export const Timeline = memo(function Timeline({
const currentTime = usePlayerStore((s) => s.currentTime);
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
const playheadRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -463,7 +541,11 @@ export const Timeline = memo(function Timeline({
const trackContentWidth = Math.max(0, effectiveDuration * pps);
const zoomModeRef = useRef(zoomMode);
zoomModeRef.current = zoomMode;
const manualZoomPercentRef = useRef(manualZoomPercent);
manualZoomPercentRef.current = manualZoomPercent;
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
const fitPpsRef = useRef(fitPps);
fitPpsRef.current = fitPps;
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
@@ -952,11 +1034,15 @@ export const Timeline = memo(function Timeline({
cancelAnimationFrame(dragScrollRaf.current);
}, []);
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
const { major, minor } = useMemo(
() => generateTicks(effectiveDuration, pps),
[effectiveDuration, pps],
);
const majorTickInterval =
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
useEffect(() => {
syncShortcutHintVisibility();
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (resizingClip?.element.id === element.id) {
@@ -1042,6 +1128,57 @@ export const Timeline = memo(function Timeline({
[onAssetDrop, onFileDrop],
);
const handlePinchWheel = useCallback(
(e: WheelEvent) => {
if (!e.ctrlKey) return;
const scroll = scrollRef.current;
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
return;
}
e.preventDefault();
e.stopPropagation();
const rect = scroll.getBoundingClientRect();
const pointerX = e.clientX - rect.left;
const nextZoomPercent = getPinchTimelineZoomPercent(
e.deltaY,
zoomModeRef.current,
manualZoomPercentRef.current,
);
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
return;
}
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
pointerX,
currentScrollLeft: scroll.scrollLeft,
gutter: GUTTER,
currentPixelsPerSecond: ppsRef.current,
nextPixelsPerSecond: nextPps,
duration: durationRef.current,
});
setZoomMode("manual");
setManualZoomPercent(nextZoomPercent);
requestAnimationFrame(() => {
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
});
},
[setManualZoomPercent, setZoomMode],
);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
return () => {
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
};
}, [handlePinchWheel, timelineReady, elements.length]);
if (!timelineReady || elements.length === 0) {
return (
<div
@@ -1272,7 +1409,7 @@ export const Timeline = memo(function Timeline({
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
style={{ color: theme.tickText }}
>
{formatTime(t)}
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
</div>
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
clampTimelineZoomPercent,
getNextTimelineZoomPercent,
getPinchTimelineZoomPercent,
getTimelinePixelsPerSecond,
getTimelineZoomPercent,
MAX_TIMELINE_ZOOM_PERCENT,
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
);
});
});
describe("getPinchTimelineZoomPercent", () => {
it("zooms in for upward pinch wheel deltas", () => {
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
});
it("zooms out for downward pinch wheel deltas", () => {
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
});
it("keeps the current zoom for zero or invalid deltas", () => {
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
});
it("clamps pinch zoom to the supported range", () => {
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
});
});
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
const ZOOM_OUT_FACTOR = 0.8;
const ZOOM_IN_FACTOR = 1.25;
const PINCH_ZOOM_SENSITIVITY = 0.0035;
export function clampTimelineZoomPercent(percent: number): number {
if (!Number.isFinite(percent)) return 100;
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
return clampTimelineZoomPercent(next);
}
export function getPinchTimelineZoomPercent(
deltaY: number,
zoomMode: ZoomMode,
manualZoomPercent: number,
): number {
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
}