diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts
index 39e5c3281..14e489dbd 100644
--- a/packages/cli/src/commands/keyframes.test.ts
+++ b/packages/cli/src/commands/keyframes.test.ts
@@ -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) =>
`
`;
+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(`
diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts
index 48fc4971e..7d4c83078 100644
--- a/packages/cli/src/commands/keyframes.ts
+++ b/packages/cli/src/commands/keyframes.ts
@@ -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 {
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 = {})
);
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(
diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts
index 5db13f6b2..0bf1061c6 100644
--- a/packages/cli/src/commands/motionShot.ts
+++ b/packages/cli/src/commands/motionShot.ts
@@ -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,
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index f28e40ee1..e283a5d56 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -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": "wrong entry",
+ "compositions/scene.html": `
+ direct scene
+ `,
+ "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 = '';
+ const bgSvg = '';
+ const dir = makeTempProject({
+ "index.html": "wrong entry",
+ "compositions/scene.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": `
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 029839f3a..763a74860 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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 {
- 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);