initial code (#2)

* feat: initial code port from hyperframes-internal

Port all OSS-ready packages from the internal monorepo:
- @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime
- @hyperframes/cli — CLI for creating, previewing, and rendering compositions
- @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg)
- @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg)
- @hyperframes/ui-player — browser-based video player component
- @hyperframes/studio — composition editor (React frontend + Hono backend)

Includes regression test suite with Docker-based test harness.

All HeyGen-internal references, deployment infrastructure, and
proprietary assets have been removed. Package names migrated
from @app/* to @hyperframes/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scrub internal codenames and stale references from OSS port

- Replace static.heygen.ai runtime URLs in test fixtures
- Remove internal CDN publish script (publish-hyperframe-runtime.ts)
- Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime
  with neutral names (studio, hyperframe-runtime, __hyperframeRuntime)
- Fix stale Vault API / localhost references in docs
- Remove broken deprecated_studio link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove remaining internal codenames and stale references

- Delete stale producer README.md and PIPELINE.md (referenced nonexistent files)
- Replace "Cerberus" codename with "HyperFrames" in test design reviews
- Replace magic-edit postMessage identifiers with hf-preview/hf-parent
- Rename debug-magic-edit-timeline.ts to debug-timeline.ts
- Replace "Motion Cut" with "HyperFrames" in Timeline comments
- Fix studio/CLI references to nonexistent archive package
  (use local data/projects/ dir, stub render proxy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-21 22:43:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 10621e7903
commit 9f8e5ba5a1
401 changed files with 54545 additions and 2 deletions
@@ -0,0 +1,532 @@
import { describe, it, expect } from "vitest";
import {
parseGsapScript,
gsapAnimationsToKeyframes,
SUPPORTED_PROPS,
SUPPORTED_EASES,
serializeGsapAnimations,
validateCompositionGsap,
getAnimationsForElement,
keyframesToGsapAnimations,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
describe("parseGsapScript", () => {
it("parses a basic timeline with .to()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("tl");
expect(result.animations).toHaveLength(1);
expect(result.animations[0].method).toBe("to");
expect(result.animations[0].targetSelector).toBe("#el1");
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].duration).toBe(0.5);
expect(result.animations[0].position).toBe(0);
});
it("parses a timeline with .from()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.from("#el2", { x: 100, duration: 1 }, 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].method).toBe("from");
expect(result.animations[0].targetSelector).toBe("#el2");
expect(result.animations[0].properties.x).toBe(100);
expect(result.animations[0].duration).toBe(1);
expect(result.animations[0].position).toBe(0.5);
});
it("parses a timeline with .set()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#el3", { opacity: 0, x: 50 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].method).toBe("set");
expect(result.animations[0].targetSelector).toBe("#el3");
expect(result.animations[0].properties.opacity).toBe(0);
expect(result.animations[0].properties.x).toBe(50);
expect(result.animations[0].duration).toBeUndefined();
});
it("parses a timeline with .fromTo() and position offset", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el4", { opacity: 0, x: 100 }, { opacity: 1, x: 200, duration: 1 }, 2);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.method).toBe("fromTo");
expect(anim.targetSelector).toBe("#el4");
expect(anim.fromProperties).toBeDefined();
expect(anim.fromProperties?.opacity).toBe(0);
expect(anim.fromProperties?.x).toBe(100);
expect(anim.properties.opacity).toBe(1);
expect(anim.properties.x).toBe(200);
expect(anim.duration).toBe(1);
expect(anim.position).toBe(2);
});
it("parseObjectLiteral does not match negative numbers (known limitation)", () => {
// The regex in parseObjectLiteral only matches [\d.]+, not negative numbers.
// Negative values like x: -100 won't be parsed by the object literal parser.
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el5", { opacity: 0, x: -100 }, { opacity: 1, x: 0, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.fromProperties).toBeDefined();
expect(anim.fromProperties?.opacity).toBe(0);
// -100 is not parseable by the regex, so x won't be in fromProperties
expect(anim.fromProperties?.x).toBeUndefined();
});
it("handles an empty script", () => {
const result = parseGsapScript("");
expect(result.animations).toHaveLength(0);
expect(result.timelineVar).toBe("tl");
expect(result.preamble).toBe("const tl = gsap.timeline({ paused: true });");
expect(result.postamble).toBe("");
});
it("extracts preamble correctly", () => {
const script = `
const myTl = gsap.timeline({ paused: true });
myTl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("myTl");
expect(result.preamble).toContain("const myTl = gsap.timeline");
});
it("extracts postamble correctly", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
console.log("done");
`;
const result = parseGsapScript(script);
expect(result.postamble).toContain('console.log("done");');
});
it("parses multiple animations", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#el1", { opacity: 0 }, 0);
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 1);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
expect(result.animations[0].method).toBe("set");
expect(result.animations[1].method).toBe("to");
expect(result.animations[2].method).toBe("to");
});
it("filters out unsupported properties from animations", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, backgroundColor: "red", x: 50, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].properties.x).toBe(50);
// backgroundColor is not in SUPPORTED_PROPS, so it's filtered out
expect(result.animations[0].properties.backgroundColor).toBeUndefined();
});
it("extracts ease from properties", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1, ease: "power2.out" }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].ease).toBe("power2.out");
});
it("uses 'let' or 'var' for timeline declaration", () => {
const script = `
let timeline = gsap.timeline({ paused: true });
timeline.to("#el1", { opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("timeline");
expect(result.animations).toHaveLength(1);
});
});
describe("gsapAnimationsToKeyframes", () => {
it("converts animations to keyframes with element start offset", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 2,
properties: { x: 100, y: 200 },
},
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 3,
properties: { x: 300, y: 400 },
duration: 1,
ease: "power2.out",
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 2);
expect(keyframes).toHaveLength(2);
// First keyframe: time = 2 - 2 = 0
expect(keyframes[0].time).toBe(0);
expect(keyframes[0].properties.x).toBe(100);
expect(keyframes[0].properties.y).toBe(200);
// Second keyframe: time = 3 - 2 = 1
expect(keyframes[1].time).toBe(1);
expect(keyframes[1].properties.x).toBe(300);
expect(keyframes[1].ease).toBe("power2.out");
});
it("filters supported props only", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: 0,
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<string, number | string>,
duration: 1,
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 0);
expect(keyframes).toHaveLength(1);
expect(keyframes[0].properties.opacity).toBe(1);
expect(keyframes[0].properties.x).toBe(50);
// String values are skipped (typeof value !== "number" check)
expect((keyframes[0].properties as Record<string, unknown>).someUnsupportedProp).toBeUndefined();
});
it("skips base set keyframes at time 0 when skipBaseSet is true", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 5,
properties: { x: 0, y: 0, scale: 1 },
},
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 6,
properties: { x: 100 },
duration: 1,
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
// The set at position 5 (time=0) with x=0, y=0, scale=1 (base values) should be skipped
expect(keyframes).toHaveLength(1);
expect(keyframes[0].id).toBe("anim-2");
});
it("does NOT skip set keyframes when they have non-base values", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 5,
properties: { x: 100, y: 0 },
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
// x=100 is non-base, so it should NOT be skipped
expect(keyframes).toHaveLength(1);
expect(keyframes[0].properties.x).toBe(100);
});
it("clamps negative time to zero by default", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 1 },
},
];
// elementStartTime is 5, so relative time = 0 - 5 = -5
const keyframes = gsapAnimationsToKeyframes(animations, 5);
expect(keyframes[0].time).toBe(0); // Clamped to 0
});
it("adjusts x/y/scale relative to base values", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: 2,
properties: { x: 150, y: 200, scale: 2 },
duration: 1,
},
];
const keyframes = gsapAnimationsToKeyframes(animations, 0, {
baseX: 50,
baseY: 100,
baseScale: 2,
});
expect(keyframes[0].properties.x).toBe(100); // 150 - 50
expect(keyframes[0].properties.y).toBe(100); // 200 - 100
expect(keyframes[0].properties.scale).toBe(1); // 2 / 2
});
});
describe("keyframesToGsapAnimations", () => {
it("converts keyframes back to GSAP animations", () => {
const keyframes: Keyframe[] = [
{ id: "kf-1", time: 0, properties: { opacity: 0 } },
{ id: "kf-2", time: 1, properties: { opacity: 1 }, ease: "power2.out" },
];
const animations = keyframesToGsapAnimations("el1", keyframes, 2);
expect(animations).toHaveLength(2);
expect(animations[0].method).toBe("set");
expect(animations[0].position).toBe(2); // elementStartTime + 0
expect(animations[0].properties.opacity).toBe(0);
expect(animations[1].method).toBe("to");
expect(animations[1].position).toBe(2); // position of prev keyframe
expect(animations[1].duration).toBe(1); // kf.time - prevKf.time
expect(animations[1].ease).toBe("power2.out");
});
it("applies base x/y/scale offsets", () => {
const keyframes: Keyframe[] = [
{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } },
];
const animations = keyframesToGsapAnimations("el1", keyframes, 0, {
x: 50,
y: 100,
scale: 0.5,
});
expect(animations[0].properties.x).toBe(60); // baseX + value
expect(animations[0].properties.y).toBe(120); // baseY + value
expect(animations[0].properties.scale).toBe(1); // baseScale * value
});
});
describe("serializeGsapAnimations", () => {
it("serializes animations into a GSAP timeline script", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 0 },
},
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 0.5,
properties: { opacity: 1 },
duration: 0.5,
ease: "power2.out",
},
];
const result = serializeGsapAnimations(animations);
expect(result).toContain("const tl = gsap.timeline({ paused: true });");
expect(result).toContain('tl.set("#el1"');
expect(result).toContain('tl.to("#el1"');
expect(result).toContain("opacity: 0");
expect(result).toContain("opacity: 1");
});
it("sorts animations by position", () => {
const animations: GsapAnimation[] = [
{
id: "anim-2",
targetSelector: "#el1",
method: "to",
position: 2,
properties: { opacity: 1 },
duration: 0.5,
},
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 0 },
},
];
const result = serializeGsapAnimations(animations);
const setIdx = result.indexOf("tl.set");
const toIdx = result.indexOf("tl.to");
expect(setIdx).toBeLessThan(toIdx);
});
it("serializes fromTo animations correctly", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "fromTo",
position: 0,
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
duration: 1,
},
];
const result = serializeGsapAnimations(animations);
expect(result).toContain('tl.fromTo("#el1"');
});
it("uses custom timeline variable name", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "set",
position: 0,
properties: { opacity: 0 },
},
];
const result = serializeGsapAnimations(animations, "myTimeline");
expect(result).toContain("const myTimeline = gsap.timeline({ paused: true });");
expect(result).toContain('myTimeline.set("#el1"');
});
});
describe("validateCompositionGsap", () => {
it("returns valid for clean scripts", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1 }, 0);
`;
const result = validateCompositionGsap(script);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("detects forbidden patterns", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1, onComplete: function() {} }, 0);
setTimeout(function() {}, 100);
`;
const result = validateCompositionGsap(script);
expect(result.valid).toBe(false);
expect(result.errors).toContain("onComplete callback not allowed");
expect(result.errors).toContain("setTimeout not allowed");
});
it("warns about yoyo and stagger", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { x: 100, stagger: 0.1, yoyo: true, duration: 1 }, 0);
`;
const result = validateCompositionGsap(script);
expect(result.warnings).toContain("yoyo animations may behave unexpectedly when scrubbing");
expect(result.warnings).toContain("stagger animations may not serialize correctly");
});
it("detects infinite repeat", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 1, repeat: -1 }, 0);
`;
const result = validateCompositionGsap(script);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Infinite repeat (repeat: -1) not allowed");
});
});
describe("getAnimationsForElement", () => {
it("filters animations by element id", () => {
const animations: GsapAnimation[] = [
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
{ id: "a2", targetSelector: "#el2", method: "to", position: 0, properties: { opacity: 1 }, duration: 1 },
{ id: "a3", targetSelector: "#el1", method: "to", position: 1, properties: { opacity: 1 }, duration: 0.5 },
];
const result = getAnimationsForElement(animations, "el1");
expect(result).toHaveLength(2);
expect(result.every((a) => a.targetSelector === "#el1")).toBe(true);
});
it("returns empty array when no animations match", () => {
const animations: GsapAnimation[] = [
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
];
const result = getAnimationsForElement(animations, "el99");
expect(result).toHaveLength(0);
});
});
describe("SUPPORTED_PROPS", () => {
it("includes expected properties", () => {
expect(SUPPORTED_PROPS).toContain("opacity");
expect(SUPPORTED_PROPS).toContain("x");
expect(SUPPORTED_PROPS).toContain("y");
expect(SUPPORTED_PROPS).toContain("scale");
expect(SUPPORTED_PROPS).toContain("rotation");
expect(SUPPORTED_PROPS).toContain("width");
expect(SUPPORTED_PROPS).toContain("height");
});
});
describe("SUPPORTED_EASES", () => {
it("includes common easing functions", () => {
expect(SUPPORTED_EASES).toContain("none");
expect(SUPPORTED_EASES).toContain("power2.out");
expect(SUPPORTED_EASES).toContain("bounce.out");
expect(SUPPORTED_EASES).toContain("elastic.inOut");
});
});
+508
View File
@@ -0,0 +1,508 @@
import type { Keyframe, KeyframeProperties } from "../core.types";
export type GsapMethod = "set" | "to" | "from" | "fromTo";
export interface GsapAnimation {
id: string;
targetSelector: string;
method: GsapMethod;
position: number;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
}
export interface ParsedGsap {
animations: GsapAnimation[];
timelineVar: string;
preamble: string;
postamble: string;
}
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
export const SUPPORTED_PROPS = [
"opacity",
"visibility",
"x",
"y",
"scale",
"scaleX",
"scaleY",
"rotation",
"autoAlpha",
"width",
"height",
];
export const SUPPORTED_EASES = [
"none",
"power1.in",
"power1.out",
"power1.inOut",
"power2.in",
"power2.out",
"power2.inOut",
"power3.in",
"power3.out",
"power3.inOut",
"power4.in",
"power4.out",
"power4.inOut",
"back.in",
"back.out",
"back.inOut",
"elastic.in",
"elastic.out",
"elastic.inOut",
"bounce.in",
"bounce.out",
"bounce.inOut",
"expo.in",
"expo.out",
"expo.inOut",
];
function parseObjectLiteral(str: string): Record<string, number | string> {
const result: Record<string, number | string> = {};
const cleaned = str.replace(/^\{|\}$/g, "").trim();
if (!cleaned) return result;
const propRegex = /(\w+)\s*:\s*("[^"]*"|'[^']*'|[\d.]+|[a-zA-Z_][\w.]*)/g;
let match;
while ((match = propRegex.exec(cleaned)) !== null) {
const key = match[1] ?? "";
let value: string | number = match[2] ?? "";
if (typeof value === "string") {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
} else if (!isNaN(Number(value))) {
value = Number(value);
}
}
result[key] = value;
}
return result;
}
function findMatchingBrace(str: string, startIndex: number): number {
let depth = 0;
for (let i = startIndex; i < str.length; i++) {
if (str[i] === "{") depth++;
else if (str[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
export function parseGsapScript(script: string): ParsedGsap {
const animations: GsapAnimation[] = [];
let idCounter = 0;
const timelineMatch = script.match(/(?:const|let|var)\s+(\w+)\s*=\s*gsap\.timeline/);
const timelineVar = timelineMatch ? timelineMatch[1] ?? "tl" : "tl";
const preambleMatch = script.match(
new RegExp(`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`),
);
const preamble = preambleMatch ? preambleMatch[0] : `const ${timelineVar} = gsap.timeline({ paused: true });`;
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
let match;
while ((match = methodPattern.exec(script)) !== null) {
const rawMethod = match[1];
if (!rawMethod || !GSAP_METHODS.has(rawMethod)) continue;
const method: GsapMethod = rawMethod as GsapMethod;
const argsStr = match[2] ?? "";
const animation = parseGsapCall(method, argsStr, ++idCounter);
if (animation) {
animations.push(animation);
}
}
const lastAnimIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
if (lastAnimIdx !== -1) {
const afterLastAnim = script.slice(lastAnimIdx);
const endOfCall = afterLastAnim.indexOf(";");
if (endOfCall !== -1) {
postamble = script.slice(lastAnimIdx + endOfCall + 1).trim();
}
}
return { animations, timelineVar, preamble, postamble };
}
function parseGsapCall(method: GsapMethod, argsStr: string, idNum: number): GsapAnimation | null {
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
if (!selectorMatch) return null;
const targetSelector = selectorMatch[1] ?? "";
const afterSelector = argsStr.slice(selectorMatch[0].length);
let properties: Record<string, number | string> = {};
let fromProperties: Record<string, number | string> | undefined;
let position = 0;
if (method === "fromTo") {
const firstBrace = afterSelector.indexOf("{");
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
if (firstBrace === -1 || firstEnd === -1) return null;
fromProperties = parseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
const secondPart = afterSelector.slice(firstEnd + 1);
const secondBrace = secondPart.indexOf("{");
const secondEnd = findMatchingBrace(secondPart, secondBrace);
if (secondBrace === -1 || secondEnd === -1) return null;
properties = parseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
const afterProps = secondPart.slice(secondEnd + 1);
const posMatch = afterProps.match(/,\s*([\d.]+)/);
if (posMatch) position = parseFloat(posMatch[1] ?? "");
} else {
const braceStart = afterSelector.indexOf("{");
const braceEnd = findMatchingBrace(afterSelector, braceStart);
if (braceStart !== -1 && braceEnd !== -1) {
properties = parseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
const afterProps = afterSelector.slice(braceEnd + 1);
const posMatch = afterProps.match(/,\s*([\d.]+)/);
if (posMatch) position = parseFloat(posMatch[1] ?? "");
}
}
const duration = typeof properties.duration === "number" ? properties.duration : undefined;
const ease = typeof properties.ease === "string" ? properties.ease : undefined;
const filteredProps: Record<string, number | string> = {};
for (const [key, value] of Object.entries(properties)) {
if (SUPPORTED_PROPS.includes(key)) {
filteredProps[key] = value;
}
}
let filteredFromProps: Record<string, number | string> | undefined;
if (fromProperties) {
filteredFromProps = {};
for (const [key, value] of Object.entries(fromProperties)) {
if (SUPPORTED_PROPS.includes(key)) {
filteredFromProps[key] = value;
}
}
}
return {
id: `anim-${idNum}`,
targetSelector,
method,
position,
properties: filteredProps,
fromProperties: filteredFromProps,
duration,
ease,
};
}
export function serializeGsapAnimations(
animations: GsapAnimation[],
timelineVar = "tl",
options?: { includeMediaSync?: boolean },
): string {
const sorted = [...animations].sort((a, b) => a.position - b.position);
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const propsStr = serializeObject(props);
switch (anim.method) {
case "set":
return ` ${timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`;
case "to":
return ` ${timelineVar}.to(${selector}, ${propsStr}, ${anim.position});`;
case "from":
return ` ${timelineVar}.from(${selector}, ${propsStr}, ${anim.position});`;
case "fromTo": {
const fromStr = serializeObject(anim.fromProperties || {});
return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${anim.position});`;
}
}
});
let mediaSync = "";
if (options?.includeMediaSync) {
mediaSync = `
// Sync media playback
${timelineVar}.eventCallback("onUpdate", function() {
const time = ${timelineVar}.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !${timelineVar}.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`;
}
return `
const ${timelineVar} = gsap.timeline({ paused: true });
${lines.join("\n")}${mediaSync}
`;
}
function serializeObject(obj: Record<string, number | string>): string {
const entries = Object.entries(obj).map(([key, value]) => {
if (typeof value === "string") {
return `${key}: "${value}"`;
}
return `${key}: ${value}`;
});
return `{ ${entries.join(", ")} }`;
}
export function updateAnimationInScript(script: string, animationId: string, updates: Partial<GsapAnimation>): string {
const parsed = parseGsapScript(script);
const updated = parsed.animations.map((anim) => {
if (anim.id === animationId) {
return { ...anim, ...updates };
}
return anim;
});
return serializeGsapAnimations(updated, parsed.timelineVar);
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
): { script: string; id: string } {
const parsed = parseGsapScript(script);
const id = `anim-${Date.now()}`;
const newAnim: GsapAnimation = { ...animation, id };
parsed.animations.push(newAnim);
return {
script: serializeGsapAnimations(parsed.animations, parsed.timelineVar),
id,
};
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScript(script);
const filtered = parsed.animations.filter((a) => a.id !== animationId);
return serializeGsapAnimations(filtered, parsed.timelineVar);
}
export function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
const selector = `#${elementId}`;
return animations.filter((a) => a.targetSelector === selector);
}
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{
pattern: /\.add\s*\(\s*function/,
message: "add(function) not allowed",
},
{
pattern: /\.add\s*\(\s*\(/,
message: "add() with arrow function not allowed",
},
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
{
pattern: /onRepeat\s*:/,
message: "onRepeat callback not allowed",
},
{
pattern: /onReverseComplete\s*:/,
message: "onReverseComplete callback not allowed",
},
{
pattern: /repeat\s*:\s*-1/,
message: "Infinite repeat (repeat: -1) not allowed",
},
{
pattern: /Math\.random\s*\(/,
message: "Random values (Math.random) not allowed",
},
{
pattern: /Date\.now\s*\(/,
message: "Date-dependent values (Date.now) not allowed",
},
{ pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" },
{ pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" },
{ pattern: /setInterval\s*\(/, message: "setInterval not allowed" },
{
pattern: /requestAnimationFrame\s*\(/,
message: "requestAnimationFrame not allowed",
},
];
export function validateCompositionGsap(script: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) {
if (pattern.test(script)) {
errors.push(message);
}
}
if (/yoyo\s*:\s*true/.test(script)) {
warnings.push("yoyo animations may behave unexpectedly when scrubbing");
}
if (/stagger\s*:/.test(script)) {
warnings.push("stagger animations may not serialize correctly");
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
export function keyframesToGsapAnimations(
elementId: string,
keyframes: Keyframe[],
elementStartTime: number,
base?: { x?: number; y?: number; scale?: number },
): GsapAnimation[] {
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const animations: GsapAnimation[] = [];
const baseX = base?.x ?? 0;
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
const prevKf = i > 0 ? sorted[i - 1] : null;
const duration = prevKf ? kf.time - prevKf.time : undefined;
const position = prevKf ? elementStartTime + prevKf.time : absoluteTime;
const properties: Record<string, number | string> = {};
for (const [key, value] of Object.entries(kf.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = baseX + value;
else if (key === "y") properties.y = baseY + value;
else if (key === "scale") properties.scale = baseScale * value;
else properties[key] = value;
}
animations.push({
id: `${elementId}-kf-${kf.id}`,
targetSelector: `#${elementId}`,
method: isFirst ? "set" : "to",
position,
properties,
duration: isFirst ? undefined : duration,
ease: kf.ease,
});
});
return animations;
}
export function gsapAnimationsToKeyframes(
animations: GsapAnimation[],
elementStartTime: number,
options?: {
baseX?: number;
baseY?: number;
baseScale?: number;
clampTimeToZero?: boolean;
skipBaseSet?: boolean;
},
): Keyframe[] {
const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"];
const baseX = options?.baseX ?? 0;
const baseY = options?.baseY ?? 0;
const baseScale = options?.baseScale ?? 1;
const clampTimeToZero = options?.clampTimeToZero ?? true;
const skipBaseSet = options?.skipBaseSet ?? false;
const baseTimeEpsilon = 0.001;
const baseValueEpsilon = 0.00001;
return animations
.filter((a) => validMethods.includes(a.method))
.map((a) => {
const relativeTimeRaw = a.position - elementStartTime;
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
const properties: Partial<KeyframeProperties> = {};
for (const [key, value] of Object.entries(a.properties)) {
if (SUPPORTED_PROPS.includes(key) && typeof value === "number") {
if (key === "x") {
(properties as Record<string, number>).x = value - baseX;
} else if (key === "y") {
(properties as Record<string, number>).y = value - baseY;
} else if (key === "scale") {
(properties as Record<string, number>).scale = baseScale !== 0 ? value / baseScale : value;
} else {
(properties as Record<string, number>)[key] = value;
}
}
}
if (skipBaseSet && a.method === "set" && Math.abs(time) <= baseTimeEpsilon) {
const propKeys = Object.keys(properties);
const isOnlyBaseProps = propKeys.every((k) => k === "x" || k === "y" || k === "scale");
if (isOnlyBaseProps && propKeys.length > 0) {
const hasNonBaseOffset =
(properties.x !== undefined && Math.abs(properties.x) > baseValueEpsilon) ||
(properties.y !== undefined && Math.abs(properties.y) > baseValueEpsilon) ||
(properties.scale !== undefined && Math.abs(properties.scale - 1) > baseValueEpsilon);
if (!hasNonBaseOffset) {
return null;
}
}
}
const kf: Keyframe = { id: a.id, time, properties };
if (a.ease !== undefined) kf.ease = a.ease;
return kf;
})
.filter((kf): kf is Keyframe => kf !== null)
.sort((a, b) => a.time - b.time);
}
@@ -0,0 +1,525 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import { parseHtml, updateElementInHtml, addElementToHtml, removeElementFromHtml, validateCompositionHtml, extractCompositionMetadata } from "./htmlParser.js";
describe("parseHtml", () => {
it("extracts elements with data-start and data-end", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5" data-name="Title"><div>Hello World</div></div>
<div id="text2" data-start="2" data-end="7" data-name="Subtitle"><div>Sub</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(2);
expect(result.elements[0].id).toBe("text1");
expect(result.elements[0].startTime).toBe(0);
expect(result.elements[0].duration).toBe(5);
expect(result.elements[0].name).toBe("Title");
expect(result.elements[0].type).toBe("text");
expect(result.elements[1].id).toBe("text2");
expect(result.elements[1].startTime).toBe(2);
expect(result.elements[1].duration).toBe(5);
});
it("handles nested compositions", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="comp1" data-start="0" data-end="10" data-type="composition" data-composition-id="abc123">
<iframe src="/compositions/abc123"></iframe>
</div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].type).toBe("composition");
expect(result.elements[0].id).toBe("comp1");
if (result.elements[0].type === "composition") {
expect(result.elements[0].compositionId).toBe("abc123");
expect(result.elements[0].src).toBe("/compositions/abc123");
}
});
it("extracts media elements (video, audio, img)", () => {
const html = `
<html>
<body>
<div id="stage">
<video id="vid1" data-start="0" data-end="10" src="video.mp4" data-name="My Video"></video>
<audio id="aud1" data-start="0" data-end="5" src="music.mp3" data-name="Music"></audio>
<img id="img1" data-start="2" data-end="8" src="photo.jpg" data-name="Photo" />
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(3);
const video = result.elements.find((e) => e.id === "vid1");
expect(video).toBeDefined();
expect(video?.type).toBe("video");
if (video?.type === "video") {
expect(video.src).toBe("video.mp4");
}
const audio = result.elements.find((e) => e.id === "aud1");
expect(audio).toBeDefined();
expect(audio?.type).toBe("audio");
const img = result.elements.find((e) => e.id === "img1");
expect(img).toBeDefined();
expect(img?.type).toBe("image");
});
it("handles missing attributes gracefully", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="el1" data-start="3"><div>Some text</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].startTime).toBe(3);
// Default duration is 5 when data-end is missing
expect(result.elements[0].duration).toBe(5);
});
it("assigns generated ids when elements have no id", () => {
const html = `
<html>
<body>
<div id="stage">
<div data-start="0" data-end="5"><div>No ID</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].id).toMatch(/^element-\d+$/);
});
it("extracts GSAP script from script tags", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#text1", { opacity: 1, duration: 1 }, 0);
</script>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.gsapScript).not.toBeNull();
expect(result.gsapScript).toContain("gsap.timeline");
expect(result.gsapScript).toContain('tl.to("#text1"');
});
it("extracts styles from style tags", () => {
const html = `
<html>
<body>
<style data-hf-custom="true">
.my-class { color: red; }
</style>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.styles).not.toBeNull();
expect(result.styles).toContain(".my-class");
});
it("detects landscape resolution from data attribute", () => {
const html = `
<html data-resolution="landscape">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape");
});
it("detects portrait resolution from data attribute", () => {
const html = `
<html data-resolution="portrait">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("defaults to portrait when no resolution info is available", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("extracts x, y, scale, opacity from data attributes", () => {
const html = `
<html>
<body>
<div id="stage">
<video id="vid1" data-start="0" data-end="5" src="v.mp4" data-x="100" data-y="200" data-scale="1.5" data-opacity="0.8"></video>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements[0].x).toBe(100);
expect(result.elements[0].y).toBe(200);
expect(result.elements[0].scale).toBe(1.5);
expect(result.elements[0].opacity).toBe(0.8);
});
it("parses text element properties (color, fontSize, fontWeight, fontFamily)", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5" data-color="red" data-font-size="72" data-font-weight="900" data-font-family="Montserrat"><div>Styled</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
const textEl = result.elements[0];
expect(textEl.type).toBe("text");
if (textEl.type === "text") {
expect(textEl.color).toBe("red");
expect(textEl.fontSize).toBe(72);
expect(textEl.fontWeight).toBe(900);
expect(textEl.fontFamily).toBe("Montserrat");
}
});
it("parses media element properties (mediaStartTime, sourceDuration, volume)", () => {
const html = `
<html>
<body>
<div id="stage">
<video id="vid1" data-start="0" data-end="10" src="v.mp4" data-media-start="5" data-source-duration="30" data-volume="0.5" data-has-audio="true"></video>
</div>
</body>
</html>
`;
const result = parseHtml(html);
const vid = result.elements[0];
expect(vid.type).toBe("video");
if (vid.type === "video") {
expect(vid.mediaStartTime).toBe(5);
expect(vid.sourceDuration).toBe(30);
expect(vid.volume).toBe(0.5);
expect(vid.hasAudio).toBe(true);
}
});
it("extracts data-keyframes attribute", () => {
const keyframes = JSON.stringify([
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
]);
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5" data-keyframes='${keyframes}'><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.keyframes["text1"]).toBeDefined();
expect(result.keyframes["text1"]).toHaveLength(2);
expect(result.keyframes["text1"][0].id).toBe("kf1");
});
it("parses stage zoom keyframes", () => {
const zoomKeyframes = JSON.stringify([
{ id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
{ id: "z2", time: 2, zoom: { scale: 2, focusX: 500, focusY: 300 } },
]);
const html = `
<html>
<body>
<div id="stage">
<div id="stage-zoom-container" data-zoom-keyframes='${zoomKeyframes}'></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.stageZoomKeyframes).toHaveLength(2);
expect(result.stageZoomKeyframes[0].zoom.scale).toBe(1);
expect(result.stageZoomKeyframes[1].zoom.scale).toBe(2);
});
it("returns empty zoom keyframes when no zoom container exists", () => {
const html = `
<html>
<body><div id="stage"></div></body>
</html>
`;
const result = parseHtml(html);
expect(result.stageZoomKeyframes).toHaveLength(0);
});
});
describe("updateElementInHtml", () => {
it("updates startTime and duration", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
</body></html>`;
const updated = updateElementInHtml(html, "el1", { startTime: 2, duration: 3 });
expect(updated).toContain('data-start="2"');
expect(updated).toContain('data-end="5"'); // data-end gets set to start + duration
});
it("updates element name", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="el1" data-start="0" data-end="5" data-name="Old"><div>Hello</div></div>
</body></html>`;
const updated = updateElementInHtml(html, "el1", { name: "New Name" });
expect(updated).toContain('data-name="New Name"');
});
it("returns original html when element not found", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
</body></html>`;
const updated = updateElementInHtml(html, "nonexistent", { name: "Test" });
expect(updated).toBe(html);
});
});
describe("addElementToHtml", () => {
it("adds a new text element to the HTML", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="stage">
<div id="stage-zoom-container"></div>
</div>
</body></html>`;
const { html: updated, id } = addElementToHtml(html, {
type: "text",
name: "New Text",
content: "Hello!",
startTime: 1,
duration: 3,
zIndex: 1,
});
expect(id).toBeDefined();
expect(updated).toContain(`id="${id}"`);
expect(updated).toContain('data-start="1"');
expect(updated).toContain('data-end="4"');
expect(updated).toContain("Hello!");
});
it("adds a video element", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="stage">
<div id="stage-zoom-container"></div>
</div>
</body></html>`;
const { html: updated, id } = addElementToHtml(html, {
type: "video",
name: "My Video",
src: "video.mp4",
startTime: 0,
duration: 10,
zIndex: 0,
});
expect(updated).toContain(`id="${id}"`);
expect(updated).toContain("video.mp4");
});
});
describe("removeElementFromHtml", () => {
it("removes an element by id", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="stage">
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
<div id="el2" data-start="1" data-end="6"><div>World</div></div>
</div>
</body></html>`;
const updated = removeElementFromHtml(html, "el1");
expect(updated).not.toContain('id="el1"');
expect(updated).toContain('id="el2"');
});
});
describe("validateCompositionHtml", () => {
it("returns valid for a well-formed composition", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10">
<body>
<div id="stage"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("reports error for missing composition-id", () => {
const html = `<!DOCTYPE html>
<html data-composition-duration="10">
<body>
<div id="stage"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing data-composition-id attribute on <html> element");
});
it("reports error for missing composition-duration", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1">
<body>
<div id="stage"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing data-composition-duration attribute on <html> element");
});
it("reports error for missing #stage", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10">
<body></body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing #stage element");
});
it("reports error for inline event handlers", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10">
<body>
<div id="stage" onclick="alert('hi')"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Inline event handlers (onclick, onload, etc.) not allowed");
});
});
describe("extractCompositionMetadata", () => {
it("extracts composition id and duration", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-abc" data-composition-duration="15.5">
<body></body>
</html>`;
const meta = extractCompositionMetadata(html);
expect(meta.compositionId).toBe("comp-abc");
expect(meta.compositionDuration).toBe(15.5);
});
it("returns null for missing metadata", () => {
const html = `<!DOCTYPE html><html><body></body></html>`;
const meta = extractCompositionMetadata(html);
expect(meta.compositionId).toBeNull();
expect(meta.compositionDuration).toBeNull();
});
it("extracts composition variables", () => {
const variables = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "count", type: "number", label: "Count", default: 5 },
]);
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10" data-composition-variables='${variables}'>
<body></body>
</html>`;
const meta = extractCompositionMetadata(html);
expect(meta.variables).toHaveLength(2);
expect(meta.variables[0].id).toBe("title");
expect(meta.variables[0].type).toBe("string");
expect(meta.variables[1].id).toBe("count");
expect(meta.variables[1].type).toBe("number");
});
});
+843
View File
@@ -0,0 +1,843 @@
import type {
TimelineElement,
TimelineElementType,
TimelineMediaElement,
TimelineTextElement,
TimelineCompositionElement,
CanvasResolution,
Keyframe,
KeyframeProperties,
StageZoomKeyframe,
CompositionVariable,
} from "../core.types";
import { CANVAS_DIMENSIONS } from "../core.types";
import {
parseGsapScript,
validateCompositionGsap,
gsapAnimationsToKeyframes,
getAnimationsForElement,
} from "./gsapParser";
import type { ValidationResult } from "./gsapParser";
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
export interface ParsedHtml {
elements: TimelineElement[];
gsapScript: string | null;
styles: string | null;
resolution: CanvasResolution;
keyframes: Record<string, Keyframe[]>;
stageZoomKeyframes: StageZoomKeyframe[];
}
function getElementType(el: Element): TimelineElementType | null {
const tag = el.tagName.toLowerCase();
if (tag === "video") return "video";
if (tag === "img") return "image";
if (tag === "audio") return "audio";
// Check for explicit data-type attribute first
const dataType = el.getAttribute("data-type");
if (dataType === "composition") return "composition";
if (dataType === "text") return "text";
// Fall back to tag-based detection for backwards compatibility
if (tag === "div" || tag === "p" || tag === "h1" || tag === "h2" || tag === "h3" || tag === "span") {
return "text";
}
return null;
}
function getElementName(el: Element): string {
const dataName = el.getAttribute("data-name");
if (dataName) return dataName;
const type = getElementType(el);
if (type === "text") {
const text = el.textContent?.trim().slice(0, 30) || "Text";
return text.length === 30 ? text + "..." : text;
}
const src = el.getAttribute("src");
if (src) {
const filename = src.split("/").pop() || src;
return filename.split("?")[0] ?? filename;
}
return el.id || el.className?.toString().split(" ")[0] || "Element";
}
function getZIndex(el: Element): number {
const dataLayer = el.getAttribute("data-layer");
if (dataLayer) return parseInt(dataLayer, 10) || 0;
const style = (el as HTMLElement).style?.zIndex;
if (style) return parseInt(style, 10) || 0;
return 0;
}
function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasResolution {
const stage = doc.getElementById("stage") || doc.querySelector("#stage");
if (stage) {
const inlineStyle = (stage as HTMLElement).style;
if (inlineStyle?.width && inlineStyle?.height) {
const w = parseInt(inlineStyle.width, 10);
const h = parseInt(inlineStyle.height, 10);
if (w && h) {
return w > h ? "landscape" : "portrait";
}
}
}
if (cssText) {
const stageMatch = cssText.match(/#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/);
if (stageMatch) {
const w = parseInt(stageMatch[1] ?? "", 10);
const h = parseInt(stageMatch[2] ?? "", 10);
return w > h ? "landscape" : "portrait";
}
const stageMatchReverse = cssText.match(/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/);
if (stageMatchReverse) {
const h = parseInt(stageMatchReverse[1] ?? "", 10);
const w = parseInt(stageMatchReverse[2] ?? "", 10);
return w > h ? "landscape" : "portrait";
}
}
return "portrait";
}
function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
const htmlEl = doc.documentElement;
const resolutionAttr = htmlEl.getAttribute("data-resolution");
if (resolutionAttr === "landscape" || resolutionAttr === "portrait") {
return resolutionAttr;
}
const widthAttr = htmlEl.getAttribute("data-composition-width");
const heightAttr = htmlEl.getAttribute("data-composition-height");
if (widthAttr && heightAttr) {
const width = parseInt(widthAttr, 10);
const height = parseInt(heightAttr, 10);
if (width && height) {
return width > height ? "landscape" : "portrait";
}
}
return null;
}
export function parseHtml(html: string): ParsedHtml {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const elements: TimelineElement[] = [];
const keyframes: Record<string, Keyframe[]> = {};
let idCounter = 0;
const htmlEl = doc.documentElement;
const customStylesAttr = htmlEl.getAttribute("data-custom-styles");
let customStyles: string | null = null;
if (customStylesAttr) {
try {
customStyles = JSON.parse(customStylesAttr);
} catch {
customStyles = customStylesAttr;
}
}
const timedElements = doc.querySelectorAll("[data-start]");
timedElements.forEach((el) => {
const type = getElementType(el);
if (!type) return;
const start = parseFloat(el.getAttribute("data-start") || "0");
const dataEnd = el.getAttribute("data-end");
let duration: number;
if (dataEnd) {
duration = Math.max(0, parseFloat(dataEnd) - start);
} else {
duration = 5;
}
const id = el.id || `element-${++idCounter}`;
const name = getElementName(el);
const zIndex = getZIndex(el);
// Parse data-keyframes attribute if present
const keyframesAttr = el.getAttribute("data-keyframes");
if (keyframesAttr) {
try {
const parsedKeyframes = JSON.parse(keyframesAttr);
if (Array.isArray(parsedKeyframes) && parsedKeyframes.length > 0) {
keyframes[id] = parsedKeyframes;
}
} catch {
// skip invalid keyframes
}
}
// Parse transform properties (x, y, scale, opacity)
const xAttr = el.getAttribute("data-x");
const yAttr = el.getAttribute("data-y");
const scaleAttr = el.getAttribute("data-scale");
const opacityAttr = el.getAttribute("data-opacity");
const x = xAttr ? parseFloat(xAttr) : undefined;
const y = yAttr ? parseFloat(yAttr) : undefined;
const scale = scaleAttr ? parseFloat(scaleAttr) : undefined;
const opacity = opacityAttr ? parseFloat(opacityAttr) : undefined;
if (type === "text") {
const textEl = el.firstElementChild;
const content = textEl?.textContent || name;
const color = el.getAttribute("data-color") || undefined;
const fontSizeAttr = el.getAttribute("data-font-size");
const fontSize = fontSizeAttr ? parseInt(fontSizeAttr, 10) : undefined;
const fontWeightAttr = el.getAttribute("data-font-weight");
const fontWeight = fontWeightAttr ? parseInt(fontWeightAttr, 10) : undefined;
const fontFamily = el.getAttribute("data-font-family") || undefined;
const textShadowAttr = el.getAttribute("data-text-shadow");
const textShadow = textShadowAttr === "false" ? false : undefined;
// Parse outline properties
const textOutlineAttr = el.getAttribute("data-text-outline");
const textOutline = textOutlineAttr === "true" ? true : undefined;
const textOutlineColor = el.getAttribute("data-text-outline-color") || undefined;
const textOutlineWidthAttr = el.getAttribute("data-text-outline-width");
const textOutlineWidth = textOutlineWidthAttr ? parseInt(textOutlineWidthAttr, 10) : undefined;
// Parse highlight properties
const textHighlightAttr = el.getAttribute("data-text-highlight");
const textHighlight = textHighlightAttr === "true" ? true : undefined;
const textHighlightColor = el.getAttribute("data-text-highlight-color") || undefined;
const textHighlightPaddingAttr = el.getAttribute("data-text-highlight-padding");
const textHighlightPadding = textHighlightPaddingAttr ? parseInt(textHighlightPaddingAttr, 10) : undefined;
const textHighlightRadiusAttr = el.getAttribute("data-text-highlight-radius");
const textHighlightRadius = textHighlightRadiusAttr ? parseInt(textHighlightRadiusAttr, 10) : undefined;
const textElement: TimelineTextElement = {
id,
type: "text",
name,
content,
startTime: start,
duration,
zIndex,
x,
y,
scale,
opacity,
color,
fontSize,
fontWeight,
fontFamily,
textShadow,
textOutline,
textOutlineColor,
textOutlineWidth,
textHighlight,
textHighlightColor,
textHighlightPadding,
textHighlightRadius,
};
elements.push(textElement);
} else if (type === "composition") {
// Composition is a div container with iframe inside
const iframe = el.querySelector("iframe");
const src = iframe?.getAttribute("src") || el.getAttribute("src") || "";
const compositionId = el.getAttribute("data-composition-id") || "";
const sourceDurationAttr = el.getAttribute("data-source-duration");
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined;
const sourceWidthAttr = el.getAttribute("data-source-width");
const sourceWidth = sourceWidthAttr ? parseInt(sourceWidthAttr, 10) : undefined;
const sourceHeightAttr = el.getAttribute("data-source-height");
const sourceHeight = sourceHeightAttr ? parseInt(sourceHeightAttr, 10) : undefined;
// Parse variable values if present
const variableValuesAttr = el.getAttribute("data-variable-values");
let variableValues: Record<string, string | number | boolean> | undefined;
if (variableValuesAttr) {
try {
variableValues = JSON.parse(variableValuesAttr);
} catch {
// skip invalid variable values
}
}
const compositionElement: TimelineCompositionElement = {
id,
type: "composition",
name,
src,
compositionId,
startTime: start,
duration,
zIndex,
x,
y,
scale,
opacity,
sourceDuration,
sourceWidth,
sourceHeight,
variableValues,
};
elements.push(compositionElement);
} else {
if (!MEDIA_TYPES.has(type)) return;
const src = el.getAttribute("src") || "";
const mediaStartTimeAttr = el.getAttribute("data-media-start");
const mediaStartTime = mediaStartTimeAttr ? parseFloat(mediaStartTimeAttr) : undefined;
const sourceDurationAttr = el.getAttribute("data-source-duration");
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined;
const isArollAttr = el.getAttribute("data-aroll");
const isAroll = isArollAttr === "true" ? true : undefined;
const volumeAttr = el.getAttribute("data-volume");
const volume = volumeAttr ? parseFloat(volumeAttr) : undefined;
const hasAudioAttr = el.getAttribute("data-has-audio");
const hasAudio = hasAudioAttr === "true" ? true : undefined;
const mediaElement: TimelineMediaElement = {
id,
type: type as "video" | "image" | "audio",
name,
src,
startTime: start,
duration,
zIndex,
x,
y,
scale,
opacity,
mediaStartTime,
sourceDuration,
isAroll,
volume,
hasAudio,
};
elements.push(mediaElement);
}
});
const scriptTags = doc.querySelectorAll("script");
let gsapScript: string | null = null;
for (const script of scriptTags) {
const src = script.getAttribute("src");
if (src && src.includes("gsap")) continue;
const content = script.textContent?.trim();
if (content && (content.includes("gsap") || content.includes("timeline"))) {
gsapScript = content;
break;
}
}
// Extract x/y positions and scale from GSAP script
if (gsapScript) {
const positionMap = extractPositionsFromGsap(gsapScript);
for (const element of elements) {
const pos = positionMap.get(element.id);
if (pos) {
if (pos.x !== undefined) element.x = pos.x;
if (pos.y !== undefined) element.y = pos.y;
if (
pos.scale !== undefined &&
(element.type === "video" || element.type === "image" || element.type === "composition")
) {
(element as TimelineMediaElement | TimelineCompositionElement).scale = pos.scale;
}
}
}
}
// Normalize keyframes (clamp negative time, convert absolute -> relative if detected)
for (const element of elements) {
const elementKeyframes = keyframes[element.id];
if (!elementKeyframes || elementKeyframes.length === 0) continue;
const baseX = element.x ?? 0;
const baseY = element.y ?? 0;
const baseScale =
element.type === "video" || element.type === "image" || element.type === "composition"
? ((element as TimelineMediaElement | TimelineCompositionElement).scale ?? 1)
: 1;
keyframes[element.id] = normalizeKeyframes(elementKeyframes, baseX, baseY, baseScale);
}
const styleTags = doc.querySelectorAll("style");
const allStyles =
Array.from(styleTags)
.map((s) => s.textContent?.trim())
.filter(Boolean)
.join("\n\n") || null;
const customStyleTags = Array.from(styleTags).filter((s) => s.getAttribute("data-hf-custom") === "true");
const customStylesFromTags =
customStyleTags
.map((s) => s.textContent?.trim())
.filter(Boolean)
.join("\n\n") || null;
const styles = customStyles ?? customStylesFromTags ?? null;
const resolution = parseResolutionFromHtml(doc) ?? parseResolutionFromCss(doc, allStyles);
// Extract keyframes from GSAP animations for elements that don't have data-keyframes
if (gsapScript) {
const parsed = parseGsapScript(gsapScript);
for (const element of elements) {
// Only extract from GSAP if we don't have explicit data-keyframes
if (keyframes[element.id]) continue;
const elementAnimations = getAnimationsForElement(parsed.animations, element.id);
if (elementAnimations.length > 0) {
const elementKeyframes = gsapAnimationsToKeyframes(elementAnimations, element.startTime, {
baseX: element.x ?? 0,
baseY: element.y ?? 0,
baseScale:
element.type === "video" || element.type === "image" || element.type === "composition"
? ((element as TimelineMediaElement | TimelineCompositionElement).scale ?? 1)
: 1,
clampTimeToZero: true,
skipBaseSet: true,
});
if (elementKeyframes.length > 0) {
keyframes[element.id] = elementKeyframes;
}
}
}
}
// Parse stage zoom keyframes from zoom container
const stageZoomKeyframes = parseStageZoomKeyframes(doc);
return {
elements,
gsapScript,
styles,
resolution,
keyframes,
stageZoomKeyframes,
};
}
function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] {
const zoomContainer = doc.getElementById("stage-zoom-container");
if (!zoomContainer) {
return [];
}
const zoomKeyframesAttr = zoomContainer.getAttribute("data-zoom-keyframes");
if (!zoomKeyframesAttr) {
return [];
}
try {
const parsed = JSON.parse(zoomKeyframesAttr);
if (Array.isArray(parsed)) {
return parsed.filter(
(kf): kf is StageZoomKeyframe =>
typeof kf === "object" &&
kf !== null &&
typeof kf.id === "string" &&
typeof kf.time === "number" &&
typeof kf.zoom === "object" &&
kf.zoom !== null &&
typeof kf.zoom.scale === "number" &&
typeof kf.zoom.focusX === "number" &&
typeof kf.zoom.focusY === "number",
);
}
} catch {
// skip invalid zoom keyframes
}
return [];
}
/**
* Extract x/y positions and scale from GSAP set() calls at position 0
* Returns a map of elementId -> { x, y, scale }
*/
function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?: number; scale?: number }> {
const positionMap = new Map<string, { x?: number; y?: number; scale?: number }>();
try {
const parsed = parseGsapScript(script);
// Look for set() calls at position 0 with x/y/scale properties
for (const anim of parsed.animations) {
if (anim.method === "set" && anim.position === 0) {
// Extract element ID from selector (e.g., "#element-1" -> "element-1")
const selectorMatch = anim.targetSelector.match(/^#(.+)$/);
if (!selectorMatch) continue;
const elementId = selectorMatch[1] ?? "";
const x = typeof anim.properties.x === "number" ? anim.properties.x : undefined;
const y = typeof anim.properties.y === "number" ? anim.properties.y : undefined;
const scale = typeof anim.properties.scale === "number" ? anim.properties.scale : undefined;
// Only add to map if x, y, or scale is defined and non-default
if ((x !== undefined && x !== 0) || (y !== undefined && y !== 0) || (scale !== undefined && scale !== 1)) {
const existing = positionMap.get(elementId) || {};
positionMap.set(elementId, {
x: x !== undefined ? x : existing.x,
y: y !== undefined ? y : existing.y,
scale: scale !== undefined ? scale : existing.scale,
});
}
}
}
} catch {
// skip GSAP position parsing failure
}
return positionMap;
}
function normalizeKeyframes(keyframes: Keyframe[], baseX: number, baseY: number, baseScale: number): Keyframe[] {
const timeEpsilon = 0.001;
const valueEpsilon = 0.00001;
const hasBaseCheck = (value: number | undefined, base: number): boolean =>
value !== undefined && Math.abs(value - base) <= valueEpsilon && Math.abs(base) > valueEpsilon;
const timeZeroKeyframes = keyframes.filter((kf) => Math.abs(kf.time) <= timeEpsilon);
const treatAsAbsolute = timeZeroKeyframes.some((kf) => {
const props = kf.properties || {};
if (
hasBaseCheck(props.x, baseX) ||
hasBaseCheck(props.y, baseY) ||
(baseScale !== 1 && hasBaseCheck(props.scale, baseScale))
) {
return true;
}
return false;
});
return keyframes.map((kf) => {
const normalizedProps: Partial<KeyframeProperties> = {};
for (const [key, value] of Object.entries(kf.properties || {})) {
if (typeof value !== "number") continue;
if (treatAsAbsolute && key === "x") {
normalizedProps.x = value - baseX;
} else if (treatAsAbsolute && key === "y") {
normalizedProps.y = value - baseY;
} else if (treatAsAbsolute && key === "scale") {
normalizedProps.scale = baseScale !== 0 ? value / baseScale : value;
} else {
(normalizedProps as Record<string, number>)[key] = value;
}
}
return {
...kf,
time: Math.max(0, kf.time),
properties: normalizedProps,
};
});
}
export function updateElementInHtml(html: string, elementId: string, updates: Partial<TimelineElement>): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const el = doc.getElementById(elementId) || doc.querySelector(`[data-name="${elementId}"]`);
if (!el) return html;
if (updates.startTime !== undefined) {
el.setAttribute("data-start", String(updates.startTime));
if (el.hasAttribute("data-end") && updates.duration !== undefined) {
el.setAttribute("data-end", String(updates.startTime + updates.duration));
}
}
if (updates.duration !== undefined) {
const start = parseFloat(el.getAttribute("data-start") || "0");
el.setAttribute("data-end", String(start + updates.duration));
el.removeAttribute("data-duration"); // Clean up legacy
}
if (updates.name !== undefined) {
el.setAttribute("data-name", updates.name);
}
if (updates.zIndex !== undefined) {
el.setAttribute("data-layer", String(updates.zIndex));
}
// Handle media-specific property
if ("src" in updates && updates.src !== undefined) {
el.setAttribute("src", updates.src);
}
// Handle text-specific properties
if ("content" in updates && updates.content !== undefined) {
const textEl = el.firstElementChild;
if (textEl) {
textEl.textContent = updates.content;
}
}
if ("color" in updates && updates.color !== undefined) {
el.setAttribute("data-color", updates.color);
}
if ("fontSize" in updates && updates.fontSize !== undefined) {
el.setAttribute("data-font-size", String(updates.fontSize));
}
if ("textShadow" in updates) {
if (updates.textShadow === false) {
el.setAttribute("data-text-shadow", "false");
} else {
el.removeAttribute("data-text-shadow");
}
}
// Handle volume property for audio/video
if ("volume" in updates) {
if (updates.volume !== undefined && updates.volume !== 1) {
el.setAttribute("data-volume", String(updates.volume));
} else {
el.removeAttribute("data-volume");
}
}
// Handle hasAudio property for videos
if ("hasAudio" in updates) {
if (updates.hasAudio === true) {
el.setAttribute("data-has-audio", "true");
} else {
el.removeAttribute("data-has-audio");
}
}
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
}
export function addElementToHtml(
html: string,
element: Omit<TimelineElement, "id"> & { id?: string },
): { html: string; id: string } {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Prefer zoom container, fall back to stage, then container, then body
const container =
doc.querySelector("#stage-zoom-container") ||
doc.querySelector(".container") ||
doc.querySelector("#stage") ||
doc.body;
const id = element.id || `element-${Date.now()}`;
let newEl: Element;
switch (element.type) {
case "video": {
const mediaEl = element as TimelineMediaElement;
newEl = doc.createElement("video");
newEl.setAttribute("muted", "");
newEl.setAttribute("playsinline", "");
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
newEl.setAttribute("data-volume", String(mediaEl.volume));
}
if (mediaEl.hasAudio) {
newEl.setAttribute("data-has-audio", "true");
}
break;
}
case "image": {
const mediaEl = element as TimelineMediaElement;
newEl = doc.createElement("img");
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
newEl.setAttribute("alt", element.name);
break;
}
case "audio": {
const mediaEl = element as TimelineMediaElement;
newEl = doc.createElement("audio");
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
newEl.setAttribute("data-volume", String(mediaEl.volume));
}
break;
}
case "text":
default: {
const textEl = element as TimelineTextElement;
newEl = doc.createElement("div");
const textContent = doc.createElement("div");
textContent.textContent = textEl.content || element.name;
newEl.appendChild(textContent);
if (textEl.color) {
newEl.setAttribute("data-color", textEl.color);
}
if (textEl.fontSize) {
newEl.setAttribute("data-font-size", String(textEl.fontSize));
}
break;
}
}
newEl.id = id;
newEl.setAttribute("data-start", String(element.startTime));
newEl.setAttribute("data-end", String(element.startTime + element.duration));
newEl.setAttribute("data-layer", String(element.zIndex));
newEl.setAttribute("data-name", element.name);
container.appendChild(newEl);
return {
html: "<!DOCTYPE html>\n" + doc.documentElement.outerHTML,
id,
};
}
export function removeElementFromHtml(html: string, elementId: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const el = doc.getElementById(elementId);
if (el) {
el.remove();
}
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
}
export interface CompositionMetadata {
compositionId: string | null;
compositionDuration: number | null;
variables: CompositionVariable[];
}
export function extractCompositionMetadata(html: string): CompositionMetadata {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const htmlEl = doc.documentElement;
const compositionId = htmlEl.getAttribute("data-composition-id");
const durationStr = htmlEl.getAttribute("data-composition-duration");
const compositionDuration = durationStr ? parseFloat(durationStr) : null;
const variables = parseCompositionVariables(htmlEl);
return {
compositionId,
compositionDuration: compositionDuration && isFinite(compositionDuration) ? compositionDuration : null,
variables,
};
}
function parseCompositionVariables(htmlEl: Element): CompositionVariable[] {
const variablesAttr = htmlEl.getAttribute("data-composition-variables");
if (!variablesAttr) {
return [];
}
try {
const parsed = JSON.parse(variablesAttr);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((v): v is CompositionVariable => {
if (typeof v !== "object" || v === null) return false;
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
if (!["string", "number", "color", "boolean", "enum"].includes(v.type)) return false;
switch (v.type) {
case "string":
return typeof v.default === "string";
case "number":
return typeof v.default === "number";
case "color":
return typeof v.default === "string";
case "boolean":
return typeof v.default === "boolean";
case "enum":
return typeof v.default === "string" && Array.isArray(v.options);
default:
return false;
}
});
} catch {
return [];
}
}
export function validateCompositionHtml(html: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const htmlEl = doc.documentElement;
const compositionId = htmlEl.getAttribute("data-composition-id");
if (!compositionId) {
errors.push("Missing data-composition-id attribute on <html> element");
}
const durationStr = htmlEl.getAttribute("data-composition-duration");
if (!durationStr) {
errors.push("Missing data-composition-duration attribute on <html> element");
} else {
const duration = parseFloat(durationStr);
if (!isFinite(duration) || duration <= 0) {
errors.push("data-composition-duration must be a positive finite number");
}
}
const stage = doc.getElementById("stage");
if (!stage) {
errors.push("Missing #stage element");
}
if (/\son\w+\s*=/i.test(html)) {
errors.push("Inline event handlers (onclick, onload, etc.) not allowed");
}
if (/javascript\s*:/i.test(html)) {
errors.push("javascript: URLs not allowed");
}
const scripts = doc.querySelectorAll("script");
if (scripts.length > 2) {
warnings.push("Multiple script tags detected - only GSAP CDN and main script expected");
}
const gsapScript = extractGsapScript(doc);
if (gsapScript) {
const gsapValidation = validateCompositionGsap(gsapScript);
errors.push(...gsapValidation.errors);
warnings.push(...gsapValidation.warnings);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
function extractGsapScript(doc: Document): string | null {
const scripts = doc.querySelectorAll("script");
for (const script of scripts) {
const content = script.textContent || "";
if (content.includes("gsap.timeline") || content.includes(".set(") || content.includes(".to(")) {
return content;
}
}
return null;
}
export { CANVAS_DIMENSIONS };