Skip to content

Commit 6228c19

Browse files
miguel-heygenclaude
andcommitted
feat(cli): general hyperframes compare visual-variant primitive
Generalize grade-compare's "render N variants → one labeled sheet → the agent looks and picks" loop into a standalone command that works on ANY variation (font, layout, motion, grade, whole compositions) — the tool never needs to know what differs. - `hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out] [--cols] [--json]`: renders each agent-authored composition variant through the real runtime (captureCompositionFrame) and stitches one labeled comparison sheet + JSON ({ok, sheet, rendered, variants, truncated?/total?}). 2+ paths required; caps at 16 with loud truncation. It presents, it does not judge — choosing is the caller's job. - Factored the shared "render a labeled set → contact sheet" path so compare, grade-compare, and snapshot all sit on it (no duplication). grade-compare is now the first color-specific specialization of this primitive. - New pathArgs util + contactSheet test; hyperframes-cli SKILL documents compare as the agent's "see your own renders and choose" primitive. Verified: 26/26 across compare + grade-compare + snapshot + contactSheet (no regressions); compare renders 3 variants into one visibly-distinct labeled sheet; 2+-path error path clean; lint/format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
1 parent 7b0947f commit 6228c19

12 files changed

Lines changed: 649 additions & 47 deletions

File tree

packages/cli/src/capture/captureCompositionFrame.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,32 @@ export async function openSettledCompositionPage(
116116
}
117117
}
118118

119+
export async function seekCompositionTimeline(
120+
page: Pick<Page, "evaluate">,
121+
timeSeconds: number,
122+
): Promise<void> {
123+
await page.evaluate((t: number) => {
124+
const player = (window as any).__player;
125+
if (!player) return;
126+
const safe = Math.max(0, Number(t) || 0);
127+
if (typeof player.renderSeek === "function") {
128+
player.renderSeek(safe);
129+
} else if (typeof player.seek === "function") {
130+
player.seek(safe);
131+
}
132+
if ((window as any).gsap?.ticker?.tick) {
133+
(window as any).gsap.ticker.tick();
134+
}
135+
}, timeSeconds);
136+
137+
await page.evaluate(`new Promise(function(r) {
138+
var settled = false;
139+
function finish() { if (settled) return; settled = true; r(); }
140+
window.setTimeout(finish, 100);
141+
requestAnimationFrame(function() { requestAnimationFrame(finish); });
142+
})`);
143+
}
144+
119145
export async function runFfmpegOnce(
120146
ffmpegPath: string,
121147
args: readonly string[],
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import sharp from "sharp";
5+
import { describe, expect, it } from "vitest";
6+
import { createContactSheet } from "./contactSheet.js";
7+
8+
function tempDir(): string {
9+
return mkdtempSync(join(tmpdir(), "hf-contact-sheet-test-"));
10+
}
11+
12+
describe("createContactSheet", () => {
13+
it("writes PNG output when the output path uses a .png extension", async () => {
14+
const dir = tempDir();
15+
try {
16+
const a = join(dir, "a.png");
17+
const b = join(dir, "b.png");
18+
const out = join(dir, "sheet.png");
19+
await sharp({
20+
create: {
21+
width: 16,
22+
height: 9,
23+
channels: 3,
24+
background: { r: 255, g: 0, b: 0 },
25+
},
26+
})
27+
.png()
28+
.toFile(a);
29+
await sharp({
30+
create: {
31+
width: 16,
32+
height: 9,
33+
channels: 3,
34+
background: { r: 0, g: 255, b: 0 },
35+
},
36+
})
37+
.png()
38+
.toFile(b);
39+
40+
await createContactSheet([a, b], out, {
41+
cols: 2,
42+
labelMode: "custom",
43+
labels: ["A", "B"],
44+
maxImages: 2,
45+
});
46+
47+
await expect(sharp(out).metadata()).resolves.toMatchObject({ format: "png" });
48+
} finally {
49+
rmSync(dir, { recursive: true, force: true });
50+
}
51+
});
52+
});

packages/cli/src/capture/contactSheet.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,20 @@ export async function createContactSheet(
9393
overlays.push({ input: labelSvg, left: x, top: y });
9494
}
9595

96-
await sharp({
96+
const sheet = sharp({
9797
create: {
9898
width: totalW,
9999
height: totalH,
100100
channels: 3,
101101
background: { r: 26, g: 26, b: 26 },
102102
},
103-
})
104-
.composite(overlays)
105-
.jpeg({ quality })
106-
.toFile(outputPath);
103+
}).composite(overlays);
104+
105+
if (extname(outputPath).toLowerCase() === ".png") {
106+
await sheet.png().toFile(outputPath);
107+
} else {
108+
await sheet.jpeg({ quality }).toFile(outputPath);
109+
}
107110

108111
return outputPath;
109112
}
@@ -263,6 +266,7 @@ export async function createAssetContactSheet(
263266
* parent assets/ root (for external SVGs downloaded as <img src="*.svg">).
264267
* Files are deduplicated by basename so duplicates across dirs are collapsed.
265268
*/
269+
// fallow-ignore-next-line complexity
266270
export async function createSvgContactSheet(
267271
svgsDir: string,
268272
outputPath: string,

packages/cli/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ const commandLoaders = {
140140
validate: () => import("./commands/validate.js").then((m) => m.default),
141141
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
142142
"grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default),
143+
compare: () => import("./commands/compare.js").then((m) => m.default),
143144
capture: () => import("./commands/capture.js").then((m) => m.default),
144145
lambda: () => import("./commands/lambda.js").then((m) => m.default),
145146
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, it, vi } from "vitest";
5+
import {
6+
buildCompareSuccessPayload,
7+
capCompareVariants,
8+
parseCompareArgs,
9+
prepareCompareVariantProjects,
10+
} from "./compare.js";
11+
12+
function tempDir(): string {
13+
return mkdtempSync(join(tmpdir(), "hf-compare-test-"));
14+
}
15+
16+
describe("parseCompareArgs", () => {
17+
it("requires at least two composition paths", () => {
18+
expect(() => parseCompareArgs({ _: ["variant-a"] }, "/tmp")).toThrow(
19+
"need 2+ paths to compare",
20+
);
21+
});
22+
23+
it("rejects --labels when the count does not match the path count", () => {
24+
expect(() =>
25+
parseCompareArgs({ _: ["variant-a", "variant-b"], labels: "one,two,three" }, "/tmp"),
26+
).toThrow("--labels count (3) must match path count (2)");
27+
});
28+
29+
it("derives default labels from directory basenames and html filenames", () => {
30+
const parsed = parseCompareArgs(
31+
{ _: ["./looks/warm", "./looks/cool.html", "/tmp/hero.alt.html"] },
32+
"/work/project",
33+
);
34+
35+
expect(parsed.variants).toEqual([
36+
{ label: "warm", inputPath: "/work/project/looks/warm", displayPath: "./looks/warm" },
37+
{
38+
label: "cool",
39+
inputPath: "/work/project/looks/cool.html",
40+
displayPath: "./looks/cool.html",
41+
},
42+
{ label: "hero.alt", inputPath: "/tmp/hero.alt.html", displayPath: "/tmp/hero.alt.html" },
43+
]);
44+
});
45+
});
46+
47+
describe("capCompareVariants", () => {
48+
it("truncates over-cap variants and exposes truncation metadata for JSON output", () => {
49+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
50+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
51+
try {
52+
const variants = Array.from({ length: 20 }, (_, index) => ({
53+
label: `variant ${index + 1}`,
54+
inputPath: `/tmp/variant-${index + 1}`,
55+
displayPath: `variant-${index + 1}`,
56+
}));
57+
58+
const capped = capCompareVariants(variants);
59+
60+
expect(capped.variants).toHaveLength(16);
61+
expect(capped.variants.at(0)?.label).toBe("variant 1");
62+
expect(capped.variants.at(15)?.label).toBe("variant 16");
63+
expect(capped.truncated).toBe(true);
64+
expect(capped.total).toBe(20);
65+
expect(buildCompareSuccessPayload("/tmp/compare.png", capped.variants, capped)).toMatchObject(
66+
{
67+
ok: true,
68+
sheet: "/tmp/compare.png",
69+
rendered: 16,
70+
truncated: true,
71+
total: 20,
72+
},
73+
);
74+
expect(errorSpy).toHaveBeenCalledWith(
75+
expect.stringContaining("Warning: 20 compare variants exceed the 16-variant cap"),
76+
);
77+
expect(logSpy).not.toHaveBeenCalled();
78+
} finally {
79+
vi.restoreAllMocks();
80+
}
81+
});
82+
});
83+
84+
describe("prepareCompareVariantProjects", () => {
85+
it("uses project directories directly and stages standalone html files as index.html", () => {
86+
const dir = tempDir();
87+
try {
88+
const projectDir = join(dir, "variant-a");
89+
const htmlDir = join(dir, "variant-b");
90+
const projectIndex = join(projectDir, "index.html");
91+
const htmlFile = join(htmlDir, "candidate.html");
92+
mkdirSync(projectDir, { recursive: true });
93+
mkdirSync(htmlDir, { recursive: true });
94+
writeFileSync(projectIndex, "<!doctype html><title>A</title>", { flag: "wx" });
95+
writeFileSync(htmlFile, "<!doctype html><title>B</title>", { flag: "wx" });
96+
writeFileSync(join(htmlDir, "asset.txt"), "asset");
97+
98+
const prepared = prepareCompareVariantProjects([
99+
{ label: "A", inputPath: projectDir, displayPath: "variant-a" },
100+
{ label: "B", inputPath: htmlFile, displayPath: "variant-b/candidate.html" },
101+
]);
102+
103+
try {
104+
expect(prepared).toHaveLength(2);
105+
expect(prepared[0]).toMatchObject({
106+
label: "A",
107+
inputPath: projectDir,
108+
displayPath: "variant-a",
109+
projectDir,
110+
});
111+
expect(prepared[0]?.stagedDir).toBeUndefined();
112+
expect(prepared[1]?.projectDir).not.toBe(htmlDir);
113+
expect(prepared[1]?.stagedDir).toBe(prepared[1]?.projectDir);
114+
expect(readFileSync(join(prepared[1]!.projectDir, "index.html"), "utf-8")).toContain(
115+
"<title>B</title>",
116+
);
117+
expect(readFileSync(join(prepared[1]!.projectDir, "asset.txt"), "utf-8")).toBe("asset");
118+
expect(existsSync(join(prepared[1]!.projectDir, "candidate.html"))).toBe(false);
119+
} finally {
120+
for (const variant of prepared) {
121+
if (variant.stagedDir) rmSync(variant.stagedDir, { recursive: true, force: true });
122+
}
123+
}
124+
} finally {
125+
rmSync(dir, { recursive: true, force: true });
126+
}
127+
});
128+
129+
it("errors clearly for inputs that are not composition projects or html files", () => {
130+
const dir = tempDir();
131+
try {
132+
const badFile = join(dir, "notes.txt");
133+
writeFileSync(badFile, "not a composition");
134+
135+
expect(() =>
136+
prepareCompareVariantProjects([
137+
{ label: "notes", inputPath: badFile, displayPath: "notes.txt" },
138+
{ label: "other", inputPath: join(dir, "missing"), displayPath: "missing" },
139+
]),
140+
).toThrow(/not a composition input.*notes\.txt/);
141+
} finally {
142+
rmSync(dir, { recursive: true, force: true });
143+
}
144+
});
145+
});

0 commit comments

Comments
 (0)