fix: harden CLI edge-case repros (#591)

## Problem

I reproduced the selected open issue batch one by one and confirmed the reports were valid. The fixes all touch the CLI/runtime capture boundary, then the follow-up regression run exposed one over-broad runtime change in sub-composition host visibility and one CI-only baseline trap.

Closes #590, #589, #588, #587, #586, and #584.

## What this fixes

### CLI/runtime edge cases

- Makes the GSAP infinite-repeat lint rule ignore JavaScript comments, so literal `repeat:-1` text in comments is not flagged.
- Lets the compositions CLI inspect `<template>` content, count visual-only template descendants, estimate simple GSAP durations, and suppress root `data-start` warnings in sub-composition lint mode.
- Preserves runtime bootstrap scripts when body scripts are coalesced, and injects the runtime into a real `<head>` when source HTML has no head.
- Keeps #589 fixed by loading and rendering template-wrapped sub-composition content, while restoring host visibility to the shorter of the authored parent clip window and the child composition live timeline.
- Resolves snapshot/validate viewport size from root `data-width` / `data-height` instead of falling back to 1920x1080.
- Skips fully off-frame text boxes during contrast sampling and bounds-checks ring samples so contrast output no longer emits `null:1` / `NaN:1`.
- Marks muted videos as `data-has-audio="false"` in the core timing compiler, which fixes the same-src muted `<video>` + separate `<audio>` StaticGuard case.
- Keeps user-authored `hf-seek` listeners reachable during capture by preventing author scripts from being merged into the runtime bootstrap path.

### Shared helper cleanup

- Removes the stale producer-local timing compiler duplicate; producer compilation now consumes the core timing compiler.
- Centralizes HTML document helpers in core: fragment parsing, embedded runtime stripping, head/body script injection, and early-head injection.
- Centralizes the CLI layout/snapshot static HTML server.
- Adds browser-safe core subpath helpers for Lottie readiness and CLI screenshot clip calculation; Studio's Vite config keeps the screenshot clip helper self-contained so clean-checkout test startup does not value-import core `.ts` source.
- Replaces the engine parity-contract copy with a core re-export.
- De-duplicates render-job cleanup and Studio static file-serving callbacks.

### Regression hardening

- Replaces the embedded-runtime script stripping regex with a script-tag scanner that handles closing tags like `</script >`.
- Escapes inline script bodies before wrapping them in `<script>` tags, so authored `</script` and `<!--` text cannot break out of the injected wrapper script.
- Shares media-duration clamping between core and producer, with a 50 ms tolerance for ffprobe precision drift between local and CI media stacks.
- Pins the affected style fixture SFX durations in source so style-1 and style-9 compile deterministically.
- Restores the `vfr-screen-recording` video golden to the CI-stable baseline; the current CI failure showed the Linux render matches the old golden, while the locally refreshed macOS golden was the mismatch.

## Root cause

The CLI paths had accumulated assumptions that held for simple direct-root landscape compositions but not for current composition patterns: DOM queries did not enter template content, snapshot/validate used a fixed viewport, runtime and author scripts shared a coalescing bucket, and timing compilation treated every video as audio-bearing unless authors manually overrode it.

The style shard failures were not product regressions. Local and CI media probing disagreed on the short SFX clip duration by about 45 ms, and the compiler was clamping authored durations to the locally probed value. The shared clamp tolerance preserves explicit author/source durations for small probe precision differences while still clamping real overflows.

The vfr fast-shard failure was a bad baseline refresh: CI actual frames matched the old `vfr-screen-recording` baseline at 40+ dB PSNR, but mismatched the macOS-refreshed golden at ~18-22 dB. The fix is to keep the Docker/Linux-stable video golden and only retain the deterministic compiled snapshot change.

The sub-composition regression came from treating a host's authored parent window as the only visibility boundary. That made settled child overlays stay visible after their own live GSAP timeline ended. The corrected runtime behavior respects both contracts: parent clips still bound where the host can appear, and the child live timeline can end the host earlier.

## Verification

### Local checks

- `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bun run --cwd packages/core test src/runtime/init.test.ts`
- `bun run --cwd packages/cli test src/commands/compositions.test.ts src/utils/compositionViewport.test.ts`
- `bun run build:hyperframes-runtime`
- `bun run --cwd packages/producer test --keep-temp --sequential style-12-prod style-5-prod`
- `bun run --cwd packages/producer test --sequential vfr-screen-recording hdr-hlg-regression style-7-prod`
- `bun run --cwd packages/core test src/compiler/htmlCompiler.test.ts src/compiler/timingCompiler.test.ts src/index.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bunx oxlint packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer test --sequential style-1-prod style-9-prod`
- `bun run --filter @hyperframes/studio test` with `packages/core/dist` temporarily hidden to simulate clean-checkout config loading
- `git diff --check`

### CI artifact checks

- Inspected failed run `25225854394` job `73969147096`: style-1 failed only on `click-sfx` `1.044898` vs `1` duration/end.
- Inspected failed run `25225854394` job `73969147061`: style-9 failed only on SFX `1.044898`-based duration/end mismatches.
- Inspected failed run `25225854394` job `73969147048`: `vfr-screen-recording` compilation/audio passed, visual failed after comparing against the macOS-refreshed golden.
- Compared the first 10 uploaded CI vfr failure frames against the restored old baseline; minimum PSNR was `40.444705`, above the fixture threshold of `28`.

### Repro checks

- `bun packages/cli/src/cli.ts lint /tmp/hf-590-repro` now passes without `gsap_infinite_repeat`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-587-repro --at 0.5 --timeout 1000` now writes a 1080x1920 PNG.
- `bun packages/cli/src/cli.ts validate /tmp/hf-588-repro --timeout 500` no longer emits `null:1` / `NaN:1` contrast output.
- `bun packages/cli/src/cli.ts validate /tmp/hf-586-repro --timeout 500 --contrast false` no longer emits the muted-video StaticGuard contract error.
- `bun packages/cli/src/cli.ts compositions /tmp/hf-589-gsap-repro` now reports `foo 0.5s 1920x1080 1 element`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-589-gsap-repro --at 0.25 --timeout 2000` captures the expected template-backed red frame.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-584-repro --at 0.5,1.5 --timeout 500` captures the expected post-seek green frame.

### Browser verification

- Refreshed the local side-by-side comparison page at `qa-artifacts/pr-591-video-compare/index.html`.
- Served the comparison page locally and used `agent-browser` to load `style-12-prod`, play both videos quickly to the failed window, pause, and inspect the side-by-side frame.
- Browser proof screenshot: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12-labeled.png`.
- Browser proof recording: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12.webm`.
- Earlier Studio proof artifacts remain local-only: `qa-artifacts/dedupe-refactor-preview.png`, `qa-artifacts/dedupe-refactor-preview-after-play.png`, `qa-artifacts/dedupe-refactor-preview.webm`.

## Notes

- Browser proof and CI diagnostic artifacts are intentionally local-only and not committed.
- Studio's Vite config intentionally keeps the thumbnail clip helper inline because Vite/Vitest config startup runs through Node's loader before package source `.ts` imports are transformed.
- The committed PR diff changes `vfr-screen-recording/output/compiled.html` but no longer changes `vfr-screen-recording/output/output.mp4` relative to `main`.
- I attempted a local `linux/amd64` Docker validation to mirror CI, but the local Docker build was blocked by Debian package download failures. The arm64 Docker image also cannot launch the x64 Puppeteer headless shell under OrbStack. The vfr baseline decision is therefore based on the uploaded CI artifact comparison above.
- I kept this validated issue batch in one PR because the fixes overlap the same CLI/runtime capture surfaces.
This commit is contained in:
Miguel Ángel
2026-05-02 00:00:08 +02:00
committed by GitHub
parent 351beb9fca
commit 15ee63c6e7
47 changed files with 810 additions and 683 deletions
+3 -82
View File
@@ -12,8 +12,11 @@ import { serve } from "@hono/node-server";
import type { IncomingMessage } from "node:http";
import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
import { join, extname, resolve, sep } from "node:path";
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
export { injectScriptsAtHeadStart, injectScriptsIntoHtml };
type PathModuleLike = {
resolve: (...segments: string[]) => string;
sep: string;
@@ -407,88 +410,6 @@ const HF_BRIDGE_SCRIPT = `(function() {
}, 50);
})();`;
function stripEmbeddedRuntimeScripts(html: string): string {
if (!html) return html;
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
const runtimeSrcMarkers = [
"hyperframe.runtime.iife.js",
"hyperframe-runtime.modular-runtime.inline.js",
"data-hyperframes-preview-runtime",
];
const runtimeInlineMarkers = [
"__hyperframeRuntimeBootstrapped",
"__hyperframeRuntime",
"__hyperframeRuntimeTeardown",
"window.__player =",
"window.__playerReady",
"window.__renderReady",
];
const shouldStrip = (block: string): boolean => {
const lowered = block.toLowerCase();
for (const marker of runtimeSrcMarkers) {
if (lowered.includes(marker.toLowerCase())) {
return true;
}
}
for (const marker of runtimeInlineMarkers) {
if (block.includes(marker)) {
return true;
}
}
return false;
};
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
}
export function injectScriptsIntoHtml(
html: string,
headScripts: string[],
bodyScripts: string[],
stripEmbedded: boolean,
): string {
if (stripEmbedded) {
html = stripEmbeddedRuntimeScripts(html);
}
if (headScripts.length > 0) {
const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
if (html.includes("</head>")) {
// Use function replacement to avoid $& interpolation in runtime source
html = html.replace("</head>", () => `${headTags}\n</head>`);
} else if (html.includes("<body")) {
html = html.replace("<body", () => `${headTags}\n<body`);
} else {
html = headTags + "\n" + html;
}
}
if (bodyScripts.length > 0) {
const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
if (html.includes("</body>")) {
// Use function replacement to avoid $& interpolation in runtime source
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
} else {
html = html + "\n" + bodyTags;
}
}
return html;
}
export function injectScriptsAtHeadStart(html: string, scripts: string[]): string {
if (scripts.length === 0) return html;
const headTags = scripts.map((src) => `<script>${src}</script>`).join("\n");
if (html.includes("<head")) {
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${headTags}`);
}
if (html.includes("<body")) {
return html.replace("<body", () => `${headTags}\n<body`);
}
return headTags + "\n" + html;
}
export interface FileServerOptions {
projectDir: string;
compiledDir?: string;
@@ -17,6 +17,7 @@ import {
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
type ResolvedDuration,
type UnresolvedElement,
rewriteAssetPaths,
@@ -236,7 +237,7 @@ async function compileHtmlFile(
);
const clampList: ResolvedDuration[] = [];
for (const r of clampResults) {
if (r.maxDuration > 0 && r.duration > r.maxDuration) {
if (r.maxDuration > 0 && shouldClampMediaDuration(r.duration, r.maxDuration)) {
clampList.push({ id: r.id, duration: r.maxDuration });
}
}
@@ -1,208 +0,0 @@
/**
* Producer-local timing compiler helpers.
* Keep this aligned with core timing compiler behavior.
*/
export interface UnresolvedElement {
id: string;
tagName: string;
src?: string;
start: number;
end?: number;
duration?: number;
mediaStart: number;
compositionSrc?: string;
}
export interface ResolvedDuration {
id: string;
duration: number;
}
export interface ResolvedMediaElement {
id: string;
tagName: string;
src?: string;
start: number;
duration: number;
mediaStart: number;
}
export interface CompilationResult {
html: string;
unresolved: UnresolvedElement[];
}
function getAttr(tag: string, attr: string): string | null {
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
return match ? (match[1] ?? null) : null;
}
function hasAttr(tag: string, attr: string): boolean {
return new RegExp(`${attr}=["']`).test(tag);
}
function injectAttr(tag: string, attr: string, value: string): string {
return tag.replace(/>$/, ` ${attr}="${value}">`);
}
function compileTag(
tag: string,
isVideo: boolean,
): { tag: string; unresolved: UnresolvedElement | null } {
let result = tag;
let unresolved: UnresolvedElement | null = null;
const id = getAttr(result, "id");
const startStr = getAttr(result, "data-start");
const start = startStr !== null ? parseFloat(startStr) : 0;
const mediaStartStr = getAttr(result, "data-media-start");
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
if (!hasAttr(result, "data-end")) {
const durationStr = getAttr(result, "data-duration");
if (durationStr !== null) {
result = injectAttr(result, "data-end", String(start + parseFloat(durationStr)));
} else if (id) {
unresolved = {
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(result, "src") ?? undefined,
start,
mediaStart,
};
}
}
if (isVideo && !hasAttr(result, "data-has-audio")) {
result = injectAttr(result, "data-has-audio", "true");
}
return { tag: result, unresolved };
}
export function compileTimingAttrs(html: string): CompilationResult {
const unresolved: UnresolvedElement[] = [];
html = html.replace(/<video[^>]*>/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, true);
if (u) {
unresolved.push(u);
}
return tag;
});
html = html.replace(/<audio[^>]*>/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, false);
if (u) {
unresolved.push(u);
}
return tag;
});
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
if (!hasAttr(match, "data-start")) {
return match;
}
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) {
return match;
}
const id = getAttr(match, "id");
if (id) {
const startStr = getAttr(match, "data-start");
const compositionSrc = getAttr(match, "data-composition-src");
unresolved.push({
id,
tagName: "div",
start: startStr ? parseFloat(startStr) : 0,
mediaStart: 0,
compositionSrc: compositionSrc ?? undefined,
});
}
return match;
});
return { html, unresolved };
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
for (const { id, duration } of resolutions) {
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
let result = tag;
if (!hasAttr(result, "data-duration")) {
result = injectAttr(result, "data-duration", String(duration));
}
if (!hasAttr(result, "data-end")) {
const startStr = getAttr(result, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
result = injectAttr(result, "data-end", String(start + duration));
}
return result;
});
}
return html;
}
/**
* Extract video/audio elements that already have data-duration set.
* Used by callers to validate declared durations against actual source durations.
*/
export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
const resolved: ResolvedMediaElement[] = [];
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = mediaRegex.exec(html)) !== null) {
const tag = match[0];
const id = getAttr(tag, "id");
const durationStr = getAttr(tag, "data-duration");
if (!id || durationStr === null) continue;
const duration = parseFloat(durationStr);
if (!Number.isFinite(duration) || duration <= 0) continue;
const isVideo = /^<video/i.test(tag);
const startStr = getAttr(tag, "data-start");
const mediaStartStr = getAttr(tag, "data-media-start");
resolved.push({
id,
tagName: isVideo ? "video" : "audio",
src: getAttr(tag, "src") ?? undefined,
start: startStr !== null ? parseFloat(startStr) : 0,
duration,
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
});
}
return resolved;
}
/**
* Clamp existing data-duration and data-end on media elements.
* For each resolution, replaces the declared duration with the clamped value
* and recomputes data-end accordingly.
*/
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
for (const { id, duration } of clamps) {
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
// Replace data-duration value
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
// Recompute data-end from data-start + clamped duration
const startStr = getAttr(tag, "data-start");
const start = startStr ? parseFloat(startStr) : 0;
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
return tag;
});
}
return html;
}
@@ -93,14 +93,14 @@
<body>
<div id="main" data-composition-id="hdr-hlg-regression" data-start="0" data-duration="5" data-width="1920" data-height="1080">
<!-- Window A · Static baseline HLG · 0.02.5s -->
<video id="wa-video" class="clip hdr-video" data-start="0" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="2.5" data-has-audio="true"></video>
<video id="wa-video" class="clip hdr-video" data-start="0" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="2.5" data-has-audio="false"></video>
<div class="label label-tl clip" data-start="0" data-duration="2.5">
A · HLG baseline + DOM overlay
</div>
<!-- Window B · Wrapper opacity fade on HLG · 2.55.0s -->
<div id="window-b-wrapper">
<video id="wb-video" class="clip hdr-video" data-start="2.5" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="5" data-has-audio="true"></video>
<video id="wb-video" class="clip hdr-video" data-start="2.5" data-duration="2.5" data-track-index="0" src="hdr-hlg-clip.mp4" muted playsinline data-end="5" data-has-audio="false"></video>
</div>
<div class="label label-tl clip" data-start="2.5" data-duration="2.5">
B · HLG wrapper opacity fade
@@ -113,6 +113,7 @@
<audio
id="click-sfx"
data-start="0"
data-duration="1.044898"
data-track-index="4"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/853dda23_70261e8b676444bfbfe147e34803851e.mp3"
></audio>
@@ -335,7 +335,7 @@
<!-- Main A-roll and Stats (3s - End) -->
<div id="main-layer" data-composition-id="main" data-start="0" data-duration="16.7" data-width="1920" data-height="1080" data-track-index="5" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5">
<!-- A-roll Video -->
<video id="aroll" src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/70648f53_abe84fb9991841ae8ba82ee21ba6d37e.mp4" data-start="0" data-duration="16.043" data-track-index="1" muted data-end="16.043" data-has-audio="true"></video>
<video id="aroll" src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/70648f53_abe84fb9991841ae8ba82ee21ba6d37e.mp4" data-start="0" data-duration="16.043" data-track-index="1" muted data-end="16.043" data-has-audio="false"></video>
<!-- Statistics Overlay -->
<div class="stats-container">
@@ -215,42 +215,49 @@
id="sfx-bounce-0"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/4ea1246d_d3e9e87e46be4104817dcd75d0adcef0.mp3"
data-start="0"
data-duration="1.044898"
data-track-index="5"
></audio>
<audio
id="sfx-whoosh-1"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/52dc4e22_72f17e2dfc9f40c08f4a5cc82f53db61.mp3"
data-start="1.839"
data-duration="1.044898"
data-track-index="6"
></audio>
<audio
id="sfx-pop-1"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/7ae8806d_d8eacbb4b2ab4a77af89be7208c0c918.mp3"
data-start="2.039"
data-duration="1.044898"
data-track-index="7"
></audio>
<audio
id="sfx-whoosh-2"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/52dc4e22_72f17e2dfc9f40c08f4a5cc82f53db61.mp3"
data-start="4.659"
data-duration="1.044898"
data-track-index="6"
></audio>
<audio
id="sfx-pop-2"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/7ae8806d_d8eacbb4b2ab4a77af89be7208c0c918.mp3"
data-start="4.859"
data-duration="1.044898"
data-track-index="7"
></audio>
<audio
id="sfx-whoosh-3"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/52dc4e22_72f17e2dfc9f40c08f4a5cc82f53db61.mp3"
data-start="8.88"
data-duration="1.044898"
data-track-index="6"
></audio>
<audio
id="sfx-pop-3"
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/generated_assets/7ae8806d_d8eacbb4b2ab4a77af89be7208c0c918.mp3"
data-start="9.08"
data-duration="1.044898"
data-track-index="7"
></audio>
@@ -63,7 +63,7 @@
</head>
<body>
<div id="main" data-composition-id="vfr-screen-recording" data-start="0" data-duration="3" data-width="480" data-height="332">
<video id="clip" class="clip" data-start="0" data-duration="3" data-media-start="1" data-track-index="0" src="clip.mp4" muted playsinline data-end="3" data-has-audio="true"></video>
<video id="clip" class="clip" data-start="0" data-duration="3" data-media-start="1" data-track-index="0" src="clip.mp4" muted playsinline data-end="3" data-has-audio="false"></video>
<div id="label">VFR</div>
</div>