diff --git a/packages/core/package.json b/packages/core/package.json
index f8e286e35..6d38d1123 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -21,6 +21,10 @@
"import": "./src/index.ts",
"types": "./src/index.ts"
},
+ "./assemble": {
+ "import": "./src/assemble/index.ts",
+ "types": "./src/assemble/index.ts"
+ },
"./lint": {
"import": "./src/lint/index.ts",
"types": "./src/lint/index.ts"
@@ -52,6 +56,10 @@
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
+ "./assemble": {
+ "import": "./dist/assemble/index.js",
+ "types": "./dist/assemble/index.d.ts"
+ },
"./lint": {
"import": "./dist/lint/index.js",
"types": "./dist/lint/index.d.ts"
diff --git a/packages/core/src/assemble/index.test.ts b/packages/core/src/assemble/index.test.ts
new file mode 100644
index 000000000..0052d83a5
--- /dev/null
+++ b/packages/core/src/assemble/index.test.ts
@@ -0,0 +1,192 @@
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { assembleScenes } from "./index";
+
+function makeProject(): string {
+ const dir = mkdtempSync(join(tmpdir(), "hyperframes-assemble-"));
+ mkdirSync(join(dir, ".hyperframes", "scenes"), { recursive: true });
+ writeFileSync(
+ join(dir, "index.html"),
+ `
`,
+ "utf-8",
+ );
+ return dir;
+}
+
+function writeScene(dir: string, sceneNumber: number, title: string): void {
+ writeFileSync(
+ join(dir, ".hyperframes", "scenes", `scene${sceneNumber}.html`),
+ `
+${title}
+
+.s${sceneNumber}-title { color: white; }
+
+var S${sceneNumber} = ${sceneNumber === 1 ? 0 : 4};
+tl.set(".s${sceneNumber}-title", { opacity: 0 }, 0);
+tl.to(".s${sceneNumber}-title", { opacity: 1, duration: 0.3, ease: "power2.out" }, S${sceneNumber});`,
+ "utf-8",
+ );
+}
+
+describe("assembleScenes", () => {
+ it("injects scene HTML, CSS, and GSAP into scaffold markers", () => {
+ const dir = makeProject();
+ writeScene(dir, 1, "One");
+ writeScene(dir, 2, "Two");
+
+ const result = assembleScenes(dir);
+
+ expect(result.ok).toBe(true);
+ expect(result.scenes).toBe(2);
+ const assembled = readFileSync(join(dir, "index.html"), "utf-8");
+ expect(assembled).toContain("");
+ expect(assembled).toContain('One
');
+ expect(assembled).toContain(".s2-title { color: white; }");
+ expect(assembled).toContain('tl.to(".s2-title"');
+ });
+
+ it("can re-run assembly without duplicating generated scene blocks", () => {
+ const dir = makeProject();
+ writeScene(dir, 1, "One");
+ writeScene(dir, 2, "Two");
+
+ expect(assembleScenes(dir).ok).toBe(true);
+ writeScene(dir, 1, "Updated");
+ expect(assembleScenes(dir).ok).toBe(true);
+
+ const assembled = readFileSync(join(dir, "index.html"), "utf-8");
+ expect(assembled).toContain('Updated
');
+ expect(assembled).not.toContain('One
');
+ expect(assembled.match(/HYPERFRAMES GENERATED SCENE 1 HTML START/g)).toHaveLength(1);
+ expect(assembled.match(/HYPERFRAMES GENERATED SCENE STYLES START/g)).toHaveLength(1);
+ expect(assembled.match(/HYPERFRAMES GENERATED SCENE TWEENS START/g)).toHaveLength(1);
+ });
+
+ it("finds scene slots with extra classes and reordered attributes", () => {
+ const dir = makeProject();
+ writeFileSync(
+ join(dir, "index.html"),
+ ``,
+ "utf-8",
+ );
+ writeScene(dir, 1, "One");
+ writeScene(dir, 2, "Two");
+
+ const result = assembleScenes(dir);
+
+ expect(result.ok).toBe(true);
+ expect(readFileSync(join(dir, "index.html"), "utf-8")).toContain(
+ 'Two
',
+ );
+ });
+
+ it("rejects standalone scene documents", () => {
+ const dir = makeProject();
+ writeFileSync(
+ join(dir, ".hyperframes", "scenes", "scene1.html"),
+ `
+
+Broken
+
+h1 { color: white; }
+
+var S1 = 0;`,
+ "utf-8",
+ );
+ writeFileSync(
+ join(dir, ".hyperframes", "scenes", "scene2.html"),
+ `
+Two
+
+h1 { color: white; }
+
+var S2 = 4;`,
+ "utf-8",
+ );
+
+ const result = assembleScenes(dir, { dryRun: true });
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.some((error) => error.message.includes("DOCTYPE"))).toBe(true);
+ });
+
+ it("rejects scene fragment contract violations", () => {
+ const dir = makeProject();
+ writeFileSync(
+ join(dir, ".hyperframes", "scenes", "scene1.html"),
+ `
+Broken
+
+body { margin: 0; }
+.heading { transform: translate(-50%, -50%); }
+#scene1 { opacity: 1; }
+
+tl.set(".heading", { opacity: 0 });
+tl.to(".heading", { opacity: 1, repeat: -1 }, 0);`,
+ "utf-8",
+ );
+ writeScene(dir, 2, "Two");
+
+ const result = assembleScenes(dir, { dryRun: true });
+ const messages = result.errors.map((error) => error.message).join("\n");
+
+ expect(result.ok).toBe(false);
+ expect(messages).toContain('HTML id "hero" must use "s1-" prefix');
+ expect(messages).toContain("CSS must not style body");
+ expect(messages).toContain("CSS must not set opacity on #scene1");
+ expect(messages).toContain('GSAP section must define "var S1 = ;"');
+ expect(messages).toContain('GSAP repeat value "-1" must be a finite non-negative number');
+ expect(messages).toContain("tl.set() calls must use time 0");
+ expect(messages).toContain("tl.to() calls must use S1");
+ });
+
+ it("rejects direct gsap tween creators in scene fragments", () => {
+ const dir = makeProject();
+ writeFileSync(
+ join(dir, ".hyperframes", "scenes", "scene1.html"),
+ `
+Broken
+
+.s1-title { color: white; }
+
+var S1 = 0;
+gsap.from(".s1-title", { opacity: 0, duration: 0.3 });`,
+ "utf-8",
+ );
+ writeScene(dir, 2, "Two");
+
+ const result = assembleScenes(dir, { dryRun: true });
+ const messages = result.errors.map((error) => error.message).join("\n");
+
+ expect(result.ok).toBe(false);
+ expect(messages).toContain("gsap.from(); use tl.set() and tl.to() instead");
+ });
+});
diff --git a/packages/core/src/assemble/index.ts b/packages/core/src/assemble/index.ts
new file mode 100644
index 000000000..d19ededef
--- /dev/null
+++ b/packages/core/src/assemble/index.ts
@@ -0,0 +1,687 @@
+/**
+ * Deterministic multi-scene assembly.
+ *
+ * Reads a scaffold (`index.html`) and scene fragments
+ * (`.hyperframes/scenes/sceneN.html`), validates each fragment against the
+ * Scene Fragment Spec, splits on markers, and injects the pieces into the
+ * scaffold.
+ */
+
+import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+export interface AssembleResult {
+ ok: boolean;
+ errors: AssembleError[];
+ lines: number;
+ scenes: number;
+ outputPath: string;
+}
+
+export interface AssembleError {
+ file: string;
+ message: string;
+}
+
+export interface AssembleOptions {
+ dryRun?: boolean;
+}
+
+interface FragmentSections {
+ html: string;
+ css: string;
+ gsap: string;
+}
+
+const HTML_MARKER = "";
+const CSS_MARKER = "";
+const GSAP_MARKER = "";
+const STYLE_MARKER = "/* SCENE STYLES */";
+const TWEENS_MARKER = "// SCENE TWEENS";
+const GENERATED_STYLES_START = "/* HYPERFRAMES GENERATED SCENE STYLES START */";
+const GENERATED_STYLES_END = "/* HYPERFRAMES GENERATED SCENE STYLES END */";
+const GENERATED_TWEENS_START = "// HYPERFRAMES GENERATED SCENE TWEENS START";
+const GENERATED_TWEENS_END = "// HYPERFRAMES GENERATED SCENE TWEENS END";
+
+const PROHIBITED_PATTERNS: Array<[RegExp, string]> = [
+ [/]/i, " tag; fragments must not be standalone documents"],
+ [/]/i, " tag; fragments must not be standalone documents"],
+ [/]/i, " tag; fragments must not be standalone documents"],
+ [/ tag; CSS section must be raw CSS"],
+ [/ tag; GSAP section must be raw JS"],
+ [/
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene");
+
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("warns on tl.fromTo() in multi-scene compositions", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene");
+
+ expect(finding).toBeDefined();
+ expect(finding?.message).toContain("tl.fromTo");
+ });
+
+ it("does not warn on tl.from() in single-scene compositions", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene");
+
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn on tl.from() targeting first-scene prefixed elements", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene");
+
+ expect(finding).toBeUndefined();
+ });
+
+ it("warns on scene 10 tl.from() when scene 1 is first", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene");
+
+ expect(finding).toBeDefined();
+ });
+
+ it("warns on tl.set with opacity: 0 after time 0 in multi-scene compositions", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "late_init_set");
+
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("warns on tl.set with opacity: 0 at a variable scene start", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "late_init_set");
+
+ expect(finding).toBeDefined();
+ expect(finding?.message).toContain("S2");
+ });
+
+ it("does not warn on tl.set with opacity: 0 at time 0", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "late_init_set");
+
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn on tl.set with opacity: 0 and no explicit position", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "late_init_set");
+
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not warn on tl.set with fractional opacity", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "late_init_set");
+
+ expect(finding).toBeUndefined();
+ });
+
+ it("errors when scene container CSS overrides scaffold positioning", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "scene_position_override");
+
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.elementId).toBe("scene2");
+ });
+
+ it("does not error when scene container CSS leaves position alone", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "scene_position_override");
+
+ expect(finding).toBeUndefined();
+ });
+
+ it("does not error on scene container position in single-scene compositions", () => {
+ const html = `
+
+
+
+
+`;
+
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "scene_position_override");
+
+ expect(finding).toBeUndefined();
+ });
});
diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts
index 8e83dbd42..479305069 100644
--- a/packages/core/src/lint/rules/gsap.ts
+++ b/packages/core/src/lint/rules/gsap.ts
@@ -1,7 +1,12 @@
import { parseGsapScript } from "../../parsers/gsapParser";
import type { LintContext, HyperframeLintFinding } from "../context";
import type { OpenTag } from "../utils";
-import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils";
+import {
+ getSceneElements,
+ readAttr,
+ truncateSnippet,
+ WINDOW_TIMELINE_ASSIGN_PATTERN,
+} from "../utils";
// ── GSAP-specific types ────────────────────────────────────────────────────
@@ -329,6 +334,96 @@ function getSingleClassSelector(selector: string): string | null {
return match?.groups?.name || null;
}
+function getSceneNumberFromId(id: string): number | null {
+ const match = id.match(/^scene(\d+)$/i);
+ if (!match?.[1]) return null;
+ const value = Number(match[1]);
+ return Number.isFinite(value) ? value : null;
+}
+
+function getFirstSceneNumber(sceneElements: OpenTag[]): number | null {
+ const sceneNumbers = sceneElements
+ .map((tag) => getSceneNumberFromId(readAttr(tag.raw, "id") || ""))
+ .filter((value) => value !== null);
+ if (sceneNumbers.length === 0) return null;
+ return Math.min(...sceneNumbers);
+}
+
+function selectorTargetsSceneNumber(selector: string, sceneNumber: number): boolean {
+ return new RegExp(`(^|[\\s>+~,])#scene${sceneNumber}(?![\\w-])`).test(selector);
+}
+
+function selectorUsesScenePrefix(selector: string, sceneNumber: number): boolean {
+ return new RegExp(`(^|[\\s>+~,])[#.]s${sceneNumber}-`).test(selector);
+}
+
+function splitTopLevelArgs(args: string): string[] {
+ const result: string[] = [];
+ let current = "";
+ let depth = 0;
+ let quote = "";
+ let escaped = false;
+
+ for (const char of args) {
+ if (quote) {
+ current += char;
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = "";
+ }
+ continue;
+ }
+
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ current += char;
+ continue;
+ }
+
+ if (char === "(" || char === "{" || char === "[") {
+ depth += 1;
+ current += char;
+ continue;
+ }
+
+ if (char === ")" || char === "}" || char === "]") {
+ depth -= 1;
+ current += char;
+ continue;
+ }
+
+ if (char === "," && depth === 0) {
+ result.push(current.trim());
+ current = "";
+ continue;
+ }
+
+ current += char;
+ }
+
+ if (current.trim()) result.push(current.trim());
+ return result;
+}
+
+function getGsapPositionExpression(rawCall: string): string {
+ const openParenIndex = rawCall.indexOf("(");
+ const closeParenIndex = rawCall.lastIndexOf(")");
+ if (openParenIndex === -1 || closeParenIndex === -1 || closeParenIndex <= openParenIndex) {
+ return "";
+ }
+
+ return splitTopLevelArgs(rawCall.slice(openParenIndex + 1, closeParenIndex))[2] ?? "";
+}
+
+function isZeroPositionExpression(positionExpression: string): boolean {
+ if (!positionExpression) return true;
+ const numeric = Number(positionExpression);
+ return Number.isFinite(numeric) && numeric === 0;
+}
+
function cssTransformToGsapProps(cssTransform: string): string | null {
const parts: string[] = [];
@@ -744,11 +839,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
- // Detect multi-scene compositions: multiple elements with "scene" in their id
- const sceneElements = tags.filter((t) => {
- const id = readAttr(t.raw, "id") || "";
- return /^scene\d+$/i.test(id);
- });
+ const sceneElements = getSceneElements(tags);
if (sceneElements.length < 2) return findings;
for (const script of scripts) {
@@ -779,4 +870,109 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
return findings;
},
+
+ // tl_from_in_multiscene
+ ({ scripts, tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const sceneElements = getSceneElements(tags);
+ if (sceneElements.length < 2) return findings;
+
+ const firstSceneNumber = getFirstSceneNumber(sceneElements);
+
+ for (const script of scripts) {
+ for (const win of extractGsapWindows(script.content)) {
+ if (win.method !== "from" && win.method !== "fromTo") continue;
+ if (
+ firstSceneNumber !== null &&
+ (selectorTargetsSceneNumber(win.targetSelector, firstSceneNumber) ||
+ selectorUsesScenePrefix(win.targetSelector, firstSceneNumber))
+ ) {
+ continue;
+ }
+
+ findings.push({
+ code: "tl_from_in_multiscene",
+ severity: "warning",
+ message:
+ `tl.${win.method}("${truncateSnippet(win.targetSelector, 40)}") in a multi-scene composition. ` +
+ "Use tl.set() at time 0 to hide, then tl.to() to animate in. " +
+ "tl.from() and tl.fromTo() can flash elements before their entrance when seeking non-linearly.",
+ selector: win.targetSelector,
+ fixHint:
+ `Replace with: tl.set("${truncateSnippet(win.targetSelector, 30)}", { opacity: 0, ... }, 0); ` +
+ `tl.to("${truncateSnippet(win.targetSelector, 30)}", { opacity: 1, ... }, startTime);`,
+ snippet: truncateSnippet(win.raw),
+ });
+ }
+ }
+
+ return findings;
+ },
+
+ // late_init_set
+ ({ scripts, tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const sceneElements = getSceneElements(tags);
+ if (sceneElements.length < 2) return findings;
+
+ for (const script of scripts) {
+ for (const win of extractGsapWindows(script.content)) {
+ if (win.method !== "set") continue;
+ if (!zeroValue(win.propertyValues.opacity) && !zeroValue(win.propertyValues.autoAlpha)) {
+ continue;
+ }
+ if ("visibility" in win.propertyValues || "display" in win.propertyValues) continue;
+
+ const positionExpression = getGsapPositionExpression(win.raw);
+ if (isZeroPositionExpression(positionExpression)) continue;
+
+ findings.push({
+ code: "late_init_set",
+ severity: "warning",
+ message:
+ `tl.set("${truncateSnippet(win.targetSelector, 40)}", { opacity: 0 }) at time ` +
+ `${positionExpression || win.position}s instead of 0. Elements must be hidden from time 0 ` +
+ "to prevent flashing when transitions reveal scenes early.",
+ selector: win.targetSelector,
+ fixHint: `Move to time 0: tl.set("${truncateSnippet(win.targetSelector, 30)}", { opacity: 0, ... }, 0);`,
+ snippet: truncateSnippet(win.raw),
+ });
+ }
+ }
+
+ return findings;
+ },
+
+ // scene_position_override
+ ({ styles, tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const sceneElements = getSceneElements(tags);
+ if (sceneElements.length < 2) return findings;
+
+ for (const style of styles) {
+ for (const tag of sceneElements) {
+ const sceneId = readAttr(tag.raw, "id");
+ if (!sceneId) continue;
+
+ const sceneRulePattern = new RegExp(`#${sceneId}\\s*\\{([^}]+)\\}`, "g");
+ let match: RegExpExecArray | null;
+ while ((match = sceneRulePattern.exec(style.content)) !== null) {
+ const declarations = match[1] || "";
+ const positionMatch = declarations.match(/position\s*:\s*(relative|static|fixed)/);
+ if (!positionMatch?.[1]) continue;
+
+ findings.push({
+ code: "scene_position_override",
+ severity: "error",
+ elementId: sceneId,
+ message: `#${sceneId} sets position: ${positionMatch[1]} — this overrides the scaffold's position: absolute and can push the scene off-screen.`,
+ fixHint: `Remove the position property from #${sceneId} CSS. The scaffold owns scene container positioning.`,
+ snippet: truncateSnippet(match[0]),
+ });
+ }
+ }
+ }
+
+ return findings;
+ },
];
diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts
index 7ff05ec5f..baeb29d7d 100644
--- a/packages/core/src/lint/utils.ts
+++ b/packages/core/src/lint/utils.ts
@@ -114,6 +114,10 @@ export function isMediaTag(tagName: string): boolean {
return tagName === "video" || tagName === "audio" || tagName === "img";
}
+export function getSceneElements(tags: OpenTag[]): OpenTag[] {
+ return tags.filter((tag) => /^scene\d+$/i.test(readAttr(tag.raw, "id") || ""));
+}
+
export function truncateSnippet(value: string, maxLength = 220): string | undefined {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) return undefined;
diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts
index 7a925c3c6..0c5c22b53 100644
--- a/packages/core/src/studio-api/helpers/safePath.ts
+++ b/packages/core/src/studio-api/helpers/safePath.ts
@@ -7,7 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean {
return resolved.startsWith(norm) || resolved === resolve(base);
}
-const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
+const IGNORE_DIRS = new Set([".thumbnails", ".hyperframes", "node_modules", ".git"]);
/** Recursively walk a directory and return relative file paths. */
export function walkDir(dir: string, prefix = ""): string[] {
diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md
index 807e6f288..aa9c747a0 100644
--- a/skills/hyperframes/SKILL.md
+++ b/skills/hyperframes/SKILL.md
@@ -1,6 +1,6 @@
---
name: hyperframes
-description: Create video compositions, animations, title cards, overlays, captions, voiceovers, audio-reactive visuals, and scene transitions in HyperFrames HTML. Use when asked to build any HTML-based video content, add captions or subtitles synced to audio, generate text-to-speech narration, create audio-reactive animation (beat sync, glow, pulse driven by music), add animated text highlighting (marker sweeps, hand-drawn circles, burst lines, scribble, sketchout), or add transitions between scenes (crossfades, wipes, reveals, shader transitions). Covers composition authoring, timing, media, and the full video production workflow. For CLI commands (init, lint, preview, render, transcribe, tts) see the hyperframes-cli skill.
+description: Create video compositions, animations, title cards, overlays, captions, voiceovers, audio-reactive visuals, multi-scene build pipelines, persistent-subject choreography, and scene transitions in HyperFrames HTML. Use when asked to build any HTML-based video content, add captions or subtitles synced to audio, generate text-to-speech narration, create audio-reactive animation (beat sync, glow, pulse driven by music), add animated text highlighting (marker sweeps, hand-drawn circles, burst lines, scribble, sketchout), build compositions with 2+ scenes, or add transitions between scenes (crossfades, wipes, reveals, shader transitions). Covers composition authoring, timing, media, scene-fragment assembly, and the full video production workflow. For CLI commands (init, lint, preview, render, transcribe, tts) see the hyperframes-cli skill.
---
# HyperFrames
@@ -42,6 +42,14 @@ Always run on every composition (except single-scene pieces and trivial edits).
Read [references/prompt-expansion.md](references/prompt-expansion.md) for the full process and output format.
+### Step 2b: Route multi-scene work
+
+For any composition with **2 or more scenes**, read [references/multi-scene.md](references/multi-scene.md) before planning or authoring. Follow that pipeline for scaffold creation, scene fragments, evaluation, assembly, and persistent elements.
+
+Before dispatching scene work, produce the blocking scene manifest described in `multi-scene.md`. That manifest is the single source of truth for scene numbers, starts, durations, transition timing, register, and persistent-subject reservations.
+
+The multi-scene reference overrides the generic entrance examples in this file for **scene fragments**: fragments use `tl.set()` at time 0 plus `tl.to()` at scene time. Do not use `gsap.from()` or `tl.from()` inside scene fragments. Single-scene compositions, standalone title cards, and small edits can use the simpler one-pass flow below.
+
### Step 3: Plan
Before writing HTML, think at a high level:
@@ -71,7 +79,7 @@ Position every element where it should be at its **most visible moment** — the
1. **Identify the hero frame** for each scene — the moment when the most elements are simultaneously visible. This is the layout you build.
2. **Write static CSS** for that frame. The `.scene-content` container MUST fill the full scene using `width: 100%; height: 100%; padding: Npx;` with `display: flex; flex-direction: column; gap: Npx; box-sizing: border-box`. Use padding to push content inward — NEVER `position: absolute; top: Npx` on a content container. Absolute-positioned content containers overflow when content is taller than the remaining space. Reserve `position: absolute` for decoratives only.
-3. **Add entrances with `gsap.from()`** — animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there. (In sub-compositions loaded via `data-composition-src`, prefer `gsap.fromTo()` — see load-bearing GSAP rules in [references/motion-principles.md](references/motion-principles.md).)
+3. **Add entrances from the static layout** — animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there. In full standalone timelines, `gsap.from()` is usually the right tool. In multi-scene scene fragments, use the fragment-safe pattern from [references/multi-scene.md](references/multi-scene.md): `tl.set()` at time 0 plus `tl.to()` at scene time. In sub-compositions loaded via `data-composition-src`, prefer `gsap.fromTo()` — see load-bearing GSAP rules in [references/motion-principles.md](references/motion-principles.md).
4. **Add exits with `gsap.to()`** — animate TO offscreen/invisible FROM the CSS position.
### Example
@@ -112,7 +120,7 @@ Position every element where it should be at its **most visible moment** — the
```
```js
-// Step 3: Animate INTO those positions
+// Standalone or single-scene example: animate INTO those positions
tl.from(".title", { y: 60, opacity: 0, duration: 0.6, ease: "power3.out" }, 0);
tl.from(".subtitle", { y: 40, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.2);
tl.from(".logo", { scale: 0.8, opacity: 0, duration: 0.4, ease: "power2.out" }, 0.3);
@@ -246,8 +254,8 @@ Video must be `muted playsinline`. Audio is always a separate `