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 { 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(`
+29 -14
View File
@@ -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(
+3 -1
View File
@@ -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,