Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/cli/src/capture/contentExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,5 +494,36 @@ export function generateAssetDescriptions(
/* no fonts dir */
}

return [...captionedLines, ...uncaptionedLines, ...svgLines, ...fontLines];
// Describe videos — high-value motion clips. The video-manifest.json (written
// earlier by captureVideoManifest) carries each clip's DOM heading/caption +
// dims. Surfaced FIRST and tagged `[video]`: for a product/demo these moving
// clips are usually the strongest hero material, and downstream planners key off
// the `[video]` marker. (The `videos/` dir is skipped in the image walk above —
// its entries come from the manifest, which has the captions the bare files lack.)
const videoLines: string[] = [];
try {
const manifest = JSON.parse(
readFileSync(join(outputDir, "extracted", "video-manifest.json"), "utf-8"),
) as Array<{
filename?: string;
localPath?: string;
caption?: string;
heading?: string;
width?: number;
height?: number;
}>;
for (const v of manifest) {
if (!v.localPath) continue; // only describe clips that actually downloaded
const base = v.localPath.split("/").pop() || v.filename || "";
if (!base) continue;
const desc =
(v.caption || v.heading || "").trim().replace(/\s+/g, " ").slice(0, 140) || "motion clip";
const dims = v.width && v.height ? `, ~${v.width}×${v.height}` : "";
videoLines.push(`${base} — [video] ${desc}${dims}`);
}
} catch {
/* no video manifest */
}

return [...videoLines, ...captionedLines, ...uncaptionedLines, ...svgLines, ...fontLines];
}
132 changes: 132 additions & 0 deletions packages/core/src/studio-api/helpers/subComposition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,136 @@ describe("buildSubCompositionHtml", () => {
expect(html).not.toContain("url('../poster.png')");
expect(html).not.toContain('url("../fonts/brand.woff2")');
});

it("promotes the <template>'s data-composition-id onto the root element when the content lacks one", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/02-music.html": `<template data-composition-id="02-music">
<style>#music-scene { background: #121212; }</style>
<div id="music-scene" class="clip" data-start="0" data-duration="3.213" data-track-index="0">
<div id="music-headline">ready when you are.</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["02-music"] = gsap.timeline({ paused: true });
</script>
</template>`,
});

const html = buildSubCompositionHtml(
dir,
"compositions/frames/02-music.html",
"/api/runtime.js",
);

// Root rendered element gains the id so the runtime can bind the timeline
// (attribute order is serializer-dependent, so match the tag as a whole).
expect(html).toMatch(
/<div\b(?=[^>]*\sid="music-scene")(?=[^>]*\sdata-composition-id="02-music")[^>]*>/,
);
// The <style>/<script> siblings must NOT receive it.
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
expect(html).not.toMatch(/<script[^>]*data-composition-id/i);
});

it("extracts the real <template> even when a head comment mentions the literal text <template>", () => {
// Regression: a greedy /<template>([\s\S]*)<\/template>/ regex latches onto
// the "<template>" inside the head comment, mis-slicing the capture so the
// real content stays wrapped in an inert <template> in the output — leaving
// the preview with no [data-composition-id] element and rendering blank.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/03-force-pair.html": `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- head is metadata only; the HF runtime clones ONLY <template> contents -->
</head>
<body>
<template>
<style>#fp-root { width: 1920px; height: 1080px; }</style>
<div id="fp-root" data-composition-id="03-force-pair" data-width="1920" data-height="1080">
<div class="headline">forces come in pairs</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["03-force-pair"] = gsap.timeline({ paused: true });
</script>
</template>
</body>
</html>`,
});

const html = buildSubCompositionHtml(
dir,
"compositions/frames/03-force-pair.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);

expect(html).not.toBeNull();
// The real composition content is rendered into <body> directly...
expect(html).toContain('data-composition-id="03-force-pair"');
expect(html).toContain("forces come in pairs");
expect(html).toContain('__timelines["03-force-pair"]');
// ...and is NOT re-wrapped in an inert <template> (which the browser would
// never render). The comment fragment must not leak through either.
const bodyStart = html!.indexOf("<body>");
const body = html!.slice(bodyStart);
expect(body).not.toContain("<template");
expect(body).not.toContain("clones ONLY");
});

it("escapes #id selectors whose id starts with a digit so the rule is not dropped", () => {
// A CSS ident can't start with a digit, so `#01-wall { ... }` is an invalid
// selector and the browser drops the whole rule — the root loses its size and
// background and a standalone preview renders blank. Escape it to a valid form.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/01-wall.html": `<template>
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="5.5" data-track-index="0">
<style>
#01-wall { position: absolute; inset: 0; width: 1920px; height: 1080px; background-color: #F0EBDE; }
.arrow { background: #1F2BE0; color: #1A2B3C; }
</style>
<div class="arrow">action</div>
</div>
</template>`,
});

const html = buildSubCompositionHtml(
dir,
"compositions/frames/01-wall.html",
"/api/runtime.js",
);

expect(html).not.toBeNull();
// The invalid `#01-wall` selector is rewritten to its escaped, valid form.
expect(html).toContain("#\\30 1-wall {");
expect(html).not.toMatch(/#01-wall\s*\{/);
// Digit-leading hex color VALUES (not element ids) must be left untouched.
expect(html).toContain("background: #1F2BE0;");
expect(html).toContain("color: #1A2B3C;");
// The element's id attribute itself is unchanged (only the selector is escaped).
expect(html).toContain('id="01-wall"');
});

it("does not add a duplicate data-composition-id when the root element already has one", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/04-allinone.html": `<template data-composition-id="04-allinone">
<div id="c04" data-composition-id="04-allinone" data-start="0" data-duration="5.695" data-track-index="0">
<div class="headline">all in one app.</div>
</div>
</template>`,
});

const html = buildSubCompositionHtml(
dir,
"compositions/frames/04-allinone.html",
"/api/runtime.js",
);
const occurrences = html?.match(/data-composition-id="04-allinone"/g) ?? [];
expect(occurrences).toHaveLength(1);
});
});
114 changes: 109 additions & 5 deletions packages/core/src/studio-api/helpers/subComposition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,53 @@ function rewriteRelativePaths(root: ParentNode, compPath: string): void {
}
}

/**
* Escape a CSS identifier whose first character is a digit so it is a valid
* selector. A CSS ident cannot start with a digit, so it must be written as an
* escaped code point: `01-foo` → `\30 1-foo` (leading `0` → `\30 `, rest kept).
*/
function escapeLeadingDigitIdent(id: string): string {
return `\\${id.charCodeAt(0).toString(16)} ${id.slice(1)}`;
}

const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;

/**
* Fix `#<digit-leading-id>` selectors in the tree's `<style>` blocks.
*
* CSS identifiers cannot start with a digit, so an authored rule like
* `#01-wall-pushes-back { width: 1920px; height: 1080px; background: #F0EBDE }`
* is an invalid selector and the browser silently drops the WHOLE rule — taking
* the root's size and background with it. In a full composition the frame is
* stretched/painted by its `data-composition-src` host so the collapse is
* masked, but a standalone preview has no host: the root falls back to
* `height: 0` + transparent and the frame renders blank (black).
*
* Rewrite each such selector to its escaped, valid form (`#\30 1-wall-pushes-back`,
* which still matches `id="01-wall-pushes-back"`) so the rule applies and the
* whole declaration block — size, background, position, container-type — comes
* back. Scoped to ids that are actually present on elements in the content and
* matched only as `#id` not followed by another ident char, so hex colors
* (`#1F2BE0`) and other values are never touched (they are not element ids).
*/
function fixDigitLeadingIdSelectors(root: ParentNode): void {
const digitIds = new Set<string>();
for (const el of root.querySelectorAll("[id]")) {
const id = el.getAttribute("id");
if (id && /^\d/.test(id)) digitIds.add(id);
}
if (digitIds.size === 0) return;

for (const styleEl of root.querySelectorAll("style")) {
let css = styleEl.textContent || "";
for (const id of digitIds) {
const pattern = new RegExp(`#${id.replace(REGEXP_SPECIALS, "\\$&")}(?![\\w-])`, "g");
css = css.replace(pattern, `#${escapeLeadingDigitIdent(id)}`);
}
styleEl.textContent = css;
}
}

/**
* Parse a full HTML document and extract its head elements and body
* content separately, so they can be reassembled into a clean standalone
Expand Down Expand Up @@ -69,6 +116,9 @@ function extractFullDocumentParts(
for (const target of rewriteTargets) {
rewriteRelativePaths(target, compPath);
}
// Run on the whole document: ids live in <body> but their rules may live in
// a <head> <style>, so the scope must span both.
fixDigitLeadingIdSelectors(doc);

const headContent = doc.head?.innerHTML ?? "";
const bodyContent = doc.body?.innerHTML ?? "";
Expand All @@ -80,6 +130,26 @@ function extractFullDocumentParts(
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
}

/**
* Extract the inner HTML of the composition's wrapping `<template>` element, or
* `null` if the source has no `<template>`.
*
* Located via the DOM rather than a regex. A greedy
* `/<template[^>]*>([\s\S]*)<\/template>/` can latch onto a literal
* `"<template>"` that appears inside an HTML comment — e.g. a head note such as
* "the HF runtime clones ONLY <template> contents" — and mis-slice the capture,
* leaving the real composition content re-wrapped in an inert `<template>` in
* the output. That template is never rendered by the browser, so the standalone
* preview has no `[data-composition-id]` element and no registered timeline, and
* renders blank. `querySelector("template")` only ever matches a real element
* node, so comment text can't fool it.
*/
function extractTemplateInnerHtml(rawComp: string): string | null {
const { document: doc } = parseHTML(rawComp);
const template = doc.querySelector("template");
return template ? template.innerHTML : null;
}

function extractElementAttrs(el: Element): string {
const parts: string[] = [];
for (let i = 0; i < el.attributes.length; i++) {
Expand All @@ -93,6 +163,38 @@ function extractElementAttrs(el: Element): string {
return parts.join(" ");
}

const NON_RENDERED_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);

/**
* Carry the `<template>`'s `data-composition-id` onto the content's root
* rendered element when the author declared it only on the `<template>` tag.
*
* In a full composition, each sub-composition is mounted under a wrapper
* element (the `data-composition-src` host) that carries the composition id,
* which is how the runtime binds `window.__timelines[id]` into the player's
* master timeline. A standalone preview has no such wrapper, so it relies on
* the frame's own root element carrying `data-composition-id`. If the id lives
* only on the inert `<template>` tag (a common authoring pattern), the rendered
* body has no `[data-composition-id]` element — the runtime then never selects
* a root composition, the registered GSAP timeline stays unbound, and seeking
* does nothing. The frame renders at its pre-animation state (GSAP `fromTo`
* pins `opacity:0`), producing a blank preview/thumbnail.
*
* This is a no-op when the content already exposes a `[data-composition-id]`
* element (e.g. the id is authored on the root div), so compositions that
* already render correctly are untouched.
*/
function promoteTemplateCompositionId(rawComp: string, body: Element): void {
const templateCompositionId = rawComp.match(
/<template[^>]*\sdata-composition-id\s*=\s*["']([^"']+)["']/i,
)?.[1];
if (!templateCompositionId) return;
if (body.querySelector("[data-composition-id]")) return;

const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
root?.setAttribute("data-composition-id", templateCompositionId);
}

/**
* Add `data-composition-file="<compPath>"` to the comp's root composition
* element (the first `[data-composition-id]` that lacks the attribute), so the
Expand Down Expand Up @@ -144,15 +246,16 @@ export function buildSubCompositionHtml(
let htmlAttrs = "";
let bodyAttrs = "";

const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
const templateInner = extractTemplateInnerHtml(rawComp);

if (templateMatch) {
const content = templateMatch[1];
if (templateInner != null) {
const { document: contentDoc } = parseHTML(
`<!DOCTYPE html><html><head></head><body>${content}</body></html>`,
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
);
rewriteRelativePaths(contentDoc, compPath);
rewrittenContent = contentDoc.body.innerHTML || content!;
fixDigitLeadingIdSelectors(contentDoc);
promoteTemplateCompositionId(rawComp, contentDoc.body);
rewrittenContent = contentDoc.body.innerHTML || templateInner;
} else if (isFullHtmlDocument(rawComp)) {
const parts = extractFullDocumentParts(rawComp, compPath);
compHeadContent = parts.headContent;
Expand All @@ -164,6 +267,7 @@ export function buildSubCompositionHtml(
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
);
rewriteRelativePaths(contentDoc, compPath);
fixDigitLeadingIdSelectors(contentDoc);
rewrittenContent = contentDoc.body.innerHTML || rawComp;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/gsapDragCommit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { describe, expect, it, beforeEach } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
commitGsapPositionFromDrag,
commitStaticGsapPosition,
commitStaticGsapRotation,
parkPlayheadOnKeyframe,
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { usePlayerStore } from "../player/store/playerStore";

// Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).
Expand Down
Loading