feat(cli): keyframes command (surface GSAP/CSS/Anime keyframes + 3D onion-skin --shot) (#1603)

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.
This commit is contained in:
Miguel Ángel
2026-07-01 19:51:55 -07:00
committed by GitHub
parent 145c71e837
commit a908af11a8
34 changed files with 3973 additions and 159 deletions
+137 -8
View File
@@ -10,8 +10,42 @@ import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
import { findFFmpeg } from "../browser/ffmpeg.js";
import { parseAngle, type Camera } from "./motionShotLayout.js";
import type { Example } from "./_examples.js";
// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so
// the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]:
// snapshot only captured the composition's own head-on camera, so 3D depth /
// occlusion couldn't be verified). Same approach as motionShot's orbit camera:
// make the composition root + its ancestor chain preserve-3d, strip intermediate
// perspective, put one perspective on the root's parent (the lens) and rotate
// the root — works on any composition shape (no #stage assumption).
//
// Kept as a self-contained copy of motionShot.ts's `applyOrbitCamera` because
// that one is module-private; this is ~15 lines and sharing it would mean
// touching motionShot.ts (out of scope for this change).
function orbitStageSource(): string {
return `function(cam) {
var root = document.querySelector("[data-composition-id]")
|| document.querySelector("#stage")
|| document.body.firstElementChild
|| document.body;
var n = root;
while (n && n !== document.body) {
n.style.transformStyle = "preserve-3d";
n.style.perspective = "none";
n = n.parentElement;
}
root.style.transformStyle = "preserve-3d";
root.style.perspective = "none";
root.style.transformOrigin = "50% 50%";
root.style.transform = "rotateX(" + cam.pitch + "deg) rotateY(" + cam.yaw + "deg)";
var lens = root.parentElement || document.body;
lens.style.perspective = "1600px";
lens.style.perspectiveOrigin = "50% 50%";
}`;
}
/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
* default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological
* clip (corrupt media, stalled network mount, codec edge case) cannot wedge
@@ -87,15 +121,76 @@ async function extractVideoFrameToBuffer(
export const examples: Example[] = [
["Capture 5 key frames from a composition", "snapshot capture"],
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
];
/**
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
* artifact: t=8.0 of an 8s clip was pure white, t=7.76 showed the final hero).
* So the "final frame" must be sampled just-before-end. The blank tail observed
* spanned the last ~2.5% of the timeline, hence a 3%-of-duration nudge (floored
* at 50ms so very short clips still back off a readable amount).
*/
export function tailFrameTime(duration: number): number {
return Math.max(0, duration - Math.max(0.05, duration * 0.03));
}
/**
* Pick the seek positions to screenshot. Pure so the "tail is always captured"
* guarantee is unit-testable (FINDING [7]: evenly-spaced --at times skipped the
* final beat and short hero beats with no signal).
*
* - No --at: evenly-spaced frames, but the LAST point is moved off the exact
* duration to `tailFrameTime` so it isn't blank.
* - With --at: the user's exact times are honoured, plus a guaranteed
* end-of-timeline frame appended (unless `includeEnd` is false), so the tail
* is never silently skipped. A near-duplicate of the tail is not added twice.
*
* `appendedTail` flags that the readable-tail frame was added on top of the
* caller's request — used to warn that short sub-interval beats between samples
* may still be missed and need explicit --at.
*/
export function computeSnapshotTimes(
duration: number,
opts: { frames: number; at?: number[]; includeEnd?: boolean },
): { times: number[]; appendedTail: boolean } {
const includeEnd = opts.includeEnd !== false;
const tail = tailFrameTime(duration);
const round = (t: number) => Math.round(t * 1000) / 1000;
if (opts.at?.length) {
const times = opts.at.map(round);
// Only append if the user didn't already sample at/near the readable tail.
const hasTail = times.some((t) => Math.abs(t - tail) < 0.05 || t >= duration);
if (includeEnd && duration > 0 && !hasTail) {
return { times: [...times, round(tail)], appendedTail: true };
}
return { times, appendedTail: false };
}
const n = opts.frames;
if (n <= 1) return { times: [round(duration / 2)], appendedTail: false };
const times = Array.from({ length: n }, (_, i) => (i / (n - 1)) * duration);
// Replace the final (exact-duration, blank) point with the readable tail.
if (includeEnd) times[times.length - 1] = tail;
return { times: times.map(round), appendedTail: false };
}
/**
* Render key frames from a composition as PNG screenshots.
* The agent can Read these to verify its output visually.
*/
async function captureSnapshots(
projectDir: string,
opts: { frames?: number; timeout?: number; at?: number[]; outputDir?: string },
opts: {
frames?: number;
timeout?: number;
at?: number[];
outputDir?: string;
angle?: Camera;
includeEnd?: boolean;
},
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
@@ -226,12 +321,25 @@ async function captureSnapshots(
return [];
}
// Calculate seek positions — explicit timestamps or evenly spaced
const positions: number[] = opts.at?.length
? opts.at
: numFrames === 1
? [duration / 2]
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
// Calculate seek positions — explicit timestamps or evenly spaced, always
// including a readable end-of-timeline frame (FINDING [7]).
const { times: positions, appendedTail } = computeSnapshotTimes(duration, {
frames: numFrames,
at: opts.at,
includeEnd: opts.includeEnd,
});
if (appendedTail) {
console.log(
` ${c.dim(`Note: added an end-of-timeline frame at ${positions[positions.length - 1]!.toFixed(2)}s. Short beats between your --at times may still be skipped — pass them explicitly.`)}`,
);
}
// Orthogonal camera (FINDING [10]) — re-applied after each seek inside the
// loop, since renderSeek may touch the stage's inline transform.
const cameraExpr =
opts.angle && (opts.angle.yaw !== 0 || opts.angle.pitch !== 0)
? `(${orbitStageSource()})(${JSON.stringify(opts.angle)})`
: null;
const snapshotDir = opts.outputDir ?? join(projectDir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
@@ -319,6 +427,8 @@ async function captureSnapshots(
requestAnimationFrame(function() { requestAnimationFrame(finish); });
})`);
if (cameraExpr) await page.evaluate(cameraExpr);
if (injectVideoFramesBatch && syncVideoFrameVisibility) {
const active = await page.evaluate((t: number) => {
return Array.from(document.querySelectorAll("video[data-start]"))
@@ -460,6 +570,17 @@ export default defineCommand({
description: "Ms to wait for runtime to initialize (default: 5000)",
default: "5000",
},
angle: {
type: "string",
description:
"Orthogonal 3D camera for depth/occlusion checks: a preset (front|iso|top|side) or 'yaw,pitch' degrees. Tilts the whole stage before screenshotting (real pixels, not bbox markers).",
},
end: {
type: "boolean",
description:
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
default: true,
},
describe: {
type: "string",
description:
@@ -487,10 +608,16 @@ export default defineCommand({
? null
: String(args.describe);
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
: `${frames} frames`;
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}`);
const angleLabel =
camera && (camera.yaw !== 0 || camera.pitch !== 0)
? ` ${c.dim(`(angle yaw ${camera.yaw}° pitch ${camera.pitch}°)`)}`
: "";
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}${angleLabel}`);
try {
const snapshotDir = args.output
@@ -501,6 +628,8 @@ export default defineCommand({
timeout,
at: atTimestamps,
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
});
if (paths.length === 0) {