mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
Renames the motion-surfacing tool from `hyperframes keyframes` to `hyperframes motion`, renames the implementation from keyframes*.ts to motion*.ts (keeping the keyframe data model name where still accurate), and renames the shipped skill from hyperframes-keyframes to hyperframes-motion. Expands the skill from a command reference into a full motion-design workflow: reading motion, 3D angle verification, layered GSAP motion, one-shot reference reproduction, diagnostic checks, and eval-derived craft guidance.
246 lines
8.8 KiB
TypeScript
246 lines
8.8 KiB
TypeScript
// Pure, Node-side geometry + SVG generation for the onion-skin motion shot.
|
||
//
|
||
// The headless step (motionShot.ts) only SAMPLES — it seeks the live timeline
|
||
// and reads each element's projected corners. Everything else (which times to
|
||
// sample, how to fit/lay them out, and the SVG markup) lives here as pure
|
||
// functions so it can be unit-tested without a browser.
|
||
|
||
export interface Pt {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
/** One time-sample of one element: its 4 projected corners, centre, colour, opacity. */
|
||
export interface OnionSample {
|
||
t: number;
|
||
q: Pt[];
|
||
c: Pt;
|
||
color: string;
|
||
opacity: number;
|
||
}
|
||
|
||
export interface OnionElement {
|
||
selector: string;
|
||
samples: OnionSample[];
|
||
}
|
||
|
||
export type ShotLayout = "path" | "strip";
|
||
|
||
export interface ShotLayoutOptions {
|
||
layout: ShotLayout;
|
||
fit: boolean;
|
||
width: number;
|
||
height: number;
|
||
/** Caption drawn top-left (camera / framing / window info). */
|
||
label?: string;
|
||
}
|
||
|
||
export interface Camera {
|
||
yaw: number;
|
||
pitch: number;
|
||
}
|
||
|
||
const ANGLE_PRESETS: Record<string, [number, number]> = {
|
||
front: [0, 0],
|
||
iso: [30, -22],
|
||
top: [0, -68],
|
||
side: [78, 0],
|
||
"rear-iso": [205, -22],
|
||
};
|
||
|
||
/** Parse an angle preset name or "yaw,pitch" degrees into a Camera. */
|
||
export function parseAngle(a?: string): Camera {
|
||
if (!a) return { yaw: 0, pitch: 0 };
|
||
const preset = ANGLE_PRESETS[a];
|
||
if (preset) return { yaw: preset[0], pitch: preset[1] };
|
||
const [y, p] = a.split(",").map((n) => Number.parseFloat(n));
|
||
return { yaw: Number.isFinite(y) ? y! : 0, pitch: Number.isFinite(p) ? p! : 0 };
|
||
}
|
||
|
||
/** Resolve which animated selectors a `--shot --selector SCOPE` should sample.
|
||
*
|
||
* The scope element is often a STATIC wrapper (the standard `.clip` root) whose
|
||
* animated CHILDREN carry the tweens — so a literal match against animated
|
||
* targets finds nothing. We fall back to the animated descendants of the scope:
|
||
*
|
||
* 1. scope itself is animated → sample just scope (exact selection)
|
||
* 2. scope is static but has animated → sample those descendants
|
||
* descendants (e.g. `.clip` wrapper)
|
||
* 3. scope contains nothing animated → sample [] (caller errors, naming
|
||
* the nearest animated elements)
|
||
*
|
||
* `isDescendant(scope, target)` is supplied by the caller (DOM-aware in the
|
||
* browser); kept as a param so this decision is pure and unit-testable.
|
||
*/
|
||
export function resolveShotSelectors(
|
||
scope: string,
|
||
animated: string[],
|
||
isDescendant: (scope: string, target: string) => boolean,
|
||
): string[] {
|
||
if (animated.includes(scope)) return [scope];
|
||
return animated.filter((target) => isDescendant(scope, target));
|
||
}
|
||
|
||
/** N equal-time sample points across [from?, to?] within [0, dur]. */
|
||
export function sampleTimes(
|
||
dur: number,
|
||
n: number,
|
||
from: number | null,
|
||
to: number | null,
|
||
): number[] {
|
||
const t0 = from != null ? Math.max(0, Math.min(from, dur)) : 0;
|
||
const t1 = to != null ? Math.max(0, Math.min(to, dur)) : dur;
|
||
const count = Math.max(1, Math.floor(n));
|
||
if (count === 1) return [t0];
|
||
return Array.from({ length: count }, (_, i) => {
|
||
const t = t0 + (i / (count - 1)) * (t1 - t0);
|
||
return Math.round(t * 1000) / 1000;
|
||
});
|
||
}
|
||
|
||
/** 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. */
|
||
export function ghostAlphas(n: number): number[] {
|
||
if (n <= 0) return [];
|
||
if (n === 1) return [1];
|
||
const lo = 0.14;
|
||
return Array.from(
|
||
{ length: n },
|
||
(_, i) => Math.round((lo + (1 - lo) * (i / (n - 1))) * 1000) / 1000,
|
||
);
|
||
}
|
||
|
||
/** Scale+centre transform that fits `pts` into a W×H frame (with padding). */
|
||
export function fitTransform(
|
||
pts: Pt[],
|
||
width: number,
|
||
height: number,
|
||
): { k: number; cx: number; cy: number } {
|
||
if (pts.length === 0) return { k: 1, cx: width / 2, cy: height / 2 };
|
||
const xs = pts.map((p) => p.x);
|
||
const ys = pts.map((p) => p.y);
|
||
const minX = Math.min(...xs);
|
||
const maxX = Math.max(...xs);
|
||
const minY = Math.min(...ys);
|
||
const maxY = Math.max(...ys);
|
||
const cx = (minX + maxX) / 2;
|
||
const cy = (minY + maxY) / 2;
|
||
const span = Math.max(maxX - minX, maxY - minY, 1);
|
||
const k = Math.max(0.3, Math.min(7, (Math.min(width, height) * 0.8) / span));
|
||
return { k, cx, cy };
|
||
}
|
||
|
||
/** Grid geometry for the filmstrip layout. */
|
||
export function stripCells(n: number, width: number, height: number) {
|
||
const cols = n <= 5 ? Math.max(1, n) : Math.ceil(Math.sqrt(n));
|
||
const rows = Math.ceil(n / cols);
|
||
return { cols, rows, cellW: width / cols, cellH: height / rows };
|
||
}
|
||
|
||
const timeColor = (f: number) => `hsl(${190 + f * 150} 90% 65%)`;
|
||
|
||
const attrs = (o: Record<string, string | number>) =>
|
||
Object.entries(o)
|
||
.map(([k, v]) => `${k}="${v}"`)
|
||
.join(" ");
|
||
|
||
const polygon = (corners: Pt[], fill: string, fillOpacity: number, stroke: string) =>
|
||
`<polygon ${attrs({
|
||
points: corners.map((p) => `${round(p.x)},${round(p.y)}`).join(" "),
|
||
fill,
|
||
"fill-opacity": fillOpacity.toFixed(2),
|
||
stroke,
|
||
"stroke-width": 2.5,
|
||
"stroke-linejoin": "round",
|
||
})}/>`;
|
||
|
||
const line = (a: Pt, b: Pt, stroke: string, w: number, o: number) =>
|
||
`<line ${attrs({ x1: round(a.x), y1: round(a.y), x2: round(b.x), y2: round(b.y), stroke, "stroke-width": w, opacity: o, "stroke-linecap": "round" })}/>`;
|
||
|
||
const circle = (p: Pt, r: number, fill: string) =>
|
||
`<circle ${attrs({ cx: round(p.x), cy: round(p.y), r, fill })}/>`;
|
||
|
||
const text = (p: Pt, s: string, fill: string, size = 15) =>
|
||
`<text ${attrs({ x: round(p.x), y: round(p.y), fill, "font-family": "ui-monospace,monospace", "font-size": size, "font-weight": 600 })}>${escapeXml(s)}</text>`;
|
||
|
||
const round = (n: number) => Math.round(n * 100) / 100;
|
||
const escapeXml = (s: string) =>
|
||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
|
||
const ghost = (corners: Pt[], center: Pt, color: string, opacity: number, f: number): string => {
|
||
const tickEnd = {
|
||
x: (corners[0]!.x + corners[1]!.x) / 2,
|
||
y: (corners[0]!.y + corners[1]!.y) / 2,
|
||
};
|
||
return (
|
||
polygon(corners, color, Math.max(0.08, opacity * 0.42), timeColor(f)) +
|
||
line(center, tickEnd, timeColor(f), 3, 0.9)
|
||
);
|
||
};
|
||
|
||
/** Build the full onion-skin SVG overlay markup from sampled elements. */
|
||
export function buildOnionSvg(elements: OnionElement[], opt: ShotLayoutOptions): string {
|
||
const { width: W, height: H } = opt;
|
||
let body = "";
|
||
|
||
if (opt.layout === "strip") {
|
||
body = stripBody(elements[0]?.samples ?? [], W, H);
|
||
} else {
|
||
body = pathBody(elements, opt.fit, W, H);
|
||
}
|
||
|
||
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 };
|
||
const M = (p: Pt): Pt => ({ x: (p.x - cx) * k + W / 2, y: (p.y - cy) * k + H / 2 });
|
||
let out = "";
|
||
for (const el of elements) {
|
||
const last = el.samples.length - 1;
|
||
const fOf = (i: number) => (last <= 0 ? 0 : i / last);
|
||
el.samples.forEach((s, i) => (out += ghost(s.q.map(M), M(s.c), s.color, s.opacity, fOf(i))));
|
||
for (let i = 0; i < last; i++)
|
||
out += line(M(el.samples[i]!.c), M(el.samples[i + 1]!.c), timeColor(fOf(i)), 3.5, 0.85);
|
||
el.samples.forEach((s, i) => {
|
||
const c = M(s.c);
|
||
out += circle(c, 4, timeColor(fOf(i)));
|
||
out += text({ x: c.x + 10, y: c.y + (i % 2 === 0 ? -10 : 18) }, `${s.t}s`, timeColor(fOf(i)));
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stripBody(samples: OnionSample[], W: number, H: number): string {
|
||
if (samples.length === 0) return "";
|
||
const { cols, cellW, cellH } = stripCells(samples.length, W, H);
|
||
let maxExt = 1;
|
||
for (const s of samples)
|
||
for (const p of s.q) maxExt = Math.max(maxExt, Math.hypot(p.x - s.c.x, p.y - s.c.y));
|
||
const cellScale = (Math.min(cellW, cellH) * 0.62) / maxExt;
|
||
const last = samples.length - 1;
|
||
let out = "";
|
||
samples.forEach((s, i) => {
|
||
const col = i % cols;
|
||
const row = Math.floor(i / cols);
|
||
const cc = { x: cellW * (col + 0.5), y: cellH * (row + 0.5) };
|
||
const f = last <= 0 ? 0 : i / last;
|
||
out += `<rect ${attrs({ x: round(col * cellW + 3), y: round(row * cellH + 3), width: round(cellW - 6), height: round(cellH - 6), fill: "none", stroke: "#1c2531", "stroke-width": 1, rx: 8 })}/>`;
|
||
const corners = s.q.map((p) => ({
|
||
x: cc.x + (p.x - s.c.x) * cellScale,
|
||
y: cc.y + (p.y - s.c.y) * cellScale,
|
||
}));
|
||
out += ghost(corners, cc, s.color, s.opacity, f);
|
||
out += text({ x: col * cellW + 12, y: row * cellH + 24 }, `${s.t}s`, timeColor(f), 16);
|
||
});
|
||
return out;
|
||
}
|