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
+32
View File
@@ -666,6 +666,38 @@ body {
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
it("accepts object-literal timeline registration and extracts its keys", async () => {
const html = `
<html><body>
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines = { "comp-1": tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "timeline_registry_missing_init"),
).toBeUndefined();
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
});
it("reports mismatched object-literal timeline registration keys", async () => {
const html = `
<html><body>
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeDefined();
expect(finding?.message).toContain('Timeline registered as "main"');
});
});
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
+3 -1
View File
@@ -9,6 +9,7 @@ import {
getInlineScriptSyntaxError,
TIMELINE_REGISTRY_INIT_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
INVALID_SCRIPT_CLOSE_PATTERN,
} from "../utils";
@@ -252,7 +253,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const findings: HyperframeLintFinding[] = [];
if (
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
!TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)
) {
findings.push({
code: "missing_timeline_registry",
+107 -78
View File
@@ -26,7 +26,9 @@ import {
readAttr,
truncateSnippet,
stripJsComments,
hasCaptionStyles,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
} from "../utils";
// ── GSAP-specific types ────────────────────────────────────────────────────
@@ -274,6 +276,17 @@ function findContainingCompositionId(tag: OpenTag, ranges: CompositionRange[]):
return match?.id || null;
}
// A tag's `class` attribute, split into tokens, but only when it carries the
// `clip` marker class — the common "is this a clip element?" filter used by
// several rules that walk every tag looking for clips.
type ClipTagClasses = { classAttr: string; classes: string[] };
function getClipTagClasses(tag: OpenTag): ClipTagClasses | null {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
return classes.includes("clip") ? { classAttr, classes } : null;
}
function collectClipStartBoundariesByComposition(
source: string,
tags: OpenTag[],
@@ -282,9 +295,7 @@ function collectClipStartBoundariesByComposition(
const boundaries = new Map<string, Set<number>>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
if (!classes.includes("clip")) continue;
if (!getClipTagClasses(tag)) continue;
const compositionId = findContainingCompositionId(tag, ranges);
if (!compositionId) continue;
const start = numberValue(readAttr(tag.raw, "data-start") ?? undefined);
@@ -507,6 +518,32 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[
return calls;
}
// Run a global regex over every script's content, yielding each match plus a
// context-padded snippet around it. Shared by the repeat-count and
// group-selector-keyframes rules below, which differ only in the pattern,
// whether comments are stripped first, and the context window size.
function scanScriptsForRegexMatches(
scripts: LintContext["scripts"],
pattern: RegExp,
options: { stripComments: boolean; contextBefore: number; contextAfter: number },
): Array<{ match: RegExpExecArray; snippet: string }> {
const hits: Array<{ match: RegExpExecArray; snippet: string }> = [];
for (const script of scripts) {
const content = options.stripComments ? stripJsComments(script.content) : script.content;
const regex = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - options.contextBefore);
const contextEnd = Math.min(
content.length,
match.index + match[0].length + options.contextAfter,
);
hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
}
}
return hits;
}
// ── GSAP rules ─────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -521,17 +558,16 @@ export const gsapRules: LintRule<LintContext>[] = [
const clipIds = new Map<string, ClipInfo>();
const clipClasses = new Map<string, ClipInfo>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
if (!classes.includes("clip")) continue;
const clipTag = getClipTagClasses(tag);
if (!clipTag) continue;
const id = readAttr(tag.raw, "id");
const info: ClipInfo = {
tag: tag.name,
id: id || "",
classes: classAttr,
classes: clipTag.classAttr,
};
if (id) clipIds.set(`#${id}`, info);
for (const cls of classes) {
for (const cls of clipTag.classes) {
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
}
}
@@ -859,8 +895,7 @@ export const gsapRules: LintRule<LintContext>[] = [
// fallow-ignore-next-line complexity
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
if (!hasCaptionStyles(styles)) return findings;
for (const script of scripts) {
const content = script.content;
@@ -904,28 +939,25 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_infinite_repeat
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
// Match repeat: -1 in GSAP tweens or timeline configs
const pattern = /repeat\s*:\s*-1(?!\d)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - 60);
const contextEnd = Math.min(content.length, match.index + match[0].length + 60);
const snippet = content.slice(contextStart, contextEnd).trim();
findings.push({
code: "gsap_infinite_repeat",
severity: "error",
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
snippet: truncateSnippet(snippet),
});
}
// Match repeat: -1 in GSAP tweens or timeline configs
const pattern = /repeat\s*:\s*-1(?!\d)/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: true,
contextBefore: 60,
contextAfter: 60,
})) {
findings.push({
code: "gsap_infinite_repeat",
severity: "error",
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
@@ -933,29 +965,26 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_repeat_ceil_overshoot
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = script.content;
// Match patterns like: repeat: Math.ceil(duration / X) - 1
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - 40);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
const snippet = content.slice(contextStart, contextEnd).trim();
findings.push({
code: "gsap_repeat_ceil_overshoot",
severity: "warning",
message:
"GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
// Match patterns like: repeat: Math.ceil(duration / X) - 1
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: false,
contextBefore: 40,
contextAfter: 40,
})) {
findings.push({
code: "gsap_repeat_ceil_overshoot",
severity: "warning",
message:
"GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
@@ -1009,7 +1038,9 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const script of scripts) {
const content = script.content;
if (!/gsap\.timeline/.test(content)) continue;
const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content);
const hasRegistration =
WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) ||
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
if (hasRegistration || canInheritFromHost) continue;
findings.push({
code: "gsap_timeline_not_registered",
@@ -1126,28 +1157,26 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_group_selector_keyframes
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const selector = match[1]!;
const count = selector.split(",").length;
const contextStart = Math.max(0, match.index - 20);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(content.slice(contextStart, contextEnd)),
});
}
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
for (const { match, snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: true,
contextBefore: 20,
contextAfter: 40,
})) {
const selector = match[1]!;
const count = selector.split(",").length;
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(snippet),
});
}
return findings;
},
+31
View File
@@ -21,6 +21,11 @@ export const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
export const TIMELINE_REGISTRY_INIT_PATTERN =
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
// Object-literal registration that assigns at least one `key: value` entry inline,
// e.g. `window.__timelines = { main: tl }` or `window.__timelines = { "comp-1": tl }`.
// Distinct from the empty-init form (`= {}`) — requires a key followed by `:`.
export const TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN =
/window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
export const TIMELINE_REGISTRY_ASSIGN_PATTERN =
/window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
export const WINDOW_TIMELINE_ASSIGN_PATTERN =
@@ -30,6 +35,14 @@ export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*scrip
const TIMELINE_REGISTRY_KEY_PATTERN =
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
// The `window.__timelines = { ... }` object-literal body (group 1), captured so its
// `key: value` entries can be scanned for registered keys.
const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
// A single object-literal entry whose value is an identifier (real timeline registration),
// e.g. `main: tl` or `"comp-1": tl`. Captures the key in group 1 (quoted) or 2 (bare).
const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
/(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
export function extractOpenTags(source: string): OpenTag[] {
const tags: OpenTag[] = [];
let match: RegExpExecArray | null;
@@ -177,6 +190,17 @@ export function extractTimelineRegistryKeys(source: string): string[] {
const key = match[1] ?? match[2];
if (key) keys.add(key);
}
const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1];
if (objectBody) {
const entryPattern = new RegExp(
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags,
);
while ((match = entryPattern.exec(objectBody)) !== null) {
const key = match[1] ?? match[2];
if (key) keys.add(key);
}
}
return [...keys];
}
@@ -300,6 +324,13 @@ export function isMediaTag(tagName: string): boolean {
return tagName === "video" || tagName === "audio" || tagName === "img";
}
// Whether any <style> block in the composition defines caption group/word
// classes (`.caption-group`, `.caption_word`, etc.) — the signal several
// caption-specific rules use to skip non-caption compositions entirely.
export function hasCaptionStyles(styles: ExtractedBlock[]): boolean {
return styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
}
export function truncateSnippet(value: string, maxLength = 220): string | undefined {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) return undefined;