fix(cli): honor direct entries in keyframe shots (#2217)

* fix(cli): honor direct entries in keyframe shots

* fix(core): rebase entry-authored asset paths for direct-entry bundling
This commit is contained in:
Miguel Ángel
2026-07-10 22:32:26 -04:00
committed by GitHub
parent de4e85add6
commit b95ddd74d5
5 changed files with 200 additions and 24 deletions
+20 -1
View File
@@ -1,12 +1,31 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js"; import { ensureDOMParser } from "../utils/dom.js";
import { collectShotSelectors, surfaceComposition } from "./keyframes.js"; import { collectShotSelectors, resolveScope, surfaceComposition } from "./keyframes.js";
beforeAll(() => ensureDOMParser()); beforeAll(() => ensureDOMParser());
const wrap = (script: string) => const wrap = (script: string) =>
`<!doctype html><html><body><div id="root" data-composition-id="main" data-duration="4"><div id="dot" class="clip"></div></div><script>${script}</script></body></html>`; `<!doctype html><html><body><div id="root" data-composition-id="main" data-duration="4"><div id="dot" class="clip"></div></div><script>${script}</script></body></html>`;
describe("keyframes direct composition scope", () => {
it("keeps the project root and passes the nested HTML entry to --shot", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-target-"));
const compositionsDir = join(projectDir, "compositions");
mkdirSync(compositionsDir);
writeFileSync(join(projectDir, "index.html"), wrap(""));
const scenePath = join(compositionsDir, "scene.html");
writeFileSync(scenePath, wrap(""));
const scope = resolveScope({ target: scenePath });
expect(scope.projectDir).toBe(projectDir);
expect(scope.entryFile).toBe("compositions/scene.html");
});
});
describe("keyframes multi-stroke traces", () => { describe("keyframes multi-stroke traces", () => {
it("composites ≥2 position strokes on one element into a single trace", () => { it("composites ≥2 position strokes on one element into a single trace", () => {
const html = wrap(` const html = wrap(`
+29 -14
View File
@@ -1,6 +1,6 @@
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { existsSync, readFileSync, statSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve, dirname, basename } from "node:path"; import { resolve, dirname, basename, join, relative, sep } from "node:path";
import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser"; import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { Example } from "./_examples.js"; import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
@@ -710,6 +710,7 @@ async function runOnionShot(
comps: SurfacedComposition[], comps: SurfacedComposition[],
allComps: SurfacedComposition[], allComps: SurfacedComposition[],
projectDir: string | undefined, projectDir: string | undefined,
entryFile: string | undefined,
args: ShotArgs & { selector?: string }, args: ShotArgs & { selector?: string },
): Promise<boolean> { ): Promise<boolean> {
const { captureMotionPathShot } = await import("./motionShot.js"); const { captureMotionPathShot } = await import("./motionShot.js");
@@ -722,32 +723,34 @@ async function runOnionShot(
console.log(c.dim(guardError)); console.log(c.dim(guardError));
return true; return true;
} }
const saved = await captureMotionPathShot( const saved = await captureMotionPathShot(projectDir!, requests, resolve(args.shot!), {
projectDir!, ...onionShotOptions(args),
requests, entryFile,
resolve(args.shot!), });
onionShotOptions(args),
);
printOnionShotSaved(saved, requests.length); printOnionShotSaved(saved, requests.length);
return false; return false;
} }
// Resolve the command target (a project dir or a single .html) into surfaced // Resolve the command target (a project dir or a single .html) into surfaced
// compositions, applying the optional --selector filter. // compositions, applying the optional --selector filter.
function resolveScope(args: { target?: string; selector?: string }): { export function resolveScope(args: { target?: string; selector?: string }): {
comps: SurfacedComposition[]; comps: SurfacedComposition[];
allComps: SurfacedComposition[]; allComps: SurfacedComposition[];
projectName: string; projectName: string;
projectDir: string | undefined; projectDir: string | undefined;
entryFile: string | undefined;
} { } {
const raw = args.target?.trim(); const raw = args.target?.trim();
let comps: SurfacedComposition[]; let comps: SurfacedComposition[];
let projectName: string; let projectName: string;
let projectDir: string | undefined; let projectDir: string | undefined;
let entryFile: string | undefined;
if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) { if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) {
comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)]; const entryPath = resolve(raw);
projectName = basename(raw); comps = [surfaceComposition(readFileSync(entryPath, "utf-8"), basename(entryPath), entryPath)];
projectDir = dirname(raw); projectName = basename(entryPath);
projectDir = findProjectRoot(entryPath);
entryFile = relative(projectDir, entryPath).split(sep).join("/");
} else { } else {
const project = resolveProject(raw); const project = resolveProject(raw);
comps = collectCompositions(project.indexPath); comps = collectCompositions(project.indexPath);
@@ -777,7 +780,19 @@ function resolveScope(args: { target?: string; selector?: string }): {
cmp.anime.length > 0, cmp.anime.length > 0,
); );
} }
return { comps, allComps, projectName, projectDir }; return { comps, allComps, projectName, projectDir, entryFile };
}
function findProjectRoot(entryPath: string): string {
const entryDir = dirname(entryPath);
let candidate = entryDir;
for (;;) {
if (existsSync(join(candidate, "index.html"))) return candidate;
if (existsSync(join(candidate, ".git"))) return entryDir;
const parent = dirname(candidate);
if (parent === candidate) return entryDir;
candidate = parent;
}
} }
function isEmptyComposition(cmp: SurfacedComposition): boolean { function isEmptyComposition(cmp: SurfacedComposition): boolean {
@@ -917,12 +932,12 @@ function createKeyframesCommand(options: Partial<KeyframesCommandOptions> = {})
); );
console.log(); console.log();
} }
const { comps: rawComps, allComps, projectName, projectDir } = resolveScope(args); const { comps: rawComps, allComps, projectName, projectDir, entryFile } = resolveScope(args);
const comps = filterCompositionsByRuntime(rawComps, runtime); const comps = filterCompositionsByRuntime(rawComps, runtime);
// --shot: 3D onion-skin self-verify screenshot. Returns true when the command // --shot: 3D onion-skin self-verify screenshot. Returns true when the command
// should stop (guard failure) so run() stays small. // should stop (guard failure) so run() stays small.
if (args.shot && (await runOnionShot(comps, allComps, projectDir, args))) return; if (args.shot && (await runOnionShot(comps, allComps, projectDir, entryFile, args))) return;
if (args.json) { if (args.json) {
console.log( console.log(
+3 -1
View File
@@ -36,6 +36,8 @@ interface ScopeResolution {
} }
export interface ShotOptions { export interface ShotOptions {
/** Project-relative HTML entry to render. Defaults to `index.html`. */
entryFile?: string;
/** Equal-time samples across the (windowed) timeline. Default 9. */ /** Equal-time samples across the (windowed) timeline. Default 9. */
samples?: number; samples?: number;
/** "path" = ghosts at real positions + path; "strip" = filmstrip by time. */ /** "path" = ghosts at real positions + path; "strip" = filmstrip by time. */
@@ -619,7 +621,7 @@ export async function captureMotionPathShot(
const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js"); const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js");
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(projectDir); const html = await bundleToSingleHtml(projectDir, { entryFile: opts.entryFile });
const server = await serveStaticProjectHtml( const server = await serveStaticProjectHtml(
projectDir, projectDir,
html, html,
@@ -52,6 +52,49 @@ function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): b
} }
describe("bundleToSingleHtml", () => { describe("bundleToSingleHtml", () => {
it("bundles a direct composition entry with paths relative to its file", async () => {
const dir = makeTempProject({
"index.html": "<html><body>wrong entry</body></html>",
"compositions/scene.html": `<!doctype html><html><head><link rel="stylesheet" href="scene.css"></head><body>
<div data-composition-id="scene" data-width="320" data-height="180">direct scene</div>
</body></html>`,
"compositions/scene.css": ".direct-scene { color: rgb(1, 2, 3); }",
});
const bundled = await bundleToSingleHtml(dir, { entryFile: "compositions/scene.html" });
expect(bundled).toContain("direct scene");
expect(bundled).not.toContain("wrong entry");
expect(bundled).toContain(".direct-scene { color: rgb(1, 2, 3); }");
});
it("rebases direct-entry authored asset paths before inlining", async () => {
const spriteSvg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>';
const bgSvg = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>';
const dir = makeTempProject({
"index.html": "<html><body>wrong entry</body></html>",
"compositions/scene.html": `<!doctype html><html><head>
<style>.scene { background-image: url("./bg.svg"); }</style>
</head><body>
<div class="scene" data-composition-id="scene" data-width="320" data-height="180" data-start="0" data-duration="1">
<img src="./sprite.svg">
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.scene = {}</script>
</body></html>`,
"compositions/sprite.svg": spriteSvg,
"compositions/bg.svg": bgSvg,
});
const bundled = await bundleToSingleHtml(dir, { entryFile: "compositions/scene.html" });
const spriteDataUrl = `data:image/svg+xml;base64,${Buffer.from(spriteSvg).toString("base64")}`;
const bgDataUrl = `data:image/svg+xml;base64,${Buffer.from(bgSvg).toString("base64")}`;
expect(bundled).toContain(`src="${spriteDataUrl}"`);
expect(bundled).toContain(`url("${bgDataUrl}")`);
expect(bundled).not.toContain("./sprite.svg");
expect(bundled).not.toContain("./bg.svg");
});
it("does not merge author scripts into the runtime bootstrap placeholder", async () => { it("does not merge author scripts into the runtime bootstrap placeholder", async () => {
const dir = makeTempProject({ const dir = makeTempProject({
"index.html": `<!doctype html> "index.html": `<!doctype html>
+105 -8
View File
@@ -3,7 +3,7 @@ export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues } from "../runtime/getVariables"; import { parseHostVariableValues } from "../runtime/getVariables";
import { cssVariableName } from "../tokenSlug"; import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs"; import { readFileSync, existsSync } from "fs";
import { join, resolve, relative, dirname, isAbsolute, sep } from "path"; import { resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js"; import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
import { transformSync } from "esbuild"; import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
@@ -133,6 +133,89 @@ function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): str
}); });
} }
function rebaseRelativePath(urlValue: string, fromDir: string, toDir: string): string {
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return urlValue;
const absolutePath = resolve(fromDir, basePath);
const rebased = relative(resolve(toDir), absolutePath).split(sep).join("/");
return appendSuffixToUrl(rebased, suffix);
}
function rebaseSrcsetPaths(srcsetValue: string, fromDir: string, toDir: string): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue
.split(",")
.map((rawCandidate) => {
const candidate = rawCandidate.trim();
if (!candidate) return candidate;
const parts = candidate.split(/\s+/);
const first = parts[0] ?? "";
if (parts.length === 0 || !isRelativeUrl(first)) return candidate;
parts[0] = rebaseRelativePath(first, fromDir, toDir);
return parts.join(" ");
})
.join(", ");
}
function rebaseColorGradingLutPath(value: string, fromDir: string, toDir: string): string {
if (!value.trim().startsWith("{")) return value;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return value;
const lut = Reflect.get(parsed, "lut");
if (typeof lut === "string") {
if (!isRelativeUrl(lut)) return value;
Reflect.set(parsed, "lut", rebaseRelativePath(lut, fromDir, toDir));
return JSON.stringify(parsed);
}
if (typeof lut !== "object" || lut === null || Array.isArray(lut)) return value;
const lutSrc = Reflect.get(lut, "src");
if (typeof lutSrc !== "string" || !isRelativeUrl(lutSrc)) return value;
Reflect.set(lut, "src", rebaseRelativePath(lutSrc, fromDir, toDir));
return JSON.stringify(parsed);
}
function rebaseEntryAuthoredAssetPaths(
document: Document,
sourceDir: string,
projectDir: string,
): void {
for (const styleEl of [...document.querySelectorAll("style")]) {
styleEl.textContent = rebaseCssUrls(styleEl.textContent || "", sourceDir, projectDir);
}
for (const el of [...document.querySelectorAll("[style]")]) {
const styleAttr = el.getAttribute("style");
if (styleAttr) el.setAttribute("style", rebaseCssUrls(styleAttr, sourceDir, projectDir));
}
for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {
if (el.tagName === "LINK" && (el.getAttribute("rel") || "").toLowerCase() === "stylesheet")
continue;
if (el.tagName === "SCRIPT" && el.hasAttribute("src")) continue;
for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
const value = el.getAttribute(attr);
if (!value || !isRelativeUrl(value)) continue;
el.setAttribute(attr, rebaseRelativePath(value, sourceDir, projectDir));
}
}
for (const el of [...document.querySelectorAll("[srcset]")]) {
const srcset = el.getAttribute("srcset");
if (srcset) el.setAttribute("srcset", rebaseSrcsetPaths(srcset, sourceDir, projectDir));
}
for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) {
const value = el.getAttribute(HF_COLOR_GRADING_ATTR);
if (value)
el.setAttribute(
HF_COLOR_GRADING_ATTR,
rebaseColorGradingLutPath(value, sourceDir, projectDir),
);
}
}
function inlineCssFile( function inlineCssFile(
css: string, css: string,
cssFileDir: string, cssFileDir: string,
@@ -581,6 +664,8 @@ function stripJsCommentsParserSafe(source: string): string {
} }
export interface BundleOptions { export interface BundleOptions {
/** Project-relative HTML entry to bundle. Defaults to `index.html`. */
entryFile?: string;
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */ /** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
probeMediaDuration?: MediaDurationProber; probeMediaDuration?: MediaDurationProber;
/** /**
@@ -692,11 +777,19 @@ export async function bundleToSingleHtml(
projectDir: string, projectDir: string,
options?: BundleOptions, options?: BundleOptions,
): Promise<string> { ): Promise<string> {
const indexPath = join(projectDir, "index.html"); const entryFile = options?.entryFile ?? "index.html";
if (!existsSync(indexPath)) throw new Error("index.html not found in project directory"); const indexPath = resolveWithinProject(projectDir, entryFile);
if (!indexPath || !existsSync(indexPath)) {
throw new Error(`${entryFile} not found in project directory`);
}
const sourceDir = dirname(indexPath);
const resolveEntryPath = (relativePath: string): string | null => {
const resolved = resolve(sourceDir, relativePath);
return isSafePath(projectDir, resolved) ? resolved : null;
};
const rawHtml = readFileSync(indexPath, "utf-8"); const rawHtml = readFileSync(indexPath, "utf-8");
const compiled = await compileHtml(rawHtml, projectDir, options?.probeMediaDuration); const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
const staticGuard = await validateHyperframeHtmlContract(compiled); const staticGuard = await validateHyperframeHtmlContract(compiled);
if (!staticGuard.isValid) { if (!staticGuard.isValid) {
@@ -708,13 +801,17 @@ export async function bundleToSingleHtml(
const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline"); const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline");
const document = parseHTMLContent(withInterceptor); const document = parseHTMLContent(withInterceptor);
if (resolve(sourceDir) !== resolve(projectDir)) {
rebaseEntryAuthoredAssetPaths(document, sourceDir, projectDir);
}
// Inline local CSS // Inline local CSS
const localCssChunks: string[] = []; const localCssChunks: string[] = [];
let cssAnchorPlaced = false; let cssAnchorPlaced = false;
for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) { for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) {
const href = el.getAttribute("href"); const href = el.getAttribute("href");
if (!href || !isRelativeUrl(href)) continue; if (!href || !isRelativeUrl(href)) continue;
const cssPath = resolveWithinProject(projectDir, href); const cssPath = resolveEntryPath(href);
if (!cssPath) continue; if (!cssPath) continue;
const css = safeReadFile(cssPath); const css = safeReadFile(cssPath);
if (css == null) continue; if (css == null) continue;
@@ -746,7 +843,7 @@ export async function bundleToSingleHtml(
for (const el of [...document.querySelectorAll("script[src]")]) { for (const el of [...document.querySelectorAll("script[src]")]) {
const src = el.getAttribute("src"); const src = el.getAttribute("src");
if (!src || !isRelativeUrl(src)) continue; if (!src || !isRelativeUrl(src)) continue;
const jsPath = resolveWithinProject(projectDir, src); const jsPath = resolveEntryPath(src);
const js = jsPath ? safeReadFile(jsPath) : null; const js = jsPath ? safeReadFile(jsPath) : null;
if (js == null) continue; if (js == null) continue;
localJsChunks.push(js); localJsChunks.push(js);
@@ -781,7 +878,7 @@ export async function bundleToSingleHtml(
const subCompResult = inlineSubCompositions(document, subCompositionHosts, { const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
resolveHtml: (srcPath: string) => { resolveHtml: (srcPath: string) => {
if (!isRelativeUrl(srcPath)) return null; if (!isRelativeUrl(srcPath)) return null;
const compPath = resolveWithinProject(projectDir, srcPath); const compPath = resolveEntryPath(srcPath);
return compPath ? safeReadFile(compPath) : null; return compPath ? safeReadFile(compPath) : null;
}, },
parseHtml: parseHTMLContent, parseHtml: parseHTMLContent,
@@ -814,7 +911,7 @@ export async function bundleToSingleHtml(
if (seenCompScriptSrcs.has(extSrc)) continue; if (seenCompScriptSrcs.has(extSrc)) continue;
seenCompScriptSrcs.add(extSrc); seenCompScriptSrcs.add(extSrc);
if (isRelativeUrl(extSrc)) { if (isRelativeUrl(extSrc)) {
const jsPath = resolveWithinProject(projectDir, extSrc); const jsPath = resolveEntryPath(extSrc);
const js = jsPath ? safeReadFile(jsPath) : null; const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) { if (js != null) {
compScriptChunks.push(js); compScriptChunks.push(js);