mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
@@ -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 { ensureDOMParser } from "../utils/dom.js";
|
||||
import { collectShotSelectors, surfaceComposition } from "./keyframes.js";
|
||||
import { collectShotSelectors, resolveScope, surfaceComposition } from "./keyframes.js";
|
||||
|
||||
beforeAll(() => ensureDOMParser());
|
||||
|
||||
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>`;
|
||||
|
||||
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", () => {
|
||||
it("composites ≥2 position strokes on one element into a single trace", () => {
|
||||
const html = wrap(`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineCommand } from "citty";
|
||||
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 type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
@@ -710,6 +710,7 @@ async function runOnionShot(
|
||||
comps: SurfacedComposition[],
|
||||
allComps: SurfacedComposition[],
|
||||
projectDir: string | undefined,
|
||||
entryFile: string | undefined,
|
||||
args: ShotArgs & { selector?: string },
|
||||
): Promise<boolean> {
|
||||
const { captureMotionPathShot } = await import("./motionShot.js");
|
||||
@@ -722,32 +723,34 @@ async function runOnionShot(
|
||||
console.log(c.dim(guardError));
|
||||
return true;
|
||||
}
|
||||
const saved = await captureMotionPathShot(
|
||||
projectDir!,
|
||||
requests,
|
||||
resolve(args.shot!),
|
||||
onionShotOptions(args),
|
||||
);
|
||||
const saved = await captureMotionPathShot(projectDir!, requests, resolve(args.shot!), {
|
||||
...onionShotOptions(args),
|
||||
entryFile,
|
||||
});
|
||||
printOnionShotSaved(saved, requests.length);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the command target (a project dir or a single .html) into surfaced
|
||||
// compositions, applying the optional --selector filter.
|
||||
function resolveScope(args: { target?: string; selector?: string }): {
|
||||
export function resolveScope(args: { target?: string; selector?: string }): {
|
||||
comps: SurfacedComposition[];
|
||||
allComps: SurfacedComposition[];
|
||||
projectName: string;
|
||||
projectDir: string | undefined;
|
||||
entryFile: string | undefined;
|
||||
} {
|
||||
const raw = args.target?.trim();
|
||||
let comps: SurfacedComposition[];
|
||||
let projectName: string;
|
||||
let projectDir: string | undefined;
|
||||
let entryFile: string | undefined;
|
||||
if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) {
|
||||
comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)];
|
||||
projectName = basename(raw);
|
||||
projectDir = dirname(raw);
|
||||
const entryPath = resolve(raw);
|
||||
comps = [surfaceComposition(readFileSync(entryPath, "utf-8"), basename(entryPath), entryPath)];
|
||||
projectName = basename(entryPath);
|
||||
projectDir = findProjectRoot(entryPath);
|
||||
entryFile = relative(projectDir, entryPath).split(sep).join("/");
|
||||
} else {
|
||||
const project = resolveProject(raw);
|
||||
comps = collectCompositions(project.indexPath);
|
||||
@@ -777,7 +780,19 @@ function resolveScope(args: { target?: string; selector?: string }): {
|
||||
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 {
|
||||
@@ -917,12 +932,12 @@ function createKeyframesCommand(options: Partial<KeyframesCommandOptions> = {})
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
const { comps: rawComps, allComps, projectName, projectDir } = resolveScope(args);
|
||||
const { comps: rawComps, allComps, projectName, projectDir, entryFile } = resolveScope(args);
|
||||
const comps = filterCompositionsByRuntime(rawComps, runtime);
|
||||
|
||||
// --shot: 3D onion-skin self-verify screenshot. Returns true when the command
|
||||
// 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) {
|
||||
console.log(
|
||||
|
||||
@@ -36,6 +36,8 @@ interface ScopeResolution {
|
||||
}
|
||||
|
||||
export interface ShotOptions {
|
||||
/** Project-relative HTML entry to render. Defaults to `index.html`. */
|
||||
entryFile?: string;
|
||||
/** Equal-time samples across the (windowed) timeline. Default 9. */
|
||||
samples?: number;
|
||||
/** "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 { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
|
||||
const html = await bundleToSingleHtml(projectDir);
|
||||
const html = await bundleToSingleHtml(projectDir, { entryFile: opts.entryFile });
|
||||
const server = await serveStaticProjectHtml(
|
||||
projectDir,
|
||||
html,
|
||||
|
||||
@@ -52,6 +52,49 @@ function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): b
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -3,7 +3,7 @@ export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
|
||||
import { parseHostVariableValues } from "../runtime/getVariables";
|
||||
import { cssVariableName } from "../tokenSlug";
|
||||
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 { transformSync } from "esbuild";
|
||||
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(
|
||||
css: string,
|
||||
cssFileDir: string,
|
||||
@@ -581,6 +664,8 @@ function stripJsCommentsParserSafe(source: string): string {
|
||||
}
|
||||
|
||||
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. */
|
||||
probeMediaDuration?: MediaDurationProber;
|
||||
/**
|
||||
@@ -692,11 +777,19 @@ export async function bundleToSingleHtml(
|
||||
projectDir: string,
|
||||
options?: BundleOptions,
|
||||
): Promise<string> {
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
if (!existsSync(indexPath)) throw new Error("index.html not found in project directory");
|
||||
const entryFile = options?.entryFile ?? "index.html";
|
||||
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 compiled = await compileHtml(rawHtml, projectDir, options?.probeMediaDuration);
|
||||
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
|
||||
|
||||
const staticGuard = await validateHyperframeHtmlContract(compiled);
|
||||
if (!staticGuard.isValid) {
|
||||
@@ -708,13 +801,17 @@ export async function bundleToSingleHtml(
|
||||
const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline");
|
||||
const document = parseHTMLContent(withInterceptor);
|
||||
|
||||
if (resolve(sourceDir) !== resolve(projectDir)) {
|
||||
rebaseEntryAuthoredAssetPaths(document, sourceDir, projectDir);
|
||||
}
|
||||
|
||||
// Inline local CSS
|
||||
const localCssChunks: string[] = [];
|
||||
let cssAnchorPlaced = false;
|
||||
for (const el of [...document.querySelectorAll('link[rel="stylesheet"]')]) {
|
||||
const href = el.getAttribute("href");
|
||||
if (!href || !isRelativeUrl(href)) continue;
|
||||
const cssPath = resolveWithinProject(projectDir, href);
|
||||
const cssPath = resolveEntryPath(href);
|
||||
if (!cssPath) continue;
|
||||
const css = safeReadFile(cssPath);
|
||||
if (css == null) continue;
|
||||
@@ -746,7 +843,7 @@ export async function bundleToSingleHtml(
|
||||
for (const el of [...document.querySelectorAll("script[src]")]) {
|
||||
const src = el.getAttribute("src");
|
||||
if (!src || !isRelativeUrl(src)) continue;
|
||||
const jsPath = resolveWithinProject(projectDir, src);
|
||||
const jsPath = resolveEntryPath(src);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js == null) continue;
|
||||
localJsChunks.push(js);
|
||||
@@ -781,7 +878,7 @@ export async function bundleToSingleHtml(
|
||||
const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
|
||||
resolveHtml: (srcPath: string) => {
|
||||
if (!isRelativeUrl(srcPath)) return null;
|
||||
const compPath = resolveWithinProject(projectDir, srcPath);
|
||||
const compPath = resolveEntryPath(srcPath);
|
||||
return compPath ? safeReadFile(compPath) : null;
|
||||
},
|
||||
parseHtml: parseHTMLContent,
|
||||
@@ -814,7 +911,7 @@ export async function bundleToSingleHtml(
|
||||
if (seenCompScriptSrcs.has(extSrc)) continue;
|
||||
seenCompScriptSrcs.add(extSrc);
|
||||
if (isRelativeUrl(extSrc)) {
|
||||
const jsPath = resolveWithinProject(projectDir, extSrc);
|
||||
const jsPath = resolveEntryPath(extSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(js);
|
||||
|
||||
Reference in New Issue
Block a user