From c821ad5632cb8c6d095023bdcb89e826e33c53e5 Mon Sep 17 00:00:00 2001 From: ishan pandey Date: Sun, 26 Apr 2026 00:02:35 +0530 Subject: [PATCH] feat(themes): per-theme HTML+GSAP template overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes can now ship their own scene templates that the planner picks from and the assembler renders, alongside the built-ins. Closes the loop between design-system specs (already in docs/design-systems/) and the production renderer — Claude design ships the JSX as reference, this PR lets the same theme ship the production HTML. Mechanism: - Drop /templates/.html with Mustache-style {{prop}} + {{#each items}} substitution. Optional .json sidecar carries metadata (description, whenToUse, durationRange, hookOnly, propsSchema, exampleProps). - Templates register with namespaced ids `__` (double-underscore so the id is safe inside CSS class selectors — a dot would be parsed as a class separator and silently break the template's scoped styles, which is exactly the bug I hit on the first port and fixed before shipping). - LoadedTheme gains a `templates: Template[]` field; the disk loader populates it via loadThemeTemplates(folder, themeId). - New resolveTemplateRegistry(projectDir) returns [...BUILTIN_TEMPLATES, ...activeTheme.templates] for callers. - planScript accepts opts.availableTemplates (defaults to built-ins); when supplied, the planner's tool catalog includes theme templates by their namespaced id and validators accept them. - assembleMaster accepts opts.templates (defaults to built-ins) so scene render uses the right registry. - Studio /script/plan and /script/generate now thread the merged registry through both planScript and assembleMaster. Engine (packages/core/src/script/themes/templateEngine.ts): Tight Mustache subset: {{prop}} HTML-escaped (dot paths supported), {{prop|raw}} unescaped, {{prop|json}} script-tag-safe JSON, {{#each items}}…{{this}}…{{this.x}}…{{@index}}…{{/each}} loops. ~100 LOC, no external deps. Always-injected context exposes {{scene_id}}, {{template_id}}, {{duration}}, {{tokens.colors.*}}, etc. Loader (packages/core/src/script/themes/templateLoader.ts): Scans /templates/*.html + optional .json sidecar, returns Template[] objects with the namespaced id and full render function. Missing sidecar produces a permissive default — the template still loads but the planner has less context for picking it. Example port: - docs/design-systems/dreamspace/templates/cold-open.html — full port of Dreamspace T1 Cold Open from the JSX reference. 4 concentric SVG rings drawing in via stroke-dashoffset, cyan dot orbiting the highlight ring (continuous theta tween via onUpdate with FINITE repeat math so the deterministic capture engine can seek), mono eyebrow, gradient-clipped wordmark, per-word tagline stagger. Honors the dreamspace token palette + ease (power4.out). - docs/design-systems/dreamspace/templates/cold-open.json — metadata sidecar declaring whenToUse, durationRange, hookOnly: true, props schema, exampleProps. Verified end-to-end with the existing my-first-video project: - loadThemeRegistry surfaces dreamspace.templates as [dreamspace__cold-open] - /script/plan now sees it in the planner catalog - assembleMaster with the merged registry renders the scene correctly — 4 rings drawn, eyebrow + wordmark + 5 tagline words visible, dreamspace tokens applied throughout Docs: - docs/design-systems/README.md gets a "Theme-shipped templates" section covering the HTML format, the metadata sidecar, the always-available context, the GSAP requirement (no infinite repeats), and the runtime wiring. --- docs/design-systems/README.md | 70 +++++++++- .../dreamspace/templates/cold-open.html | 89 +++++++++++++ .../dreamspace/templates/cold-open.json | 34 +++++ packages/core/src/script/assemble.ts | 12 +- packages/core/src/script/designTokens.ts | 19 ++- packages/core/src/script/index.ts | 1 + packages/core/src/script/planner.ts | 33 +++-- packages/core/src/script/themes/loader.ts | 2 + packages/core/src/script/themes/manifest.ts | 9 +- packages/core/src/script/themes/registry.ts | 3 + .../core/src/script/themes/templateEngine.ts | 92 +++++++++++++ .../core/src/script/themes/templateLoader.ts | 121 ++++++++++++++++++ packages/core/src/studio-api/routes/script.ts | 16 ++- 13 files changed, 486 insertions(+), 15 deletions(-) create mode 100644 docs/design-systems/dreamspace/templates/cold-open.html create mode 100644 docs/design-systems/dreamspace/templates/cold-open.json create mode 100644 packages/core/src/script/themes/templateEngine.ts create mode 100644 packages/core/src/script/themes/templateLoader.ts diff --git a/docs/design-systems/README.md b/docs/design-systems/README.md index 5d74b550d29..2a16dc90807 100644 --- a/docs/design-systems/README.md +++ b/docs/design-systems/README.md @@ -74,6 +74,74 @@ Or write a `DESIGN.md` and let it overlay the named theme — the resolver merge If a theme is project-specific, drop it under `/themes//` instead of here. Project-local themes override repo-shipped ones of the same id. +## Theme-shipped templates + +A theme can ship its own scene templates that the planner and assembler treat as first-class members of the template catalog. Drop HTML files under `/templates/`: + +``` +docs/design-systems//templates/ +├── cold-open.html ← required: the markup with {{prop}} substitution +├── cold-open.json ← optional: metadata + propsSchema sidecar +├── manifesto.html +└── manifesto.json +``` + +Templates register with namespaced ids: `__` (e.g. `dreamspace__cold-open` — double-underscore so the id is safe to drop into CSS class selectors). They sit alongside built-in template ids in the planner's catalog, so the AI can pick them by name. + +### Template HTML format + +The template engine is a tight Mustache subset. Four primitives: + +| Syntax | Meaning | +|---|---| +| `{{prop}}` | HTML-escaped value lookup. Dot paths supported (`{{tokens.colors.bg}}`). | +| `{{prop\|raw}}` | Unescaped — only for trusted markup. | +| `{{prop\|json}}` | `JSON.stringify`-safe for embedding inside ` + diff --git a/docs/design-systems/dreamspace/templates/cold-open.json b/docs/design-systems/dreamspace/templates/cold-open.json new file mode 100644 index 00000000000..02b47351953 --- /dev/null +++ b/docs/design-systems/dreamspace/templates/cold-open.json @@ -0,0 +1,34 @@ +{ + "description": "Brand reveal hero with 4 concentric orbital rings drawing in, a cyan dot orbiting the highlighted ring, a gradient-clipped wordmark, and a row of tagline words staggering in. Use as the opening scene of an episode.", + "whenToUse": [ + "Opening scene of a video — episode intro / brand reveal", + "Cold-open replacement for hook-bigtext when the brand is the protagonist", + "When the script's first sentence introduces the brand by name" + ], + "durationRange": { "min": 4, "max": 6 }, + "hookOnly": true, + "propsSchema": { + "type": "object", + "properties": { + "eyebrow": { + "type": "string", + "description": "Small mono uppercase line above the wordmark. 2-6 words. Sets context." + }, + "wordmark": { + "type": "string", + "description": "The brand name (or hero word) — rendered huge with paper→accent gradient. Keep under 14 characters for the gradient to read." + }, + "tagline_words": { + "type": "array", + "items": { "type": "string" }, + "description": "3-7 short words that stagger in below the wordmark. Each word must be its own array entry — the stagger fires per item." + } + }, + "required": ["wordmark"] + }, + "exampleProps": { + "eyebrow": "a Space and Time creation", + "wordmark": "dreamspace", + "tagline_words": ["Write", "an", "app.", "Render", "onchain."] + } +} diff --git a/packages/core/src/script/assemble.ts b/packages/core/src/script/assemble.ts index 0df6f33ac39..7c8c8081924 100644 --- a/packages/core/src/script/assemble.ts +++ b/packages/core/src/script/assemble.ts @@ -1,6 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { BUILTIN_TEMPLATES, DEFAULT_TOKENS, type DesignTokens } from "./templates/index.js"; +import type { Template } from "./templates/types.js"; import { defaultAtmosphereForTemplate, renderAtmosphere } from "./atmosphere/index.js"; import { TRANSITION_DURATIONS, defaultTransitionForTemplate } from "./transitions/index.js"; import type { PlannedScene, PlannedScript, SceneTransition } from "./types.js"; @@ -16,6 +17,14 @@ export interface AssembleOptions { height?: number; /** Optional custom GSAP CDN URL. */ gsapUrl?: string; + /** + * Full template registry used for scene rendering. Defaults to + * BUILTIN_TEMPLATES; the studio API passes the union of built-ins + + * active-theme templates so theme-shipped templates render alongside + * built-ins. Templates here must include any id the planned script + * references — unknown ids are silently skipped. + */ + templates?: readonly Template[]; } export interface AssembleResult { @@ -57,8 +66,9 @@ export function assembleMaster(planned: PlannedScript, opts: AssembleOptions): A transitionInMs: number; }> = []; + const templates = opts.templates ?? BUILTIN_TEMPLATES; for (const scene of planned.scenes) { - const tpl = BUILTIN_TEMPLATES.find((t) => t.id === scene.template); + const tpl = templates.find((t) => t.id === scene.template); if (!tpl) continue; const sceneTotal = sceneTotalDuration(scene); const audioStartOffset = scene.audio?.leadInSeconds ?? 0; diff --git a/packages/core/src/script/designTokens.ts b/packages/core/src/script/designTokens.ts index 71c85b04415..07b1bdb045a 100644 --- a/packages/core/src/script/designTokens.ts +++ b/packages/core/src/script/designTokens.ts @@ -9,7 +9,8 @@ import { type LoadedTheme, type ThemeSearchRoots, } from "./themes/index.js"; -import type { DesignTokens } from "./templates/types.js"; +import { BUILTIN_TEMPLATES } from "./templates/index.js"; +import type { DesignTokens, Template } from "./templates/types.js"; /** * Walk up from this module's location until we hit a directory containing @@ -244,6 +245,21 @@ export function listAvailableThemes(projectDir?: string): LoadedTheme[] { return loadThemeRegistry(searchRootsFor(projectDir)); } +/** + * Resolve the union of built-in templates plus the active theme's own + * shipped templates. The planner uses this as its catalog and the + * assembler uses this for template lookup. Disk-shipped templates carry + * namespaced ids (`.`) so they can never shadow a + * built-in by accident — both can coexist. + */ +export function resolveTemplateRegistry( + projectDir: string, + briefOverride?: string | null, +): Template[] { + const active = resolveActiveTheme(projectDir, briefOverride); + return [...BUILTIN_TEMPLATES, ...active.templates]; +} + function fallbackTheme(): LoadedTheme { return { id: "default", @@ -254,6 +270,7 @@ function fallbackTheme(): LoadedTheme { preferences: { atmospheres: [], transitions: [], icons: [] }, designSystemDoc: null, referenceRenderPath: null, + templates: [], source: "builtin", }; } diff --git a/packages/core/src/script/index.ts b/packages/core/src/script/index.ts index b84e1f4c016..4fca1d8dd75 100644 --- a/packages/core/src/script/index.ts +++ b/packages/core/src/script/index.ts @@ -42,6 +42,7 @@ export { parseDesignBriefToTokens, resolveProjectTokens, resolveActiveTheme, + resolveTemplateRegistry, listAvailableThemes, } from "./designTokens.js"; export { diff --git a/packages/core/src/script/planner.ts b/packages/core/src/script/planner.ts index 7275dd8f471..70808b83a66 100644 --- a/packages/core/src/script/planner.ts +++ b/packages/core/src/script/planner.ts @@ -2,6 +2,7 @@ import { callStructuredTool, AnthropicError, DEFAULT_ANTHROPIC_MODEL } from "../ import type { ToolDefinition } from "../anthropic/index.js"; import { RETENTION_PLAYBOOK } from "./playbook.js"; import { BUILTIN_TEMPLATES } from "./templates/index.js"; +import type { Template } from "./templates/types.js"; import { BUILTIN_CHARTS } from "./charts/index.js"; import { ATMOSPHERE_IDS } from "./atmosphere/index.js"; import { TRANSITION_IDS } from "./transitions/index.js"; @@ -67,6 +68,13 @@ export interface PlanOptions { transitions?: string[]; icons?: string[]; }; + /** + * Full list of templates the planner is allowed to pick from. Defaults to + * BUILTIN_TEMPLATES; callers pass the union of built-ins + active-theme + * shipped templates (via resolveTemplateRegistry) so theme-shipped + * templates show up in the catalog and pass validation alongside built-ins. + */ + availableTemplates?: readonly Template[]; } export type ScriptFidelity = "verbatim" | "split-merge" | "refine"; @@ -92,9 +100,12 @@ interface PlanToolInput { }>; } -function buildToolDefinition(maxSceneDuration: number): ToolDefinition { - const templateEnum = BUILTIN_TEMPLATES.map((t) => t.id); - const templateCatalog = BUILTIN_TEMPLATES.map((t) => ({ +function buildToolDefinition( + maxSceneDuration: number, + templates: readonly Template[] = BUILTIN_TEMPLATES, +): ToolDefinition { + const templateEnum = templates.map((t) => t.id); + const templateCatalog = templates.map((t) => ({ id: t.id, description: t.description, whenToUse: t.whenToUse, @@ -252,7 +263,8 @@ export async function planScript(rawScript: string, opts: PlanOptions): Promise< throw new ScriptPlannerError("Script is empty"); } - const tool = buildToolDefinition(opts.maxSceneDuration ?? 9); + const templateRegistry = opts.availableTemplates ?? BUILTIN_TEMPLATES; + const tool = buildToolDefinition(opts.maxSceneDuration ?? 9, templateRegistry); const user = buildUserMessage(rawScript, opts); const sections: string[] = [RETENTION_PLAYBOOK, fidelityRule(opts.fidelity ?? "split-merge")]; @@ -364,7 +376,7 @@ export async function planScript(rawScript: string, opts: PlanOptions): Promise< result = retried; } - let issues = collectSchemaIssues(result.scenes); + let issues = collectSchemaIssues(result.scenes, templateRegistry); if (issues.length > 0) { const corrective = `${system}\n\n# REQUIRED CORRECTIONS — fix and re-emit\n\n` + @@ -376,10 +388,10 @@ export async function planScript(rawScript: string, opts: PlanOptions): Promise< if (!Array.isArray(result?.scenes) || result.scenes.length === 0) { throw new ScriptPlannerError("Planner returned no scenes after schema-correction retry"); } - issues = collectSchemaIssues(result.scenes); + issues = collectSchemaIssues(result.scenes, templateRegistry); } - const validIds = new Set(BUILTIN_TEMPLATES.map((t) => t.id)); + const validIds = new Set(templateRegistry.map((t) => t.id)); const validAtmoIds = new Set(ATMOSPHERE_IDS); const validTransitionIds = new Set(TRANSITION_IDS); const scenes: SceneRef[] = result.scenes.map((scene, i) => { @@ -771,13 +783,16 @@ interface SchemaIssue { * validator — that's intentional. We only flag classes of error the planner * can reasonably fix on retry. */ -function collectSchemaIssues(scenes: PlanToolInput["scenes"]): SchemaIssue[] { +function collectSchemaIssues( + scenes: PlanToolInput["scenes"], + templates: readonly Template[] = BUILTIN_TEMPLATES, +): SchemaIssue[] { const issues: SchemaIssue[] = []; for (let i = 0; i < scenes.length; i++) { const s = scenes[i]; if (!s) continue; const sceneId = s.id || `s${String(i + 1).padStart(2, "0")}`; - const tpl = BUILTIN_TEMPLATES.find((t) => t.id === s.template); + const tpl = templates.find((t) => t.id === s.template); if (!tpl) continue; // unknown template handled elsewhere as a hard error const props = (s.props ?? {}) as Record; const required = diff --git a/packages/core/src/script/themes/loader.ts b/packages/core/src/script/themes/loader.ts index daabdb10055..3f35769f39f 100644 --- a/packages/core/src/script/themes/loader.ts +++ b/packages/core/src/script/themes/loader.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import type { LoadedTheme, ThemeManifest } from "./manifest.js"; import type { DesignTokens } from "../templates/types.js"; +import { loadThemeTemplates } from "./templateLoader.js"; /** * Default search roots for disk themes. The order matters — later roots @@ -102,6 +103,7 @@ export function materializeTheme(raw: unknown, folder: string): LoadedTheme | nu }, designSystemDoc, referenceRenderPath, + templates: loadThemeTemplates(folder, m.id), source: `disk:${folder}`, }; } diff --git a/packages/core/src/script/themes/manifest.ts b/packages/core/src/script/themes/manifest.ts index 4c76bd3273b..fcb09e16f45 100644 --- a/packages/core/src/script/themes/manifest.ts +++ b/packages/core/src/script/themes/manifest.ts @@ -1,4 +1,4 @@ -import type { DesignTokens } from "../templates/types.js"; +import type { DesignTokens, Template } from "../templates/types.js"; /** * On-disk theme manifest. Drop a folder under docs/design-systems// @@ -68,6 +68,13 @@ export interface LoadedTheme { designSystemDoc: string | null; /** Absolute path to the reference render file, if present. */ referenceRenderPath: string | null; + /** + * Theme-shipped templates loaded from /templates/*.html. Each + * carries a namespaced id like `.` so it can sit in + * the same registry as built-in templates without colliding. The planner + * sees them in the tool catalog when this theme is the active theme. + */ + templates: Template[]; /** * Where the theme came from: "builtin" for the TypeScript constants, * "disk:" for a discovered manifest. Used for diff --git a/packages/core/src/script/themes/registry.ts b/packages/core/src/script/themes/registry.ts index 0bf032fd345..076e2cb8764 100644 --- a/packages/core/src/script/themes/registry.ts +++ b/packages/core/src/script/themes/registry.ts @@ -22,6 +22,7 @@ const BUILTIN: LoadedTheme[] = [ }, designSystemDoc: null, referenceRenderPath: null, + templates: [], source: "builtin", }, { @@ -37,6 +38,7 @@ const BUILTIN: LoadedTheme[] = [ }, designSystemDoc: null, referenceRenderPath: null, + templates: [], source: "builtin", }, { @@ -59,6 +61,7 @@ const BUILTIN: LoadedTheme[] = [ }, designSystemDoc: null, referenceRenderPath: null, + templates: [], source: "builtin", }, ]; diff --git a/packages/core/src/script/themes/templateEngine.ts b/packages/core/src/script/themes/templateEngine.ts new file mode 100644 index 00000000000..119a5bf8fb3 --- /dev/null +++ b/packages/core/src/script/themes/templateEngine.ts @@ -0,0 +1,92 @@ +/** + * Tiny Mustache-style template engine for theme-shipped templates. + * Supports the minimum that lets a theme designer port a Remotion JSX + * component to HTML+GSAP without losing the loop / interpolation / token + * access they had in JSX. Not a full Mustache implementation — explicitly + * scoped to the four primitives 90% of templates need: + * + * {{prop}} → HTML-escaped value lookup (dot-path supported) + * {{prop|raw}} → unescaped (use only for trusted markup) + * {{prop|json}} → JSON.stringify-safe for embedding in JS + * {{prop|attr}} → HTML-attribute-escaped (= same as default) + * {{tokens.colors.bg}} → dot-path through any object in context + * {{#each items}}…{{/each}} → iterate an array; inside the body + * {{this}} is the current item + * {{this.label}} works for object items + * {{@index}} is the 0-based position + * + * That's it. No conditionals, no helpers, no partials. If a theme needs + * more, port the template by hand or contribute a richer engine. + */ + +export type TemplateContext = Record; + +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c); +} + +/** + * Embed-safe JSON: escapes " tag can't + * close the script context. Anthropic / OpenAI prompt-caching SDKs use the + * same trick. + */ +function escapeJsonForScript(v: unknown): string { + return JSON.stringify(v).replace(/((cur, key) => { + if (cur == null || typeof cur !== "object") return undefined; + return (cur as Record)[key]; + }, ctx); +} + +function applyFilter(v: unknown, filter: string | undefined): string { + if (v == null) return ""; + if (filter === "json") return escapeJsonForScript(v); + if (filter === "raw") return typeof v === "string" ? v : String(v); + // default + attr both escape; HTML attribute escaping is a superset of + // text escaping that includes quotes — handled by the same map. + const s = typeof v === "string" ? v : String(v); + return escapeHtml(s); +} + +const EACH_RE = /\{\{#each\s+([\w.@]+)\s*\}\}([\s\S]*?)\{\{\/each\}\}/g; +const VAR_RE = /\{\{([@\w.]+)(?:\|(\w+))?\}\}/g; + +export function renderTemplate(html: string, ctx: TemplateContext): string { + // Handle {{#each path}}…{{/each}} first so its body can also contain + // variable substitutions that we resolve in the recursive pass. + let out = html.replace(EACH_RE, (_, path: string, body: string) => { + const arr = lookup(ctx, path); + if (!Array.isArray(arr)) return ""; + return arr + .map((item, i) => + renderTemplate(body, { + ...ctx, + this: item, + "@index": i, + "@first": i === 0, + "@last": i === arr.length - 1, + }), + ) + .join(""); + }); + + // Variable substitutions. Skip anything we already consumed in EACH_RE. + out = out.replace(VAR_RE, (_, path: string, filter: string | undefined) => { + const v = lookup(ctx, path); + return applyFilter(v, filter); + }); + + return out; +} diff --git a/packages/core/src/script/themes/templateLoader.ts b/packages/core/src/script/themes/templateLoader.ts new file mode 100644 index 00000000000..faad29445de --- /dev/null +++ b/packages/core/src/script/themes/templateLoader.ts @@ -0,0 +1,121 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, extname, join } from "node:path"; +import type { Template, TemplateRenderContext } from "../templates/types.js"; +import { renderTemplate } from "./templateEngine.js"; + +/** + * Optional sidecar metadata for a theme-shipped template. Lives next to + * the .html file as .json. All fields are optional — a missing + * sidecar still produces a working template with sensible defaults. + */ +interface TemplateSidecar { + description?: string; + whenToUse?: string[]; + durationRange?: { min?: number; max?: number }; + hookOnly?: boolean; + /** JSON-Schema-style props block. Defaults to a permissive object. */ + propsSchema?: Record; + /** Sample props payload the studio could use to render a preview. */ + exampleProps?: Record; +} + +/** + * Scan /templates/*.html and return Template objects ready + * to merge into BUILTIN_TEMPLATES. Each .html may have a sibling .json + * file carrying metadata; if missing, the template is created with + * defaults (permissive props schema, 4-7s duration range). + * + * Template ids are namespaced as `__` to avoid + * collisions with built-ins. e.g. `dreamspace/templates/cold-open.html` + * registers as id `dreamspace__cold-open`. Double-underscore so the id + * survives use as a CSS class identifier (a dot would be parsed as a + * class separator and silently break the template's scoped styles). + */ +export function loadThemeTemplates(themeFolder: string, themeId: string): Template[] { + const templatesDir = join(themeFolder, "templates"); + if (!existsSync(templatesDir)) return []; + let entries: string[] = []; + try { + entries = readdirSync(templatesDir); + } catch { + return []; + } + const templates: Template[] = []; + for (const name of entries) { + if (extname(name).toLowerCase() !== ".html") continue; + const filePath = join(templatesDir, name); + let isFile = false; + try { + isFile = statSync(filePath).isFile(); + } catch { + continue; + } + if (!isFile) continue; + const baseName = basename(name, ".html"); + const id = `${themeId}__${baseName}`; + const html = safeRead(filePath); + if (!html) continue; + const sidecar = readSidecar(join(templatesDir, `${baseName}.json`)); + templates.push(buildTemplate(id, html, sidecar)); + } + return templates; +} + +function safeRead(path: string): string | null { + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +function readSidecar(path: string): TemplateSidecar { + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")); + if (parsed && typeof parsed === "object") return parsed as TemplateSidecar; + } catch { + /* malformed sidecar is treated as absent — no crash */ + } + return {}; +} + +function buildTemplate(id: string, html: string, sidecar: TemplateSidecar): Template { + const description = + sidecar.description ?? `${id} — theme-shipped template (no description provided)`; + const whenToUse = Array.isArray(sidecar.whenToUse) ? sidecar.whenToUse : []; + const durationRange = { + min: sidecar.durationRange?.min ?? 3, + max: sidecar.durationRange?.max ?? 9, + }; + const propsSchema = + sidecar.propsSchema && typeof sidecar.propsSchema === "object" + ? sidecar.propsSchema + : ({ type: "object", properties: {}, additionalProperties: true } as Record); + return { + id, + description, + whenToUse, + propsSchema, + durationRange, + hookOnly: sidecar.hookOnly === true, + render(props: Record, ctx: TemplateRenderContext): string { + // Build the substitution context. Anything in `props` is exposed at + // top level; framework values are exposed under stable names. + const context = { + ...props, + scene_id: ctx.sceneId, + template_id: id, + duration: ctx.durationSeconds.toFixed(2), + is_hook: ctx.isHook, + audio_src: ctx.audioSrc ?? "", + tokens: ctx.tokens, + // Convenience aliases so themes can write {{colors.bg}} too. + colors: ctx.tokens.colors, + fonts: ctx.tokens.fonts, + motion: ctx.tokens.motion, + }; + return renderTemplate(html, context); + }, + }; +} diff --git a/packages/core/src/studio-api/routes/script.ts b/packages/core/src/studio-api/routes/script.ts index 9fb5ce8e184..4335577f0d4 100644 --- a/packages/core/src/studio-api/routes/script.ts +++ b/packages/core/src/studio-api/routes/script.ts @@ -17,6 +17,7 @@ import { loadResearch, resolveActiveTheme, resolveProjectTokens, + resolveTemplateRegistry, ScriptPlannerError, DESIGN_ART_TEMPLATE, RESEARCH_TEMPLATE, @@ -85,6 +86,9 @@ export function registerScriptRoutes(api: Hono, adapter: StudioApiAdapter): void // (DESIGN_SYSTEM.md + preferred atmospheres / transitions / icons), // not just a colour palette. const activeTheme = resolveActiveTheme(project.dir, designBrief); + // Theme-shipped templates show up in the planner's catalog so the + // AI can pick `.` ids alongside built-ins. + const availableTemplates = resolveTemplateRegistry(project.dir, designBrief); let script = await planScript(text, { apiKey, model: body.model, @@ -98,6 +102,7 @@ export function registerScriptRoutes(api: Hono, adapter: StudioApiAdapter): void themeName: activeTheme.name, themeDesignSystemDoc: activeTheme.designSystemDoc ?? undefined, themePreferences: activeTheme.preferences, + availableTemplates, }); // Second-pass hook critic. Defaults on; the user can set // improveHook: false to skip the extra LLM round-trip when iterating. @@ -503,8 +508,15 @@ export function registerScriptRoutes(api: Hono, adapter: StudioApiAdapter): void return c.json({ error: "forbidden outFile path" }, 403); } - const tokens = resolveProjectTokens(project.dir, loadDesignBrief(project.dir)); - const result = assembleMaster(planned, { projectDir: project.dir, outFile, tokens }); + const briefForAssemble = loadDesignBrief(project.dir); + const tokens = resolveProjectTokens(project.dir, briefForAssemble); + const templates = resolveTemplateRegistry(project.dir, briefForAssemble); + const result = assembleMaster(planned, { + projectDir: project.dir, + outFile, + tokens, + templates, + }); return c.json({ ok: true, planned, result }); } catch (err) { return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);