mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user