Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7fe8bab
refactor(skills): restructure product-launch-video into product-lunch…
WaterrrForever Jun 19, 2026
d08e942
fix(skills): correct directory name product-lunch-video -> product-la…
WaterrrForever Jun 19, 2026
d7e4a5d
docs(skill): add inspect + snapshot to product-launch-video validation
WaterrrForever Jun 20, 2026
0bb82d2
feat(skill): add 10 frame-presets and register them in design-spec
WaterrrForever Jun 20, 2026
7a01e7c
refactor(product-launch-video): make every frame a directed shot
WaterrrForever Jun 21, 2026
a789cc5
fix(product-launch-video): floor SFX search min_score to 0.4
WaterrrForever Jun 21, 2026
6532d02
refactor(product-launch-video): stage assets at Step 4; tighten prompts
WaterrrForever Jun 21, 2026
9771c58
chore(skills): refresh skill test list, add biennale caption skin
WaterrrForever Jun 21, 2026
5537d6c
fix(product-launch-video): restore skill name to match directory
WaterrrForever Jun 21, 2026
8a0cb7c
feat(skills): per-preset caption skins + auto-load in product-launch …
WaterrrForever Jun 21, 2026
abf43ae
feat(product-launch-video): one-command Step 2 — build-frame remixes …
WaterrrForever Jun 21, 2026
b416f5d
feat(media): shared audio engine (TTS/BGM/SFX); wire product-launch +…
WaterrrForever Jun 21, 2026
922524e
refactor(product-launch-video): stage assets into assets/ not public/
WaterrrForever Jun 21, 2026
e1782e6
refactor(product-launch-video): build-frame token remix + caption tweaks
WaterrrForever Jun 21, 2026
d61de27
refactor(pr-to-video): restructure onto the script-driven architecture
WaterrrForever Jun 21, 2026
8c9a176
refactor(faceless-explainer): restructure onto the script-driven arch…
WaterrrForever Jun 21, 2026
aca40cd
feat(hyperframes-creative): add claude frame preset
WaterrrForever Jun 21, 2026
e43b377
fix(core): escape digit-leading id selectors in standalone sub-compos…
WaterrrForever Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
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 @@ -39,6 +39,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 @@ -68,6 +115,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 @@ -79,6 +129,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 @@ -92,6 +162,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);
}

/**
* Build a standalone HTML page for a sub-composition.
*
Expand Down Expand Up @@ -126,15 +228,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 @@ -146,6 +249,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
10 changes: 7 additions & 3 deletions scripts/test-skills-fresh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
# 5. Installs the full skills tree from the LOCAL repo via `npx skills add
# --agent <agent>`, then prunes the internal _meta/ authoring skills so the
# installed set matches what an end user gets.
# 6. Verifies the router + 6 workflows + 6 domain skills landed.
# 6. Verifies the router + 10 workflows + 6 domain skills landed.
# 7. Prints the command to start the agent + example prompts to try.
#
# Iterate after editing:
Expand Down Expand Up @@ -234,7 +234,9 @@ fi
say "Verifying skill installation..."

ROUTER="hyperframes"
WORKFLOWS=(product-launch-video faceless-explainer footage-recut pr-to-video general-video remotion-to-hyperframes motion-graphics)
WORKFLOWS=(product-launch-video website-to-video faceless-explainer embedded-captions \
graphic-overlays pr-to-video motion-graphics general-video \
remotion-to-hyperframes slideshow)
DOMAIN=(hyperframes-core hyperframes-creative hyperframes-animation hyperframes-cli hyperframes-media hyperframes-registry)

MISSING=()
Expand Down Expand Up @@ -272,6 +274,8 @@ echo "Then type any request you want to test — the agent routes it to a workfl
echo " • \"make a product launch video for https://your-site.com/\" → product-launch-video (exercises capture)"
echo " • \"explain how transformers work as a faceless explainer video\" → faceless-explainer"
echo " • \"make a video from this PR: owner/repo#123\" → pr-to-video"
echo " • \"recut this footage ./clip.mp4 with info-card overlays\" → footage-recut"
echo " • \"add lower-thirds / overlay cards to ./clip.mp4\" → graphic-overlays"
echo " • \"add captions/subtitles to ./clip.mp4\" → embedded-captions"
echo " • \"turn https://your-site.com/ into a site tour video\" → website-to-video"
echo " • \"a logo reveal / title card / data montage\" → general-video"
echo ""
Loading
Loading