mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(cli): render SVG selector proof strips
This commit is contained in:
@@ -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>';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user