mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user