mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
chore: merge main into next
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user