fix(cli): render SVG selector proof strips

This commit is contained in:
Miguel Ángel
2026-08-28 10:31:40 +00:00
parent af1cb1c10d
commit beb963914a
4 changed files with 270 additions and 49 deletions
+31 -1
View File
@@ -4,22 +4,52 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { seekAllAdaptersInBrowser } from "./motionShot.js";
import { sampleMarkerOnionElements, seekAllAdaptersInBrowser } from "./motionShot.js";
const motionShotSourcePath = join(dirname(fileURLToPath(import.meta.url)), "motionShot.ts");
const motionWindow = window as Window & {
__player?: { renderSeek?: (time: number) => void };
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfSeekAllAdapters?: (time: number) => Promise<void>;
};
afterEach(() => {
delete motionWindow.__player;
delete motionWindow.__hfWaitForSeekCompletion;
delete motionWindow.__hfSeekAllAdapters;
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("motion-shot adapter seeking", () => {
it("samples an SVG group from its local bbox projected through the screen CTM", async () => {
document.body.innerHTML = '<svg><g id="bike"></g></svg>';
const bike = document.querySelector("#bike")!;
Object.defineProperties(bike, {
getBBox: {
value: () => ({ x: -44, y: -120, width: 238, height: 164 }),
},
getScreenCTM: {
value: () => ({ a: 1, b: 0, c: 0, d: 1, e: 60, f: 200 }),
},
});
motionWindow.__hfSeekAllAdapters = vi.fn(async () => undefined);
const [element] = await sampleMarkerOnionElements(["#bike"], [2]);
expect(element).toBeDefined();
if (!element) return;
const [sample] = element.samples;
expect(sample).toBeDefined();
if (!sample) return;
expect(sample.q).toEqual([
{ x: 16, y: 80 },
{ x: 254, y: 80 },
{ x: 254, y: 244 },
{ x: 16, y: 244 },
]);
expect(sample.c).toEqual({ x: 135, y: 162 });
});
it("awaits GPU work registered by a standalone hf-seek listener", async () => {
document.body.innerHTML =
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
+171 -48
View File
@@ -21,11 +21,14 @@ import {
} from "../browser/gpuPolicy.js";
import {
buildOnionSvg,
buildRenderedStripSvg,
ghostAlphas,
parseAngle,
resolveShotSelectors,
sampleTimes,
stripCaptureTimeCandidates,
type OnionElement,
type RenderedStripFrame,
} from "./motionShotLayout.js";
export interface ShotRequest {
@@ -82,6 +85,91 @@ interface PageSample {
type OrbitCamera = { yaw: number; pitch: number };
type FrameSize = { width: number; height: number };
/** Runs in the browser: sample HTML targets through inherited marker geometry,
* and SVG graphics through their native local bbox + screen transform. */
export async function sampleMarkerOnionElements(
selectors: string[],
ts: number[],
): Promise<OnionElement[]> {
const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => Promise<void> })
.__hfSeekAllAdapters;
const rigs = selectors.map((selector) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return null;
const svg = el as Element & {
getBBox?: () => { x: number; y: number; width: number; height: number };
getScreenCTM?: () => {
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
} | null;
};
if (typeof svg.getBBox === "function" && typeof svg.getScreenCTM === "function") {
return { el, svg, markers: null };
}
const w = el.offsetWidth;
const h = el.offsetHeight;
const local: Array<[number, number]> = [
[0, 0],
[w, 0],
[w, h],
[0, h],
[w / 2, h / 2],
];
const markers = local.map(([lx, ly]) => {
const marker = document.createElement("div");
marker.style.cssText = `position:absolute;left:${lx}px;top:${ly}px;width:0;height:0;pointer-events:none`;
el.appendChild(marker);
return marker;
});
return { el, svg: null, markers };
});
const out = selectors.map((selector) => ({ selector, samples: [] as PageSample[] }));
for (const t of ts) {
await seek?.(t);
rigs.forEach((rig, index) => {
if (!rig) return;
let points: Array<{ x: number; y: number }>;
if (rig.svg) {
const box = rig.svg.getBBox?.();
const matrix = rig.svg.getScreenCTM?.();
if (!box || !matrix) return;
const local = [
{ x: box.x, y: box.y },
{ x: box.x + box.width, y: box.y },
{ x: box.x + box.width, y: box.y + box.height },
{ x: box.x, y: box.y + box.height },
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
];
points = local.map((point) => ({
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
}));
} else {
points = rig.markers!.map((marker) => {
const rect = marker.getBoundingClientRect();
return { x: rect.left, y: rect.top };
});
}
const style = getComputedStyle(rig.el);
out[index]!.samples.push({
t: Math.round(t * 1000) / 1000,
q: points.slice(0, 4),
c: points[4]!,
color: style.backgroundColor,
opacity: parseFloat(style.opacity) || 0,
});
});
}
rigs.forEach((rig) => {
if (rig) rig.el.style.visibility = "hidden";
});
return out.filter((element) => element.samples.length > 0);
}
// Runs IN THE BROWSER (serialized by page.evaluate). Make the element's ancestor
// chain preserve-3d, strip intermediate perspective, put one perspective on the
// composition root's parent (the lens) and rotate the root — so the element's own
@@ -546,6 +634,64 @@ async function captureGhostOnionSkin(
return outPath;
}
async function captureRenderedSvgStrip(
page: import("puppeteer-core").Page,
selector: string,
times: number[],
size: FrameSize,
camera: OrbitCamera,
hasWindow: boolean,
outPath: string,
): Promise<string> {
await applyOrbitCameraIfAngled(page, [{ selector }], camera);
const frames: RenderedStripFrame[] = [];
for (const t of times) {
let clip: { x: number; y: number; width: number; height: number } | null = null;
for (const captureTime of stripCaptureTimeCandidates(t)) {
await page.evaluate(
async (time: number) =>
await (
window as unknown as { __hfSeekAllAdapters?: (value: number) => Promise<void> }
).__hfSeekAllAdapters?.(time),
captureTime,
);
clip = await page.evaluate((value: string) => {
const element = document.querySelector(value);
const rect = element?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
const padding = 8;
const x = Math.max(0, Math.floor(rect.left - padding));
const y = Math.max(0, Math.floor(rect.top - padding));
const right = Math.min(window.innerWidth, Math.ceil(rect.right + padding));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.bottom + padding));
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
}, selector);
if (clip) break;
}
if (!clip) throw new Error(`--shot: '${selector}' has no visible SVG bounds at ${t}s.`);
const shot = await page.screenshot({ type: "png", clip });
const png = Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
frames.push({
t,
dataUrl: `data:image/png;base64,${png.toString("base64")}`,
width: clip.width,
height: clip.height,
});
}
const windowStr = hasWindow ? ` · t ${times[0]}${times[times.length - 1]}s` : "";
const markup = buildRenderedStripSvg(frames, {
width: size.width,
height: size.height,
label: `${cameraLabel(camera)} · filmstrip · ${times.length} frames${windowStr}`,
});
await page.evaluate((svg: string) => document.body.insertAdjacentHTML("beforeend", svg), markup);
await new Promise((resolveWait) => setTimeout(resolveWait, 60));
const output = await page.screenshot({ type: "png" });
if (!output) throw new Error("screenshot returned no data");
writeFileSync(outPath, output as Uint8Array);
return outPath;
}
// Default (marker) onion-skin: seek to each sample time, read every element's
// projected corners. Marker children (zero-size) inherit the element's full
// transform chain, so their screen positions ARE the 3D projection of each
@@ -563,54 +709,7 @@ async function captureMarkerOnionSkin(
await applyOrbitCameraIfAngled(page, requests, camera);
const elements = (await page.evaluate(
async (selectors: string[], ts: number[]) => {
const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => Promise<void> })
.__hfSeekAllAdapters;
const rigs = selectors.map((sel) => {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) return null;
const w = el.offsetWidth;
const h = el.offsetHeight;
const local: Array<[number, number]> = [
[0, 0],
[w, 0],
[w, h],
[0, h],
[w / 2, h / 2],
];
const markers = local.map(([lx, ly]) => {
const m = document.createElement("div");
m.style.cssText = `position:absolute;left:${lx}px;top:${ly}px;width:0;height:0;pointer-events:none`;
el.appendChild(m);
return m;
});
return { el, markers };
});
const out = selectors.map((selector) => ({ selector, samples: [] as PageSample[] }));
for (const t of ts) {
await seek?.(t);
rigs.forEach((rig, i) => {
if (!rig) return;
const pts = rig.markers.map((m) => {
const r = m.getBoundingClientRect();
return { x: r.left, y: r.top };
});
const cs = getComputedStyle(rig.el);
out[i]!.samples.push({
t: Math.round(t * 1000) / 1000,
q: pts.slice(0, 4),
c: pts[4]!,
color: cs.backgroundColor,
opacity: parseFloat(cs.opacity) || 0,
});
});
}
rigs.forEach((rig) => {
if (rig) rig.el.style.visibility = "hidden";
});
return out.filter((o) => o.samples.length > 0);
},
sampleMarkerOnionElements,
requests.map((r) => r.selector),
times,
)) as OnionElement[];
@@ -683,6 +782,30 @@ export async function captureMotionPathShot(
return await captureGhostOnionSkin(page, requests, times, size, camera, outPath);
}
const stripSelector = layout === "strip" ? requests[0]?.selector : undefined;
const stripTargetsSvg = stripSelector
? await page.evaluate((selector: string) => {
const element = document.querySelector(selector) as Element & {
getBBox?: () => unknown;
getScreenCTM?: () => unknown;
};
return (
typeof element?.getBBox === "function" && typeof element.getScreenCTM === "function"
);
}, stripSelector)
: false;
if (stripSelector && stripTargetsSvg) {
return await captureRenderedSvgStrip(
page,
stripSelector,
times,
size,
camera,
opts.from != null || opts.to != null,
outPath,
);
}
return await captureMarkerOnionSkin(
page,
requests,
@@ -1,12 +1,14 @@
import { describe, expect, it } from "vitest";
import {
buildOnionSvg,
buildRenderedStripSvg,
fitTransform,
ghostAlphas,
parseAngle,
resolveShotSelectors,
sampleTimes,
stripCells,
stripCaptureTimeCandidates,
type OnionElement,
} from "./motionShotLayout.js";
@@ -54,6 +56,11 @@ describe("resolveShotSelectors", () => {
});
describe("sampleTimes", () => {
it("offers one pre-end frame when an exact sample has no visible bounds", () => {
expect(stripCaptureTimeCandidates(4)).toEqual([4, 4 - 1 / 30]);
expect(stripCaptureTimeCandidates(0)).toEqual([0]);
});
it("spreads N equal-time steps across the full duration", () => {
expect(sampleTimes(4, 5, null, null)).toEqual([0, 1, 2, 3, 4]);
});
@@ -149,6 +156,20 @@ const sample = (t: number) => ({
const oneElement: OnionElement[] = [{ selector: "#hero", samples: [sample(0), sample(2)] }];
describe("buildOnionSvg", () => {
it("builds a filmstrip from rendered selector crops", () => {
const svg = buildRenderedStripSvg(
[
{ t: 0, dataUrl: "data:image/png;base64,AAA", width: 240, height: 160 },
{ t: 2, dataUrl: "data:image/png;base64,BBB", width: 200, height: 200 },
],
{ width: 640, height: 360, label: "front · filmstrip" },
);
expect((svg.match(/<image/g) ?? []).length).toBe(2);
expect(svg).toContain("data:image/png;base64,AAA");
expect(svg).toContain("front · filmstrip");
});
it("path layout: one ghost per sample, a connecting path, and centre dots", () => {
const svg = buildOnionSvg(oneElement, { layout: "path", fit: true, width: 1000, height: 1000 });
expect(svg.startsWith("<svg")).toBe(true);
@@ -24,6 +24,13 @@ export interface OnionElement {
samples: OnionSample[];
}
export interface RenderedStripFrame {
t: number;
dataUrl: string;
width: number;
height: number;
}
export type ShotLayout = "path" | "strip";
export interface ShotLayoutOptions {
@@ -98,6 +105,12 @@ export function sampleTimes(
});
}
/** Exact clip ends are half-open in the runtime. If an authored sample has no
* visible bounds there, retry one nominal 30fps frame inside the clip. */
export function stripCaptureTimeCandidates(time: number): number[] {
return time > 0 ? [time, Math.max(0, time - 1 / 30)] : [time];
}
/** Opacity ramp for the rendered ("ghost") onion-skin: older frames fainter,
* the newest frame solid, so the composite of real painted frames reads as a
* motion trail leading to the final pose. One alpha in [0,1] per sample. */
@@ -199,6 +212,40 @@ export function buildOnionSvg(elements: OnionElement[], opt: ShotLayoutOptions):
})}>${body}</svg>`;
}
/** Build an image-backed filmstrip from real selector crops. Unlike marker
* ghosts, this preserves transparent SVG groups' painted child geometry. */
export function buildRenderedStripSvg(
frames: RenderedStripFrame[],
opt: Pick<ShotLayoutOptions, "width" | "height" | "label">,
): string {
const { width: W, height: H } = opt;
const { cols, cellW, cellH } = stripCells(frames.length, W, H);
let body = `<rect ${attrs({ x: 0, y: 0, width: W, height: H, fill: "#0b1320" })}/>`;
frames.forEach((frame, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const cellX = col * cellW;
const cellY = row * cellH;
const availableW = Math.max(1, cellW - 20);
const availableH = Math.max(1, cellH - 38);
const scale = Math.min(availableW / frame.width, availableH / frame.height);
const imageW = frame.width * scale;
const imageH = frame.height * scale;
const imageX = cellX + (cellW - imageW) / 2;
const imageY = cellY + 30 + (availableH - imageH) / 2;
const f = frames.length <= 1 ? 0 : index / (frames.length - 1);
body += `<rect ${attrs({ x: round(cellX + 3), y: round(cellY + 3), width: round(cellW - 6), height: round(cellH - 6), fill: "none", stroke: "#1c2531", "stroke-width": 1, rx: 8 })}/>`;
body += `<image ${attrs({ x: round(imageX), y: round(imageY), width: round(imageW), height: round(imageH), href: frame.dataUrl, preserveAspectRatio: "xMidYMid meet" })}/>`;
body += text({ x: cellX + 12, y: cellY + 24 }, `${frame.t}s`, timeColor(f), 16);
});
if (opt.label) body += text({ x: 28, y: 40 }, opt.label, timeColor(0), 18);
return `<svg ${attrs({
xmlns: "http://www.w3.org/2000/svg",
style: "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647",
viewBox: `0 0 ${W} ${H}`,
})}>${body}</svg>`;
}
function pathBody(elements: OnionElement[], fit: boolean, W: number, H: number): string {
const all = elements.flatMap((e) => e.samples.flatMap((s) => [...s.q, s.c]));
const { k, cx, cy } = fit ? fitTransform(all, W, H) : { k: 1, cx: W / 2, cy: H / 2 };