feat(captions): energy-based technique selection and mandatory quality checks (#176)

## Summary

- Rewrite script-to-style mapping as an energy detection table (high → low) with mandatory animation requirements: karaoke baseline, 2+ highlight techniques, kinetic exits
- Replace `tl.call()` per-frame audio-reactive pattern with group-level GSAP tweens — read peak bass/treble for each group's time range and modulate entrance intensity at build time, no per-frame callbacks needed
- Add transcript quality check with automatic retry rules (>20% music tokens = retry with larger model)
- Add caption word structure lint rule (`.caption-group` + `<span>`) for studio editor compatibility
- Add multilingual model guidance and decision tree for model selection

## Test plan

- [ ] Skill files render correctly as markdown
- [ ] Cross-references between SKILL.md, dynamic-techniques.md, and transcript-guide.md resolve correctly
- [ ] `dynamic-techniques.md` audio-reactive section uses `tl.to()`/`tl.set()` only, no `tl.call()` loops

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-04-02 00:47:43 -07:00
committed by GitHub
parent ad2d63db32
commit d36c1785b9
22 changed files with 3881 additions and 14 deletions
+13 -1
View File
@@ -34,7 +34,7 @@ The skills encode HyperFrames-specific patterns (e.g., required `class="clip"` o
- When creating a video from audio (music video, lyric video, audio visualizer with text) → invoke BOTH `/hyperframes-compose` AND `/hyperframes-captions`
- When writing GSAP animations → invoke `/gsap-core` and `/gsap-timeline` BEFORE writing any code
- When optimizing animation performance → invoke `/gsap-performance` BEFORE making changes
- After creating or editing any `.html` composition → run `npx hyperframes lint` and fix all errors before considering the task complete
- After creating or editing any `.html` composition → run `npx hyperframes lint` and `npx hyperframes validate` in parallel, fix all errors before opening the studio or considering the task complete. `lint` checks the HTML structure statically; `validate` loads the composition in headless Chrome and catches runtime JS errors, missing assets, and failed network requests. Always validate before `npx hyperframes preview`.
### Installing skills
@@ -67,6 +67,18 @@ pnpm build # Build all packages
pnpm test # Run tests
```
### Linting & Formatting
This project uses **oxlint** and **oxfmt** (not biome, not eslint, not prettier).
```bash
bunx oxlint <files> # Lint
bunx oxfmt <files> # Format (write)
bunx oxfmt --check <files> # Format (check only, used by pre-commit hook)
```
Always run both on changed files before committing. The lefthook pre-commit hook runs `bunx oxlint` and `bunx oxfmt --check` automatically.
## Key Concepts
- **Compositions** are HTML files with `data-*` attributes defining timeline, tracks, and media
+3
View File
@@ -7,6 +7,9 @@ pre-commit:
format:
glob: "*.{js,jsx,ts,tsx,json,css,md,yaml,yml}"
run: bunx oxfmt --check {staged_files}
typecheck:
glob: "*.{ts,tsx}"
run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit
commit-msg:
commands:
+1
View File
@@ -37,6 +37,7 @@ const subCommands = {
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default),
};
const main = defineCommand({
+247
View File
@@ -0,0 +1,247 @@
import { defineCommand } from "citty";
import { existsSync, readFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject } from "../utils/project.js";
import { c } from "../ui/colors.js";
import { withMeta } from "../utils/updateCheck.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
interface ConsoleEntry {
level: "error" | "warning";
text: string;
url?: string;
line?: number;
}
/**
* Bundle the project HTML with the runtime injected, serve it via a minimal
* static server, open headless Chrome, and collect console errors.
*/
async function validateInBrowser(
projectDir: string,
opts: { timeout?: number },
): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[] }> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
// 1. Bundle
let html = await bundleToSingleHtml(projectDir);
// Inject local runtime if available
const runtimePath = resolve(
__dirname,
"..",
"..",
"..",
"core",
"dist",
"hyperframe.runtime.iife.js",
);
if (existsSync(runtimePath)) {
const runtimeSource = readFileSync(runtimePath, "utf-8");
html = html.replace(
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
`<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
);
}
// 2. Start minimal file server for project assets (audio, images, fonts, json)
const { createServer } = await import("node:http");
const { getMimeType } = await import("@hyperframes/core/studio-api");
const server = createServer((req, res) => {
const url = req.url ?? "/";
if (url === "/" || url === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(html);
return;
}
// Serve project files
const filePath = join(projectDir, decodeURIComponent(url));
if (existsSync(filePath)) {
res.writeHead(200, { "Content-Type": getMimeType(filePath) });
res.end(readFileSync(filePath));
return;
}
res.writeHead(404);
res.end();
});
const port = await new Promise<number>((resolvePort) => {
server.listen(0, () => {
const addr = server.address();
resolvePort(typeof addr === "object" && addr ? addr.port : 0);
});
});
const errors: ConsoleEntry[] = [];
const warnings: ConsoleEntry[] = [];
try {
// 3. Launch headless Chrome
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
const page = await chromeBrowser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 4. Capture console messages
page.on("console", (msg) => {
const type = msg.type();
const loc = msg.location();
const text = msg.text();
if (type === "error") {
// Network errors show as console errors but with no useful location.
// We capture those separately via response/requestfailed events.
if (text.startsWith("Failed to load resource")) return;
errors.push({ level: "error", text, url: loc.url, line: loc.lineNumber });
} else if (type === "warn") {
warnings.push({ level: "warning", text, url: loc.url, line: loc.lineNumber });
}
});
// Capture uncaught exceptions
page.on("pageerror", (err) => {
const message = err instanceof Error ? err.message : String(err);
errors.push({ level: "error", text: message });
});
// Capture failed network requests for project assets (skip favicon, data: URIs)
page.on("requestfailed", (req) => {
const url = req.url();
if (url.includes("favicon")) return;
if (url.startsWith("data:")) return;
// Extract the path relative to the server
const urlObj = new URL(url);
const path = decodeURIComponent(urlObj.pathname).replace(/^\//, "");
const failure = req.failure()?.errorText ?? "net::ERR_FAILED";
errors.push({ level: "error", text: `Failed to load ${path}: ${failure}`, url });
});
// Capture HTTP errors (404, 500, etc.) for project assets
page.on("response", (res) => {
const status = res.status();
if (status >= 400) {
const url = res.url();
if (url.includes("favicon")) return;
const urlObj = new URL(url);
const path = decodeURIComponent(urlObj.pathname).replace(/^\//, "");
errors.push({ level: "error", text: `${status} loading ${path}`, url });
}
});
// 5. Navigate and wait
const timeoutMs = opts.timeout ?? 3000;
await page.goto(`http://127.0.0.1:${port}/`, {
waitUntil: "domcontentloaded",
timeout: 10000,
});
// Wait for scripts to settle
await new Promise((r) => setTimeout(r, timeoutMs));
await chromeBrowser.close();
} finally {
server.close();
}
return { errors, warnings };
}
export default defineCommand({
meta: {
name: "validate",
description: `Load a composition in headless Chrome and report console errors
Examples:
hyperframes validate
hyperframes validate ./my-project
hyperframes validate --json
hyperframes validate --timeout 5000`,
},
args: {
dir: {
type: "positional",
description: "Project directory",
required: false,
},
json: {
type: "boolean",
description: "Output as JSON",
default: false,
},
timeout: {
type: "string",
description: "Ms to wait for scripts to settle (default: 3000)",
default: "3000",
},
},
async run({ args }) {
const project = resolveProject(args.dir);
const timeout = parseInt(args.timeout as string, 10) || 3000;
if (!args.json) {
console.log(`${c.accent("◆")} Validating ${c.accent(project.name)} in headless Chrome`);
}
try {
const { errors, warnings } = await validateInBrowser(project.dir, { timeout });
if (args.json) {
console.log(
JSON.stringify(
withMeta({
ok: errors.length === 0,
errors,
warnings,
}),
null,
2,
),
);
process.exit(errors.length > 0 ? 1 : 0);
}
if (errors.length === 0 && warnings.length === 0) {
console.log(`${c.success("◇")} No console errors`);
return;
}
console.log();
for (const e of errors) {
const loc = e.line ? ` (line ${e.line})` : "";
console.log(` ${c.error("✗")} ${e.text}${c.dim(loc)}`);
}
for (const w of warnings) {
const loc = w.line ? ` (line ${w.line})` : "";
console.log(` ${c.warn("⚠")} ${w.text}${c.dim(loc)}`);
}
console.log();
console.log(`${c.accent("◇")} ${errors.length} error(s), ${warnings.length} warning(s)`);
process.exit(errors.length > 0 ? 1 : 0);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (args.json) {
console.log(
JSON.stringify(
withMeta({ ok: false, error: message, errors: [], warnings: [] }),
null,
2,
),
);
process.exit(1);
}
console.error(`${c.error("✗")} ${message}`);
process.exit(1);
}
},
});
+15 -4
View File
@@ -2,6 +2,10 @@ import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { extname, join } from "node:path";
export interface Word {
/** Stable identifier for referencing this word in overrides and compositions.
* Assigned during normalization as `w{index}`. Optional for backwards compat
* with existing transcript.json files that predate this field. */
id?: string;
text: string;
start: number;
end: number;
@@ -150,13 +154,13 @@ function parseWhisperCpp(data: Record<string, unknown>): Word[] {
}
function parseOpenAI(data: Record<string, unknown>): Word[] {
const rawWords = (data.words ?? []) as Array<{
const words = (data.words ?? []) as Array<{
word?: string;
text?: string;
start?: number;
end?: number;
}>;
return rawWords
return words
.map((w) => ({
text: (w.word ?? w.text ?? "").trim(),
start: round3(w.start ?? 0),
@@ -280,8 +284,14 @@ export function loadTranscript(filePath: string): { words: Word[]; format: Trans
const ext = extname(filePath).toLowerCase();
const content = readFileSync(filePath, "utf-8");
if (ext === ".srt") return { words: parseSrt(content), format: "srt" };
if (ext === ".vtt") return { words: parseVtt(content), format: "vtt" };
if (ext === ".srt") {
const words = parseSrt(content).map((w, i) => ({ ...w, id: w.id ?? `w${i}` }));
return { words, format: "srt" };
}
if (ext === ".vtt") {
const words = parseVtt(content).map((w, i) => ({ ...w, id: w.id ?? `w${i}` }));
return { words, format: "vtt" };
}
// JSON formats — parse once, detect, then extract words
const parsed = JSON.parse(content);
@@ -293,6 +303,7 @@ export function loadTranscript(filePath: string): { words: Word[]; format: Trans
: format === "openai"
? parseOpenAI(parsed)
: (parsed as Word[]).map((w) => ({
id: w.id ?? "",
text: w.text.trim(),
start: round3(w.start),
end: round3(w.end),
@@ -0,0 +1,132 @@
/**
* Caption Overrides — applies per-word style overrides from a JSON data file.
*
* Strategy: wrap each overridden word span in an inline-block wrapper span,
* then apply transforms to the wrapper. The inner span keeps all its original
* GSAP animations (entrance, karaoke, exit) untouched. No tweens are killed.
*
* Matching (in priority order):
* 1. `wordId` — matches by element ID (document.getElementById)
* 2. `wordIndex` — fallback, DOM traversal order across .caption-group > span
*/
export interface CaptionOverride {
wordId?: string;
wordIndex?: number;
x?: number;
y?: number;
scale?: number;
rotation?: number;
/** Color when the word is being spoken (karaoke active state) */
activeColor?: string;
/** Color before and after the word is spoken (dim/inactive state) */
dimColor?: string;
opacity?: number;
fontSize?: number;
fontWeight?: number;
fontFamily?: string;
}
interface GsapTween {
vars: Record<string, unknown>;
startTime(): number;
}
interface GsapStatic {
set: (target: Element, vars: Record<string, unknown>) => void;
killTweensOf: (target: Element, props: string) => void;
getTweensOf: (target: Element) => GsapTween[];
}
export function applyCaptionOverrides(): void {
const gsap = (window as unknown as { gsap?: GsapStatic }).gsap;
if (!gsap) return;
fetch("caption-overrides.json")
.then((r) => {
if (!r.ok) return null;
return r.json();
})
.then((data: CaptionOverride[] | null) => {
if (!data || !Array.isArray(data) || data.length === 0) return;
// Build word element index for wordIndex fallback
const wordEls: Element[] = [];
const groups = document.querySelectorAll(".caption-group");
for (const group of groups) {
const spans = group.querySelectorAll(":scope > span");
for (const span of spans) {
wordEls.push(span);
}
}
for (const override of data) {
let el: Element | null = null;
if (override.wordId) {
el = document.getElementById(override.wordId);
}
if (!el && override.wordIndex !== undefined) {
el = wordEls[override.wordIndex] ?? null;
}
if (!el || !(el instanceof HTMLElement)) continue;
// Split into transform props (wrapper) and style props (word span)
const transformProps: Record<string, unknown> = {};
const styleProps: Record<string, unknown> = {};
if (override.x !== undefined) transformProps.x = override.x;
if (override.y !== undefined) transformProps.y = override.y;
if (override.scale !== undefined) transformProps.scale = override.scale;
if (override.rotation !== undefined) transformProps.rotation = override.rotation;
if (override.opacity !== undefined) styleProps.opacity = override.opacity;
if (override.fontSize !== undefined) styleProps.fontSize = `${override.fontSize}px`;
if (override.fontWeight !== undefined) styleProps.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) styleProps.fontFamily = override.fontFamily;
// Replace color values in existing GSAP tweens by timeline order.
// For any word, color tweens follow: dim (setup) → active (spoken) → after.
// Sort by startTime and assign by position, not by content heuristics.
if (override.activeColor || override.dimColor) {
const allTweens = gsap.getTweensOf(el);
const colorTweens = allTweens
.filter((tw) => tw.vars.color !== undefined)
.sort((a, b) => a.startTime() - b.startTime());
for (let i = 0; i < colorTweens.length; i++) {
if (i === 0 && override.dimColor) {
// First color tween = dim setup
colorTweens[i].vars.color = override.dimColor;
} else if (i === 1 && override.activeColor) {
// Second color tween = active/spoken
colorTweens[i].vars.color = override.activeColor;
} else if (i >= 2 && override.dimColor) {
// Third+ = after/deactivate (use dim color)
colorTweens[i].vars.color = override.dimColor;
}
}
// Set current visible color (words start in dim state)
if (override.dimColor) {
gsap.set(el, { color: override.dimColor });
}
}
// Apply non-color style props
if (Object.keys(styleProps).length > 0) {
gsap.set(el, styleProps);
}
// Wrap the word in an inline-block span and apply transforms to the wrapper.
// This preserves all GSAP entrance/exit/karaoke animations on the inner span.
if (Object.keys(transformProps).length > 0) {
const wrapper = document.createElement("span");
wrapper.style.display = "inline-block";
wrapper.dataset.captionWrapper = "true";
el.parentNode?.insertBefore(wrapper, el);
wrapper.appendChild(el);
gsap.set(wrapper, transformProps);
}
}
})
.catch(() => {});
}
+5
View File
@@ -12,6 +12,7 @@ import { createRuntimeState } from "./state";
import { collectRuntimeTimelinePayload } from "./timeline";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type { PlayerAPI } from "../core.types";
@@ -1316,9 +1317,13 @@ export function initSandboxRuntimeModular(): void {
runAdapters("discover", state.currentTime);
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
applyCaptionOverrides();
postTimeline();
postState(true);
});
} else {
// No external/inline compositions to load — apply caption overrides immediately
applyCaptionOverrides();
}
const picker = createPickerModule({
+205
View File
@@ -12,6 +12,12 @@ import { LintModal } from "./components/LintModal";
import type { LintFinding } from "./components/LintModal";
import { MediaPreview } from "./components/MediaPreview";
import { isMediaFile } from "./utils/mediaTypes";
import { CaptionOverlay } from "./captions/components/CaptionOverlay";
import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
import { CaptionTimeline } from "./captions/components/CaptionTimeline";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { parseCaptionComposition } from "./captions/parser";
interface EditingFile {
path: string;
@@ -50,12 +56,134 @@ export function StudioApp() {
const [fileTree, setFileTree] = useState<string[]>([]);
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
const renderQueue = useRenderQueue(projectId);
const captionEditMode = useCaptionStore((s) => s.isEditMode);
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
const captionSync = useCaptionSync(projectId);
// Resizable and collapsible panel widths
const [leftWidth, setLeftWidth] = useState(240);
const [rightWidth, setRightWidth] = useState(400);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(true);
// Auto-enter caption edit mode when viewing a captions composition
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
// Listens for the runtime's postMessage events (state/timeline) which fire after
// all compositions are loaded, then checks for caption groups.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!projectId) return;
let pollId: ReturnType<typeof setInterval> | null = null;
let activating = false;
const tryActivateCaptions = () => {
if (useCaptionStore.getState().isEditMode || activating) {
if (pollId) { clearInterval(pollId); pollId = null; }
return;
}
const iframe = previewIframeRef.current;
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe?.contentDocument ?? null;
win = iframe?.contentWindow ?? null;
} catch { return; }
if (!doc || !win) return;
const groups = doc.querySelectorAll(".caption-group");
if (groups.length === 0) return;
// Find the captions composition source path.
// The runtime strips data-composition-src after loading, so also check
// data-composition-file (set by the bundler) and the compIdToSrc map.
let captionSrcPath: string | null = null;
// Strategy 1: data-composition-src or data-composition-file attributes
const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
for (const host of compHosts) {
const src = host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
if (src && src.includes("captions")) {
captionSrcPath = src;
break;
}
}
// Strategy 2: compIdToSrc map (built from raw index.html before runtime strips attrs)
if (!captionSrcPath) {
for (const [id, src] of compIdToSrc) {
if (id.includes("caption") || src.includes("caption")) {
captionSrcPath = src;
break;
}
}
}
// Strategy 3: activeCompPath if viewing captions directly
if (!captionSrcPath && activeCompPath?.includes("captions")) {
captionSrcPath = activeCompPath;
}
// Strategy 4: find composition element with "caption" in its ID
if (!captionSrcPath) {
const captionComp = doc.querySelector('[data-composition-id*="caption"]');
if (captionComp) {
const compId = captionComp.getAttribute("data-composition-id") || "";
captionSrcPath = compIdToSrc.get(compId) || null;
}
}
if (!captionSrcPath) return;
activating = true;
const srcPath = captionSrcPath;
fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
.then((r) => r.json())
.then((data: { content?: string }) => {
if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
const root = doc.querySelector("[data-composition-id]");
const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
if (!model) return;
const store = useCaptionStore.getState();
store.setModel(model);
store.setSourceFilePath(srcPath);
store.setEditMode(true);
captionSync.loadOverrides();
})
.catch(() => {})
.finally(() => { activating = false; });
};
// Listen for runtime messages that signal composition loading is complete
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
tryActivateCaptions();
}
};
window.addEventListener("message", handleMessage);
// Try immediately in case compositions are already loaded
tryActivateCaptions();
// Poll until captions are detected — sub-composition scripts run async
pollId = setInterval(tryActivateCaptions, 200);
return () => {
window.removeEventListener("message", handleMessage);
if (pollId) clearInterval(pollId);
};
}, [activeCompPath, projectId, compIdToSrc]);
// Auto-expand right panel when a caption word is selected
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (captionEditMode) {
setRightCollapsed(!captionHasSelection);
}
}, [captionHasSelection, captionEditMode]);
const [globalDragOver, setGlobalDragOver] = useState(false);
const [uploadToast, setUploadToast] = useState<string | null>(null);
const [timelineVisible, setTimelineVisible] = useState(false);
@@ -159,12 +287,15 @@ export function StudioApp() {
[compIdToSrc, activePreviewUrl],
);
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
const [linting, setLinting] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const projectIdRef = useRef(projectId);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
const consoleErrorsRef = useRef<LintFinding[]>([]);
// Listen for external file changes (user editing HTML outside the editor).
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
@@ -673,7 +804,68 @@ export function StudioApp() {
}}
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
consoleErrorsRef.current = [];
setConsoleErrors(null);
if (!iframe) return;
// Attach error capture after each iframe load (content resets on navigation)
const attachErrorCapture = () => {
try {
const win = iframe.contentWindow as (Window & typeof globalThis) | null;
if (!win) return;
// Guard against double-patching
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
const origError = win.console.error.bind(win.console);
win.console.error = function (...args: unknown[]) {
origError(...args);
const text = args
.map((a) => (a instanceof Error ? a.message : String(a)))
.join(" ");
if (text.includes("favicon")) return;
consoleErrorsRef.current = [
...consoleErrorsRef.current,
{ severity: "error", message: text },
];
setConsoleErrors([...consoleErrorsRef.current]);
};
win.addEventListener("error", (e: ErrorEvent) => {
const text = e.message || String(e);
consoleErrorsRef.current = [
...consoleErrorsRef.current,
{ severity: "error", message: text },
];
setConsoleErrors([...consoleErrorsRef.current]);
});
} catch {
// cross-origin — can't attach
}
};
// Attach now (iframe may already be loaded) and on future loads
attachErrorCapture();
iframe.addEventListener("load", () => {
consoleErrorsRef.current = [];
setConsoleErrors(null);
attachErrorCapture();
});
}}
previewOverlay={
captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : undefined
}
timelineFooter={
captionEditMode ? (
<div className="border-t border-neutral-800/30">
<div className="flex items-center gap-1.5 px-2 py-1">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Captions
</span>
</div>
<CaptionTimeline pixelsPerSecond={100} />
</div>
) : undefined
}
timelineVisible={timelineVisible}
onToggleTimeline={() => setTimelineVisible((v) => !v)}
/>
@@ -693,6 +885,9 @@ export function StudioApp() {
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
style={{ width: rightWidth }}
>
{captionEditMode ? (
<CaptionPropertyPanel iframeRef={previewIframeRef} />
) : (
<RenderQueue
jobs={renderQueue.jobs}
projectId={projectId}
@@ -701,6 +896,7 @@ export function StudioApp() {
onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
isRendering={renderQueue.isRendering}
/>
)}
</div>
</>
)}
@@ -711,6 +907,15 @@ export function StudioApp() {
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
)}
{/* Console errors modal — auto-shows when composition has runtime errors */}
{consoleErrors !== null && consoleErrors.length > 0 && projectId && (
<LintModal
findings={consoleErrors}
projectId={projectId}
onClose={() => setConsoleErrors(null)}
/>
)}
{/* Global drag-drop overlay */}
{globalDragOver && (
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
@@ -0,0 +1,300 @@
import { memo, useCallback } from "react";
import { useCaptionStore } from "../store";
import type { CaptionAnimation } from "../types";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ENTRANCE_PRESETS = [
"none",
"fade",
"slide-up",
"slide-down",
"slide-left",
"slide-right",
"pop",
"slam",
"bounce",
"typewriter",
"blur-in",
"flip",
"drop",
];
const HIGHLIGHT_PRESETS = [
"none",
"color-change",
"scale-pop",
"glow-pulse",
"underline-sweep",
"background-fill",
"bounce",
];
const EXIT_PRESETS = [
"none",
"fade",
"slide-up",
"slide-down",
"slide-left",
"slide-right",
"scatter",
"drop",
"collapse",
"blur-out",
"shrink",
];
const EASE_PRESETS = [
"power1.out",
"power2.out",
"power3.out",
"power4.out",
"power1.in",
"power2.in",
"power3.in",
"power1.inOut",
"power2.inOut",
"back.out(1.7)",
"elastic.out(1,0.3)",
"bounce.out",
];
// ---------------------------------------------------------------------------
// Shared input class (matches CaptionPropertyPanel)
// ---------------------------------------------------------------------------
const inputCls =
"w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600";
// ---------------------------------------------------------------------------
// Helper Components
// ---------------------------------------------------------------------------
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mb-3">
<div className="flex items-center gap-1.5 mt-2 mb-1.5">
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
{label}
</span>
</div>
<div className="space-y-1">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="text-2xs text-neutral-600 w-14 text-right flex-shrink-0">{label}</span>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Animation phase controls
// ---------------------------------------------------------------------------
interface AnimationPhaseProps {
label: string;
presets: string[];
animation: CaptionAnimation | null;
showIntensity?: boolean;
onChange: (update: Partial<CaptionAnimation>) => void;
}
function AnimationPhase({
label,
presets,
animation,
showIntensity,
onChange,
}: AnimationPhaseProps) {
const preset = animation?.preset ?? "none";
const duration = animation?.duration ?? 0.2;
const ease = animation?.ease ?? "power2.out";
const stagger = animation?.stagger ?? 0;
const intensity = animation?.intensity ?? 1;
return (
<Section label={label}>
<Row label="Preset">
<select
value={preset}
onChange={(e) => onChange({ preset: e.target.value })}
className={inputCls}
>
{presets.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</Row>
<Row label="Duration">
<input
type="number"
value={duration}
step={0.05}
min={0}
max={2}
onChange={(e) => onChange({ duration: Number(e.target.value) })}
className={inputCls}
/>
</Row>
<Row label="Ease">
<select
value={ease}
onChange={(e) => onChange({ ease: e.target.value })}
className={inputCls}
>
{EASE_PRESETS.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</Row>
<Row label="Stagger">
<input
type="number"
value={stagger}
step={0.02}
min={0}
max={0.5}
onChange={(e) => onChange({ stagger: Number(e.target.value) })}
className={inputCls}
/>
</Row>
{showIntensity && (
<Row label="Intensity">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.01}
value={intensity}
onChange={(e) => onChange({ intensity: Number(e.target.value) })}
className="flex-1 accent-studio-accent"
/>
<span className="text-2xs text-neutral-400 font-mono w-8 text-right flex-shrink-0">
{intensity.toFixed(2)}
</span>
</div>
</Row>
)}
</Section>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
const model = useCaptionStore((s) => s.model);
const selectedGroupId = useCaptionStore((s) => s.selectedGroupId);
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
const updateGroupAnimation = useCaptionStore((s) => s.updateGroupAnimation);
const applyAnimationToAll = useCaptionStore((s) => s.applyAnimationToAll);
// Resolve which group to edit
let resolvedGroupId: string | null = selectedGroupId;
if (!resolvedGroupId && model && selectedSegmentIds.size > 0) {
const firstSegmentId = [...selectedSegmentIds][0];
if (firstSegmentId) {
for (const [gid, group] of model.groups) {
if (group.segmentIds.includes(firstSegmentId)) {
resolvedGroupId = gid;
break;
}
}
}
}
const group = resolvedGroupId ? model?.groups.get(resolvedGroupId) : undefined;
const animation = group?.animation;
// All hooks must be called before any early return
const handleEntranceChange = useCallback(
(update: Partial<CaptionAnimation>) => {
if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "entrance", update);
},
[resolvedGroupId, updateGroupAnimation],
);
const handleHighlightChange = useCallback(
(update: Partial<CaptionAnimation>) => {
if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "highlight", update);
},
[resolvedGroupId, updateGroupAnimation],
);
const handleExitChange = useCallback(
(update: Partial<CaptionAnimation>) => {
if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "exit", update);
},
[resolvedGroupId, updateGroupAnimation],
);
const handleApplyToAll = useCallback(() => {
if (animation) applyAnimationToAll(animation);
}, [animation, applyAnimationToAll]);
// Empty state — after all hooks
if (!group || !resolvedGroupId || !animation) {
return (
<div className="flex items-center justify-center h-full px-4 text-center">
<p className="text-xs text-neutral-500">Select a caption group to edit animations</p>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-3 py-2">
<AnimationPhase
label="Entrance"
presets={ENTRANCE_PRESETS}
animation={animation.entrance}
onChange={handleEntranceChange}
/>
<AnimationPhase
label="Highlight"
presets={HIGHLIGHT_PRESETS}
animation={animation.highlight}
showIntensity
onChange={handleHighlightChange}
/>
<AnimationPhase
label="Exit"
presets={EXIT_PRESETS}
animation={animation.exit}
onChange={handleExitChange}
/>
</div>
{/* Footer */}
<div className="flex-shrink-0 px-3 py-2 border-t border-neutral-800">
<button
type="button"
onClick={handleApplyToAll}
className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors"
>
Apply to all groups
</button>
</div>
</div>
);
});
@@ -0,0 +1,462 @@
import { memo, useState, useCallback, useRef } from "react";
import { useCaptionStore } from "../store";
import { useMountEffect } from "../../hooks/useMountEffect";
interface CaptionOverlayProps {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
}
interface WordBox {
segmentId: string;
groupId: string;
groupIndex: number;
wordIndex: number;
x: number;
y: number;
width: number;
height: number;
}
function readWordBoxes(
iframe: HTMLIFrameElement,
model: {
groupOrder: string[];
groups: Map<string, { segmentIds: string[] }>;
},
overlayEl: HTMLElement,
): WordBox[] {
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe.contentDocument;
win = iframe.contentWindow;
} catch {
return [];
}
if (!doc || !win) return [];
const iframeDisplayRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
const cssScale = iframeDisplayRect.width / nativeW;
const offsetX = iframeDisplayRect.left - overlayRect.left;
const offsetY = iframeDisplayRect.top - overlayRect.top;
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
const boxes: WordBox[] = [];
for (let gi = 0; gi < model.groupOrder.length; gi++) {
const groupId = model.groupOrder[gi];
const group = model.groups.get(groupId);
if (!group) continue;
const groupEl = groupEls[gi] as HTMLElement | undefined;
if (!groupEl) continue;
const computed = win.getComputedStyle(groupEl);
if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
// Find word spans — may be direct children or inside wrappers
const resolvedWordEls: HTMLElement[] = [];
for (const child of groupEl.children) {
const c = child as HTMLElement;
if (c.dataset.captionWrapper === "true") {
const inner = c.querySelector<HTMLElement>(":scope > span");
if (inner) resolvedWordEls.push(inner);
} else if (c.tagName === "SPAN") {
resolvedWordEls.push(c);
}
}
for (let wi = 0; wi < group.segmentIds.length; wi++) {
const segId = group.segmentIds[wi];
const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
if (!wordEl) continue;
const rect = wordEl.getBoundingClientRect();
boxes.push({
segmentId: segId, groupId, groupIndex: gi, wordIndex: wi,
x: rect.left * cssScale + offsetX,
y: rect.top * cssScale + offsetY,
width: rect.width * cssScale,
height: rect.height * cssScale,
});
}
}
return boxes;
}
function getWordEl(iframe: HTMLIFrameElement, groupIndex: number, wordIndex: number): HTMLElement | null {
let doc: Document | null = null;
try { doc = iframe.contentDocument; } catch { return null; }
if (!doc) return null;
const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
if (!groupEl) return null;
// Find word spans — they may be direct children or inside wrapper spans.
// Word spans have class "word" or an id starting with "w".
// Wrappers have data-caption-wrapper="true".
const wordEls: HTMLElement[] = [];
for (const child of groupEl.children) {
const el = child as HTMLElement;
if (el.dataset.captionWrapper === "true") {
// Wrapped word — get the inner span
const inner = el.querySelector<HTMLElement>(":scope > span");
if (inner) wordEls.push(inner);
} else if (el.tagName === "SPAN") {
wordEls.push(el);
}
}
return wordEls[wordIndex] ?? null;
}
/**
* Read GSAP's internal transform state for an element.
* GSAP stores transforms in its own cache, not in el.style.transform.
*/
function readGsapTransform(el: HTMLElement, iframeWin: Window): { x: number; y: number; scale: number; rotation: number } {
const gsap = (iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }).gsap;
if (gsap && gsap.getProperty) {
return {
x: gsap.getProperty(el, "x") || 0,
y: gsap.getProperty(el, "y") || 0,
scale: gsap.getProperty(el, "scale") || 1,
rotation: gsap.getProperty(el, "rotation") || 0,
};
}
// Fallback: parse from style
const t = el.style.transform || "";
const scaleMatch = t.match(/scale\(([^)]+)\)/);
const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
return {
x: txyMatch ? parseFloat(txyMatch[1]) : 0,
y: txyMatch ? parseFloat(txyMatch[2]) : 0,
scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
};
}
/**
* Get or create an inline-block wrapper span around a word element.
* Transforms are applied to the wrapper so the word's GSAP animations are preserved.
*/
function getOrCreateWrapper(el: HTMLElement): HTMLElement {
// If el IS a wrapper, return it
if (el.dataset.captionWrapper === "true") return el;
// If el's parent is a wrapper, return the parent
const parent = el.parentElement;
if (parent && parent.dataset.captionWrapper === "true") return parent;
// Create new wrapper
const doc = el.ownerDocument;
const wrapper = doc.createElement("span");
wrapper.style.display = "inline-block";
wrapper.dataset.captionWrapper = "true";
el.parentNode?.insertBefore(wrapper, el);
wrapper.appendChild(el);
return wrapper;
}
/**
* Write transform values to a wrapper span around the word element.
* The word keeps its GSAP animations; the wrapper handles editor transforms.
*/
function writeTransform(el: HTMLElement, iframeWin: Window, x: number, y: number, scale: number, rotation: number) {
const wrapper = getOrCreateWrapper(el);
const gsap = (iframeWin as unknown as { gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void } }).gsap;
if (gsap && gsap.set) {
gsap.set(wrapper, { x, y, scale, rotation });
} else {
wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
}
}
/** Sync canvas state back to the Zustand store so the property panel reflects it.
* Only writes non-default values to avoid creating spurious overrides. */
function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
const wrapper = getOrCreateWrapper(el);
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
const style: Record<string, number> = {};
if (Math.abs(x) > 0.5) style.x = x;
if (Math.abs(y) > 0.5) style.y = y;
if (Math.abs(scale - 1) > 0.001) { style.scaleX = scale; style.scaleY = scale; }
if (Math.abs(rotation) > 0.1) style.rotation = rotation;
if (Object.keys(style).length > 0) {
useCaptionStore.getState().updateSegmentStyle(segmentId, style);
}
}
const HANDLE = 8;
const ROTATION_OFFSET = 20; // px above the selection box
export const CaptionOverlay = memo(function CaptionOverlay({
iframeRef,
}: CaptionOverlayProps) {
const isEditMode = useCaptionStore((s) => s.isEditMode);
const model = useCaptionStore((s) => s.model);
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
const selectSegment = useCaptionStore((s) => s.selectSegment);
const clearSelection = useCaptionStore((s) => s.clearSelection);
const [wordBoxes, setWordBoxes] = useState<WordBox[]>([]);
const overlayRef = useRef<HTMLDivElement>(null);
const modelRef = useRef(model);
modelRef.current = model;
// Interaction mode — only one active at a time
const interactionRef = useRef<
| { type: "move"; wordEl: HTMLElement; segmentId: string; startMX: number; startMY: number; origTX: number; origTY: number; origScale: number; origRotation: number }
| { type: "scale"; wordEl: HTMLElement; segmentId: string; startMX: number; startWidth: number; origTX: number; origTY: number; origScale: number; origRotation: number }
| { type: "rotate"; wordEl: HTMLElement; segmentId: string; centerX: number; centerY: number; startAngle: number; origTX: number; origTY: number; origRotation: number; origScale: number }
| null
>(null);
useMountEffect(() => {
if (!isEditMode) return;
let prevBoxes: WordBox[] = [];
const tick = () => {
const iframe = iframeRef.current;
const m = modelRef.current;
const overlay = overlayRef.current;
if (!iframe || !m || !overlay) return;
const next = readWordBoxes(iframe, m, overlay);
// Skip state update if nothing changed (avoids re-render every 66ms)
if (next.length === prevBoxes.length &&
next.every((b, i) => Math.abs(b.x - prevBoxes[i].x) < 0.5 && Math.abs(b.y - prevBoxes[i].y) < 0.5)) return;
prevBoxes = next;
setWordBoxes(next);
};
const id = setInterval(tick, 66);
tick();
// Arrow key nudge for selected words
const handleKeyDown = (e: KeyboardEvent) => {
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
if (sel.size === 0 || !m) return;
const arrow = e.key;
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const dx = arrow === "ArrowLeft" ? -step : arrow === "ArrowRight" ? step : 0;
const dy = arrow === "ArrowUp" ? -step : arrow === "ArrowDown" ? step : 0;
const iframe = iframeRef.current;
const win = iframe?.contentWindow;
if (!iframe || !win) return;
for (const segId of sel) {
// Find group/word index for this segment
for (let gi = 0; gi < m.groupOrder.length; gi++) {
const group = m.groups.get(m.groupOrder[gi]);
if (!group) continue;
const wi = group.segmentIds.indexOf(segId);
if (wi < 0) continue;
const wordEl = getWordEl(iframe, gi, wi);
if (!wordEl) continue;
const wrapper = getOrCreateWrapper(wordEl);
const state = readGsapTransform(wrapper, win);
writeTransform(wordEl, win, state.x + dx, state.y + dy, state.scale, state.rotation);
syncToStore(segId, wordEl, win);
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
clearInterval(id);
window.removeEventListener("keydown", handleKeyDown);
};
});
const getCssScale = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return 1;
const rect = iframe.getBoundingClientRect();
const nativeW = parseFloat(iframe.style.width) || rect.width;
return rect.width / nativeW;
}, [iframeRef]);
// --- Move ---
const startMove = useCallback((groupIndex: number, wordIndex: number, segmentId: string, e: React.PointerEvent) => {
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const iframe = iframeRef.current;
if (!iframe) return;
const wordEl = getWordEl(iframe, groupIndex, wordIndex);
const win = iframe.contentWindow;
if (!wordEl || !win) return;
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
interactionRef.current = {
type: "move", wordEl, segmentId,
startMX: e.clientX, startMY: e.clientY,
origTX: state.x, origTY: state.y,
origScale: state.scale, origRotation: state.rotation,
};
}, [iframeRef]);
// --- Scale ---
const startScale = useCallback((groupIndex: number, wordIndex: number, segmentId: string, e: React.PointerEvent) => {
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const iframe = iframeRef.current;
if (!iframe) return;
const wordEl = getWordEl(iframe, groupIndex, wordIndex);
const win = iframe.contentWindow;
if (!wordEl || !win) return;
const rect = wordEl.getBoundingClientRect();
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
interactionRef.current = {
type: "scale", wordEl, segmentId,
startMX: e.clientX, startWidth: rect.width,
origTX: state.x, origTY: state.y,
origScale: state.scale, origRotation: state.rotation,
};
}, [iframeRef]);
// --- Rotate ---
const startRotate = useCallback((box: WordBox, e: React.PointerEvent) => {
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const iframe = iframeRef.current;
if (!iframe) return;
const wordEl = getWordEl(iframe, box.groupIndex, box.wordIndex);
const win = iframe.contentWindow;
if (!wordEl || !win) return;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
interactionRef.current = {
type: "rotate", wordEl, segmentId: box.segmentId,
centerX: cx, centerY: cy,
startAngle, origTX: state.x, origTY: state.y,
origRotation: state.rotation, origScale: state.scale,
};
}, [iframeRef]);
/** Get iframe contentWindow, needed for gsap calls */
const getIframeWin = useCallback((): Window | null => {
try { return iframeRef.current?.contentWindow ?? null; } catch { return null; }
}, [iframeRef]);
// --- Unified pointer move ---
const handlePointerMove = useCallback((e: React.PointerEvent) => {
const i = interactionRef.current;
if (!i) return;
const win = getIframeWin();
if (!win) return;
if (i.type === "move") {
const cssScale = getCssScale();
const dx = (e.clientX - i.startMX) / cssScale;
const dy = (e.clientY - i.startMY) / cssScale;
writeTransform(i.wordEl, win, i.origTX + dx, i.origTY + dy, i.origScale, i.origRotation);
} else if (i.type === "scale") {
const dx = e.clientX - i.startMX;
const factor = 1 + dx / Math.max(i.startWidth, 50);
const newScale = Math.max(0.1, i.origScale * factor);
writeTransform(i.wordEl, win, i.origTX, i.origTY, newScale, i.origRotation);
} else if (i.type === "rotate") {
const angle = Math.atan2(e.clientY - i.centerY, e.clientX - i.centerX) * (180 / Math.PI);
const delta = angle - i.startAngle;
writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation + delta);
}
}, [getCssScale, getIframeWin]);
// --- Unified pointer up — sync back to store ---
const handlePointerUp = useCallback(() => {
const i = interactionRef.current;
if (i) {
const win = getIframeWin();
if (win) syncToStore(i.segmentId, i.wordEl, win);
interactionRef.current = null;
}
}, [getIframeWin]);
const handleBackgroundClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) clearSelection();
}, [clearSelection]);
if (!isEditMode) return null;
return (
<div
ref={overlayRef}
className="absolute inset-0 z-50"
style={{ pointerEvents: "auto" }}
onClick={handleBackgroundClick}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handlePointerUp}
>
{wordBoxes.map((box) => {
const isSelected = selectedSegmentIds.has(box.segmentId);
return (
<div
key={box.segmentId}
className={[
"absolute",
isSelected ? "ring-2 ring-studio-accent" : "hover:ring-1 hover:ring-white/30",
].join(" ")}
style={{
left: box.x, top: box.y, width: box.width, height: box.height,
cursor: isSelected ? "move" : "pointer",
touchAction: "none", borderRadius: 2,
}}
onClick={(e) => { e.stopPropagation(); selectSegment(box.segmentId, e.shiftKey); }}
onPointerDown={(e) => {
if (isSelected) startMove(box.groupIndex, box.wordIndex, box.segmentId, e);
}}
>
{isSelected && (
<>
{/* Rotation handle — circle above the box */}
<div
style={{
position: "absolute",
left: "50%", top: -ROTATION_OFFSET - HANDLE,
marginLeft: -HANDLE / 2,
width: HANDLE, height: HANDLE,
borderRadius: "50%",
backgroundColor: "var(--hf-accent, #3CE6AC)",
border: "1px solid rgba(0,0,0,0.5)",
cursor: "grab", touchAction: "none",
}}
onPointerDown={(e) => startRotate(box, e)}
/>
{/* Line from box to rotation handle */}
<div
style={{
position: "absolute",
left: "50%", top: -ROTATION_OFFSET,
width: 1, height: ROTATION_OFFSET,
marginLeft: -0.5,
backgroundColor: "var(--hf-accent, #3CE6AC)",
opacity: 0.5, pointerEvents: "none",
}}
/>
{/* Scale handles — four corners */}
{[
{ right: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nwse-resize" },
{ left: -HANDLE / 2, top: -HANDLE / 2, cursor: "nwse-resize" },
{ right: -HANDLE / 2, top: -HANDLE / 2, cursor: "nesw-resize" },
{ left: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nesw-resize" },
].map((pos, idx) => (
<div
key={idx}
style={{
position: "absolute", ...pos,
width: HANDLE, height: HANDLE,
backgroundColor: "var(--hf-accent, #3CE6AC)",
border: "1px solid rgba(0,0,0,0.5)",
borderRadius: 2, touchAction: "none",
}}
onPointerDown={(e) => startScale(box.groupIndex, box.wordIndex, box.segmentId, e)}
/>
))}
</>
)}
</div>
);
})}
</div>
);
});
@@ -0,0 +1,294 @@
import { memo, useCallback, useState } from "react";
import { useCaptionStore } from "../store";
import type { CaptionStyle } from "../types";
import { CaptionAnimationPanel } from "./CaptionAnimationPanel";
// ---------------------------------------------------------------------------
// Helper Components
// ---------------------------------------------------------------------------
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mb-3">
<div className="flex items-center gap-1.5 mt-2 mb-1.5">
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
{label}
</span>
</div>
<div className="space-y-1">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="text-2xs text-neutral-600 w-14 text-right flex-shrink-0">{label}</span>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
const inputCls =
"w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600";
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface CaptionPropertyPanelProps {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
}
export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
iframeRef,
}: CaptionPropertyPanelProps) {
const model = useCaptionStore((s) => s.model);
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
const selectedGroupId = useCaptionStore((s) => s.selectedGroupId);
const updateSelectedStyle = useCaptionStore((s) => s.updateSelectedStyle);
const updateGroupStyle = useCaptionStore((s) => s.updateGroupStyle);
const [activeTab, setActiveTab] = useState<"style" | "animation">("style");
// Resolve effective style for the first selected segment
const firstSegmentId = selectedSegmentIds.size > 0 ? [...selectedSegmentIds][0] : undefined;
const firstSegment = model?.segments.get(firstSegmentId ?? "");
// Find the group that owns the first segment
let ownerGroupId: string | null = null;
if (model && firstSegmentId) {
for (const gid of model.groupOrder) {
const group = model.groups.get(gid);
if (group && group.segmentIds.includes(firstSegmentId)) {
ownerGroupId = gid;
break;
}
}
}
const groupStyle = ownerGroupId ? model?.groups.get(ownerGroupId)?.style : undefined;
const segmentOverrides = firstSegment?.style ?? {};
// Merge group style with segment overrides for display
const effectiveStyle: Partial<CaptionStyle> = {
...groupStyle,
...segmentOverrides,
};
/**
* Apply a CSS style change to selected word elements in the iframe DOM in real time.
* Maps CaptionStyle property names to CSS properties.
*/
const applyToIframeDom = useCallback(
(updates: Partial<CaptionStyle>) => {
const iframe = iframeRef.current;
if (!iframe || !model) return;
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return;
}
if (!doc) return;
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
// Build list of word elements to update
const targetEls: HTMLElement[] = [];
for (const segId of selectedSegmentIds) {
for (let gi = 0; gi < model.groupOrder.length; gi++) {
const group = model.groups.get(model.groupOrder[gi]);
if (!group) continue;
const wi = group.segmentIds.indexOf(segId);
if (wi < 0) continue;
const groupEl = groupEls[gi];
if (!groupEl) continue;
// Resolve word span, handling wrappers
const children = groupEl.children;
let idx = 0;
for (const child of children) {
const c = child as HTMLElement;
if (c.dataset.captionWrapper === "true") {
const inner = c.querySelector<HTMLElement>(":scope > span");
if (inner && idx === wi) { targetEls.push(inner); break; }
} else if (c.tagName === "SPAN") {
if (idx === wi) { targetEls.push(c); break; }
}
idx++;
}
break;
}
}
// Apply transform updates via gsap.set on the WRAPPER (not the word span)
const hasTransform = updates.x !== undefined || updates.y !== undefined ||
updates.scaleX !== undefined || updates.scaleY !== undefined || updates.rotation !== undefined;
if (hasTransform) {
try {
const iframeGsap = (iframeRef.current?.contentWindow as unknown as {
gsap?: { set: (el: HTMLElement, props: Record<string, unknown>) => void;
getProperty: (el: HTMLElement, prop: string) => number };
})?.gsap;
if (iframeGsap) {
for (const el of targetEls) {
// Get or create wrapper
let wrapper = el.parentElement;
if (!wrapper || wrapper.dataset.captionWrapper !== "true") {
wrapper = doc.createElement("span") as HTMLElement;
wrapper.style.display = "inline-block";
wrapper.dataset.captionWrapper = "true";
el.parentNode?.insertBefore(wrapper, el);
wrapper.appendChild(el);
}
// Read current wrapper state and merge with updates
const curX = iframeGsap.getProperty(wrapper, "x") || 0;
const curY = iframeGsap.getProperty(wrapper, "y") || 0;
const curScale = iframeGsap.getProperty(wrapper, "scale") || 1;
const curRotation = iframeGsap.getProperty(wrapper, "rotation") || 0;
iframeGsap.set(wrapper, {
x: updates.x ?? curX,
y: updates.y ?? curY,
scale: updates.scaleX ?? curScale,
rotation: updates.rotation ?? curRotation,
});
}
}
} catch { /* cross-origin */ }
}
},
[iframeRef, model, selectedSegmentIds],
);
// All hooks must be called before any early return
const handleStyleChange = useCallback(
(updates: Partial<CaptionStyle>) => {
if (selectedGroupId) {
updateGroupStyle(selectedGroupId, updates);
} else {
updateSelectedStyle(updates);
}
applyToIframeDom(updates);
},
[selectedGroupId, updateGroupStyle, updateSelectedStyle, applyToIframeDom],
);
// Empty state — after all hooks
if (selectedSegmentIds.size === 0) {
return (
<div className="flex items-center justify-center h-full px-4 text-center">
<p className="text-xs text-neutral-500">Select caption words to edit their style</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Derived style values with fallbacks
// ---------------------------------------------------------------------------
const x = effectiveStyle.x ?? 0;
const y = effectiveStyle.y ?? 0;
const rotation = effectiveStyle.rotation ?? 0;
const scaleX = effectiveStyle.scaleX ?? 1;
// Count label
const countLabel = selectedSegmentIds.size === 1
? "1 word"
: `${selectedSegmentIds.size} words`;
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="px-3 py-2 border-b border-neutral-800 flex-shrink-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-2xs text-neutral-500">
{countLabel}
</span>
</div>
{/* Tab switcher */}
<div className="flex gap-1">
<button
type="button"
onClick={() => setActiveTab("style")}
className={[
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
activeTab === "style"
? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
: "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
].join(" ")}
>
Style
</button>
<button
type="button"
onClick={() => setActiveTab("animation")}
className={[
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
activeTab === "animation"
? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
: "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
].join(" ")}
>
Animation
</button>
</div>
</div>
{/* Animation tab */}
{activeTab === "animation" && <CaptionAnimationPanel />}
{/* Style tab — Transform only */}
{activeTab === "style" && (
<div className="flex-1 overflow-y-auto px-3 py-2">
<Section label="Position">
<Row label="X">
<input
type="number"
value={x}
onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
className={inputCls}
/>
</Row>
<Row label="Y">
<input
type="number"
value={y}
onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
className={inputCls}
/>
</Row>
</Section>
<Section label="Transform">
<Row label="Scale">
<input
type="number"
value={scaleX}
step={0.1}
onChange={(e) =>
handleStyleChange({
scaleX: Number(e.target.value),
scaleY: Number(e.target.value),
})
}
className={inputCls}
/>
</Row>
<Row label="Rotation">
<input
type="number"
value={rotation}
onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
className={inputCls}
/>
</Row>
</Section>
</div>
)}
</div>
);
});
@@ -0,0 +1,187 @@
import { memo, useCallback, useRef } from "react";
import { useCaptionStore } from "../store";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const GROUP_COLORS = [
"#3CE6AC",
"#FF6B6B",
"#4ECDC4",
"#FFE66D",
"#A78BFA",
"#F472B6",
"#34D399",
"#FB923C",
"#60A5FA",
"#C084FC",
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CaptionTimelineProps {
pixelsPerSecond: number;
onSeek?: (time: number) => void;
}
interface DragState {
segId: string;
edge: "start" | "end";
originalStart: number;
originalEnd: number;
startX: number;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const CaptionTimeline = memo(function CaptionTimeline({
pixelsPerSecond,
onSeek,
}: CaptionTimelineProps) {
const model = useCaptionStore((s) => s.model);
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
const selectSegment = useCaptionStore((s) => s.selectSegment);
const updateSegmentTiming = useCaptionStore((s) => s.updateSegmentTiming);
const splitGroup = useCaptionStore((s) => s.splitGroup);
const dragRef = useRef<DragState | null>(null);
const handleEdgePointerDown = useCallback(
(
e: React.PointerEvent<HTMLDivElement>,
segId: string,
edge: "start" | "end",
originalStart: number,
originalEnd: number,
) => {
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { segId, edge, originalStart, originalEnd, startX: e.clientX };
},
[],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
const delta = (e.clientX - drag.startX) / pixelsPerSecond;
if (drag.edge === "start") {
const newStart = Math.max(0, drag.originalStart + delta);
const clampedStart = Math.min(newStart, drag.originalEnd - 0.05);
updateSegmentTiming(drag.segId, clampedStart, drag.originalEnd);
} else {
const newEnd = Math.max(drag.originalStart + 0.05, drag.originalEnd + delta);
const clampedEnd = Math.max(0, newEnd);
updateSegmentTiming(drag.segId, drag.originalStart, clampedEnd);
}
},
[pixelsPerSecond, updateSegmentTiming],
);
const handlePointerUp = useCallback(() => {
dragRef.current = null;
}, []);
const handleBlockClick = useCallback(
(e: React.MouseEvent, segId: string) => {
e.stopPropagation();
selectSegment(segId, e.shiftKey);
},
[selectSegment],
);
const handleBlockDoubleClick = useCallback(
(e: React.MouseEvent, groupId: string, segId: string) => {
e.stopPropagation();
splitGroup(groupId, segId);
},
[splitGroup],
);
const handleTrackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - rect.left - 32;
const time = Math.max(0, x / pixelsPerSecond);
onSeek(time);
},
[onSeek, pixelsPerSecond],
);
if (!model) return null;
return (
<div
className="relative select-none overflow-x-auto"
style={{ height: 40, minWidth: "100%" }}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onClick={handleTrackClick}
>
{model.groupOrder.map((groupId, groupIdx) => {
const group = model.groups.get(groupId);
if (!group) return null;
const color = GROUP_COLORS[groupIdx % GROUP_COLORS.length];
return group.segmentIds.map((segId) => {
const seg = model.segments.get(segId);
if (!seg) return null;
const left = 32 + seg.start * pixelsPerSecond;
const width = Math.max((seg.end - seg.start) * pixelsPerSecond, 4);
const isSelected = selectedSegmentIds.has(segId);
return (
<div
key={segId}
className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer${
isSelected ? " ring-1 ring-white/50 z-10" : ""
}`}
style={{
left,
width,
backgroundColor: color,
zIndex: isSelected ? 10 : 1,
}}
onClick={(e) => handleBlockClick(e, segId)}
onDoubleClick={(e) => handleBlockDoubleClick(e, groupId, segId)}
>
{/* Left edge drag handle */}
<div
className="absolute left-0 top-0 bottom-0 cursor-col-resize z-20"
style={{ width: 6 }}
onPointerDown={(e) => handleEdgePointerDown(e, segId, "start", seg.start, seg.end)}
/>
{/* Text label */}
<span
className="flex-1 truncate px-2 pointer-events-none"
style={{ fontSize: 9, color: "#000000", lineHeight: 1 }}
>
{seg.text}
</span>
{/* Right edge drag handle */}
<div
className="absolute right-0 top-0 bottom-0 cursor-col-resize z-20"
style={{ width: 6 }}
onPointerDown={(e) => handleEdgePointerDown(e, segId, "end", seg.start, seg.end)}
/>
</div>
);
});
})}
</div>
);
});
@@ -0,0 +1,279 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { generateCaptionHtml } from "./generator.js";
import { buildCaptionModel, TranscriptWord } from "./parser.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SAMPLE_TRANSCRIPT: TranscriptWord[] = [
{ text: "We", start: 0.1, end: 0.3 },
{ text: "asked", start: 0.4, end: 0.6 },
{ text: "what", start: 0.7, end: 0.9 },
{ text: "you", start: 1.0, end: 1.2 },
{ text: "needed.", start: 1.3, end: 1.8 },
{ text: "Forty-seven", start: 1.9, end: 2.3 },
{ text: "percent", start: 2.4, end: 2.7 },
];
function buildTestModel(wordsPerGroup = 5) {
return buildCaptionModel(SAMPLE_TRANSCRIPT, {
width: 1920,
height: 1080,
duration: 16,
wordsPerGroup,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("generateCaptionHtml", () => {
describe("HTML structure", () => {
it("wraps output in a <template id='captions-template'>", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('<template id="captions-template">');
expect(html).toContain("</template>");
});
it("includes correct data-composition-id, data-width, data-height, data-duration attributes", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('data-composition-id="captions"');
expect(html).toContain('data-width="1920"');
expect(html).toContain('data-height="1080"');
expect(html).toContain('data-duration="16"');
});
it("includes the captions-container div", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('<div id="captions-container"></div>');
});
it("includes a <style> block", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("<style>");
expect(html).toContain("</style>");
});
it("includes a <script> block", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("<script>");
expect(html).toContain("</script>");
});
});
describe("CSS generation", () => {
it("includes composition base styles with correct dimensions", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('data-composition-id="captions"');
expect(html).toContain("width: 1920px");
expect(html).toContain("height: 1080px");
});
it("includes .caption-group base styles with opacity: 0", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain(".caption-group");
expect(html).toContain("opacity: 0");
});
it("includes .word base styles with display: inline-block", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain(".word");
expect(html).toContain("display: inline-block");
});
it("includes per-group CSS class with font styles from DEFAULT_STYLE", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
// DEFAULT_STYLE has fontFamily: "sans-serif" and fontSize: 48
expect(html).toContain("font-family: sans-serif");
expect(html).toContain("font-size: 48px");
});
it("includes per-group CSS class for each group", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
// Two groups: group-0 and group-1
expect(html).toContain("group-0");
expect(html).toContain("group-1");
});
});
describe("TRANSCRIPT array", () => {
it("includes a TRANSCRIPT array in the script block", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("const TRANSCRIPT =");
});
it("includes all word texts from the transcript", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('"We"');
expect(html).toContain('"asked"');
expect(html).toContain('"what"');
expect(html).toContain('"you"');
expect(html).toContain('"needed."');
expect(html).toContain('"Forty-seven"');
expect(html).toContain('"percent"');
});
it("includes start and end timing for words", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('"start": 0.1');
expect(html).toContain('"end": 0.3');
expect(html).toContain('"start": 1.9');
expect(html).toContain('"end": 2.7');
});
it("TRANSCRIPT contains all 7 words from the sample", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
// Count occurrences of "start" property in the TRANSCRIPT JSON
const transcriptSection = html.slice(
html.indexOf("const TRANSCRIPT ="),
html.indexOf("const TRANSCRIPT =") + 1000,
);
const startCount = (transcriptSection.match(/"start":/g) ?? []).length;
expect(startCount).toBe(7);
});
});
describe("GSAP timeline", () => {
it("registers the timeline via window.__timelines", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain('window.__timelines["captions"]');
});
it("creates a gsap.timeline() call", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("gsap.timeline(");
});
it("includes entrance tween with opacity: 1 for each group", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("opacity: 1");
});
it("includes exit tween with opacity: 0 for each group", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("opacity: 0");
});
it("uses group start time as position for entrance tween", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
// First group starts at 0.1 (first word start)
expect(html).toContain(", 0.1)");
});
it("creates caption-group div elements with class='clip'", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("caption-group clip");
});
it("creates word span elements with class='word clip'", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("word clip");
});
it("sets data-start and data-end on group elements", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("dataset.start");
expect(html).toContain("dataset.end");
});
it("wraps everything in an IIFE", () => {
const model = buildTestModel();
const html = generateCaptionHtml(model);
expect(html).toContain("(function ()");
expect(html).toContain("})();");
});
});
describe("positioning", () => {
it("centers groups without explicit x/y using transform: translateX(-50%)", () => {
const model = buildTestModel();
// DEFAULT_STYLE has x: 0 and y: 0
const html = generateCaptionHtml(model);
expect(html).toContain("translateX(-50%)");
});
it("uses absolute left/top when group style has explicit x/y", () => {
const model = buildTestModel();
// Override the first group's style to have explicit position
const firstGroupId = model.groupOrder[0];
const firstGroup = model.groups.get(firstGroupId);
if (firstGroup) {
firstGroup.style = { ...firstGroup.style, x: 200, y: 300 };
}
const html = generateCaptionHtml(model);
expect(html).toContain("200px");
expect(html).toContain("300px");
});
});
describe("edge cases", () => {
it("handles an empty model (no segments or groups) without throwing", () => {
const model = buildCaptionModel([], {
width: 1280,
height: 720,
duration: 5,
});
expect(() => generateCaptionHtml(model)).not.toThrow();
});
it("empty model still produces valid template wrapper", () => {
const model = buildCaptionModel([], {
width: 1280,
height: 720,
duration: 5,
});
const html = generateCaptionHtml(model);
expect(html).toContain('<template id="captions-template">');
expect(html).toContain('data-composition-id="captions"');
});
it("handles custom dimensions correctly", () => {
const model = buildCaptionModel(SAMPLE_TRANSCRIPT, {
width: 1280,
height: 720,
duration: 30,
});
const html = generateCaptionHtml(model);
expect(html).toContain('data-width="1280"');
expect(html).toContain('data-height="720"');
expect(html).toContain('data-duration="30"');
expect(html).toContain("width: 1280px");
expect(html).toContain("height: 720px");
});
it("words with special characters are escaped in JS output", () => {
const transcript: TranscriptWord[] = [{ text: "it's", start: 0.0, end: 0.5 }];
const model = buildCaptionModel(transcript, {
width: 1920,
height: 1080,
duration: 5,
});
expect(() => generateCaptionHtml(model)).not.toThrow();
});
});
});
+372
View File
@@ -0,0 +1,372 @@
// Caption HTML Generator
// Serializes a CaptionModel into a complete captions.html HyperFrames composition.
import type {
CaptionModel,
CaptionSegment,
CaptionStyle,
CaptionContainerStyle,
CaptionShadow,
CaptionGlow,
} from "./types.js";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Serializes a CaptionModel into a complete captions.html composition string.
*
* Output format:
* ```html
* <template id="captions-template">
* <div data-composition-id="captions" data-width="..." data-height="..." data-duration="...">
* <div id="captions-container"></div>
* <style>/* generated CSS *\/</style>
* <script>/* generated JS *\/</script>
* </div>
* </template>
* ```
*/
export function generateCaptionHtml(model: CaptionModel): string {
const css = generateCss(model);
const js = generateJs(model);
const durationStr = model.duration.toString();
return [
`<template id="captions-template">`,
` <div data-composition-id="captions" data-width="${model.width}" data-height="${model.height}" data-duration="${durationStr}">`,
` <div id="captions-container"></div>`,
` <style>`,
indent(css, 6),
` </style>`,
` <script>`,
indent(js, 6),
` </script>`,
` </div>`,
`</template>`,
].join("\n");
}
// ---------------------------------------------------------------------------
// CSS generation
// ---------------------------------------------------------------------------
function generateCss(model: CaptionModel): string {
const lines: string[] = [];
// Base composition styles
lines.push(
`[data-composition-id="captions"] {`,
` position: relative;`,
` width: ${model.width}px;`,
` height: ${model.height}px;`,
` background: transparent;`,
` overflow: hidden;`,
`}`,
``,
);
// Container styles
lines.push(`#captions-container {`, ` position: absolute;`, ` inset: 0;`, `}`, ``);
// .caption-group base styles
lines.push(
`.caption-group {`,
` position: absolute;`,
` display: flex;`,
` flex-wrap: wrap;`,
` gap: 0.25em;`,
` opacity: 0;`,
`}`,
``,
);
// .word base styles
lines.push(`.word {`, ` display: inline-block;`, `}`, ``);
// Per-group CSS classes
for (const groupId of model.groupOrder) {
const group = model.groups.get(groupId);
if (!group) continue;
const className = groupId.replace(/[^a-zA-Z0-9-_]/g, "-");
const styleDecls = buildGroupStyleDecls(group.style, group.containerStyle);
if (styleDecls.length > 0) {
lines.push(`.caption-group.${className} {`);
for (const decl of styleDecls) {
lines.push(` ${decl}`);
}
lines.push(`}`, ``);
}
}
return lines.join("\n");
}
function buildGroupStyleDecls(
style: CaptionStyle,
containerStyle: CaptionContainerStyle,
): string[] {
const decls: string[] = [];
// Typography
if (style.fontFamily) {
decls.push(`font-family: ${style.fontFamily};`);
}
if (style.fontSize) {
decls.push(`font-size: ${style.fontSize}px;`);
}
if (style.fontWeight) {
decls.push(`font-weight: ${style.fontWeight};`);
}
if (style.fontStyle && style.fontStyle !== "normal") {
decls.push(`font-style: ${style.fontStyle};`);
}
if (style.textDecoration && style.textDecoration !== "none") {
decls.push(`text-decoration: ${style.textDecoration};`);
}
if (style.textTransform && style.textTransform !== "none") {
decls.push(`text-transform: ${style.textTransform};`);
}
if (style.letterSpacing !== 0) {
decls.push(`letter-spacing: ${style.letterSpacing}em;`);
}
if (style.lineHeight) {
decls.push(`line-height: ${style.lineHeight};`);
}
// Color / fill
if (style.color) {
decls.push(`color: ${style.color};`);
}
if (style.opacity !== undefined && style.opacity !== 1) {
// opacity is managed by GSAP animations, but non-default base opacity can be declared
decls.push(`--caption-base-opacity: ${style.opacity};`);
}
// Stroke (via text-stroke / webkit-text-stroke)
if (style.strokeWidth > 0) {
decls.push(`-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};`);
}
// Shadows
if (style.shadows && style.shadows.length > 0) {
const shadowStr = style.shadows.map(shadowToCss).join(", ");
decls.push(`text-shadow: ${shadowStr};`);
}
// Glow (implemented as additional text-shadow)
if (style.glow) {
const glowStr = glowToCss(style.glow);
const existingShadow =
style.shadows && style.shadows.length > 0
? style.shadows.map(shadowToCss).join(", ") + ", "
: "";
// Only emit if not already emitted shadows (override the text-shadow if both present)
if (!(style.shadows && style.shadows.length > 0)) {
decls.push(`text-shadow: ${glowStr};`);
} else {
// Replace the last text-shadow declaration with combined
const idx = decls.findLastIndex((d) => d.startsWith("text-shadow:"));
if (idx >= 0) {
decls[idx] = `text-shadow: ${existingShadow}${glowStr};`;
}
}
}
// Blend mode
if (style.blendMode && style.blendMode !== "normal") {
decls.push(`mix-blend-mode: ${style.blendMode};`);
}
// Container: background
if (
containerStyle.backgroundColor &&
containerStyle.backgroundColor !== "transparent" &&
containerStyle.backgroundOpacity > 0
) {
const bg = hexToRgba(containerStyle.backgroundColor, containerStyle.backgroundOpacity);
decls.push(`background-color: ${bg};`);
}
// Container: padding
const { paddingTop, paddingRight, paddingBottom, paddingLeft } = containerStyle;
if (paddingTop > 0 || paddingRight > 0 || paddingBottom > 0 || paddingLeft > 0) {
decls.push(`padding: ${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px;`);
}
// Container: border radius
if (containerStyle.borderRadius > 0) {
decls.push(`border-radius: ${containerStyle.borderRadius}px;`);
}
// Container: border
if (containerStyle.borderWidth > 0) {
decls.push(
`border: ${containerStyle.borderWidth}px ${containerStyle.borderStyle} ${containerStyle.borderColor};`,
);
}
// Container: box shadow
if (containerStyle.boxShadow && containerStyle.boxShadow !== "none") {
decls.push(`box-shadow: ${containerStyle.boxShadow};`);
}
return decls;
}
function shadowToCss(shadow: CaptionShadow): string {
return `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`;
}
function glowToCss(glow: CaptionGlow): string {
// Glow is represented as a spread text-shadow with opacity applied to color
return `0 0 ${glow.blur}px ${hexToRgba(glow.color, glow.opacity)}`;
}
/** Converts a hex color and opacity into rgba(...) for CSS */
function hexToRgba(color: string, opacity: number): string {
// If it's already rgb/rgba, just return it (can't easily inject opacity)
if (color.startsWith("rgb")) {
return color;
}
// Try to parse hex
const hex = color.replace("#", "");
if (hex.length === 3 || hex.length === 6) {
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
return color;
}
// ---------------------------------------------------------------------------
// JS generation
// ---------------------------------------------------------------------------
function generateJs(model: CaptionModel): string {
// Collect all segments across all groups in order
const allSegments: Array<{ text: string; start: number; end: number }> = [];
for (const groupId of model.groupOrder) {
const group = model.groups.get(groupId);
if (!group) continue;
for (const segId of group.segmentIds) {
const seg = model.segments.get(segId);
if (!seg) continue;
allSegments.push({ text: seg.text, start: seg.start, end: seg.end });
}
}
const transcriptJson = JSON.stringify(allSegments, null, 2);
const groupBlocks: string[] = [];
for (const groupId of model.groupOrder) {
const group = model.groups.get(groupId);
if (!group) continue;
const className = groupId.replace(/[^a-zA-Z0-9-_]/g, "-");
// Compute group start/end from its segments
const groupSegments = group.segmentIds
.map((id) => model.segments.get(id))
.filter((s): s is CaptionSegment => s !== undefined);
if (groupSegments.length === 0) continue;
const firstSeg = groupSegments[0];
const lastSeg = groupSegments[groupSegments.length - 1];
const groupStart = firstSeg.start;
const groupEnd = lastSeg.end;
const groupVar = className.replace(/[^a-zA-Z0-9_]/g, "_");
// Build word spans
const wordLines: string[] = groupSegments.map((seg) => {
const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
return (
` const ${segVar} = document.createElement('span');` +
`\n ${segVar}.className = 'word clip';` +
`\n ${segVar}.textContent = '${escaped}';` +
`\n ${segVar}.dataset.start = '${seg.start}';` +
`\n ${segVar}.dataset.end = '${seg.end}';` +
`\n groupEl_${groupVar}.appendChild(${segVar});`
);
});
// Position: if x/y non-zero, use absolute with left/top; otherwise center
const groupVarName = `groupEl_${groupVar}`;
const hasExplicitPosition = group.style.x !== 0 || group.style.y !== 0;
let positionLines: string;
if (hasExplicitPosition) {
positionLines = [
` ${groupVarName}.style.left = '${group.style.x}px';`,
` ${groupVarName}.style.top = '${group.style.y}px';`,
].join("\n");
} else {
positionLines = [
` ${groupVarName}.style.left = '50%';`,
` ${groupVarName}.style.top = '80%';`,
` ${groupVarName}.style.transform = 'translateX(-50%) translateY(-50%)';`,
` ${groupVarName}.style.justifyContent = 'center';`,
` ${groupVarName}.style.maxWidth = '90%';`,
].join("\n");
}
const block = [
`// Group: ${groupId}`,
`const ${groupVarName} = document.createElement('div');`,
`${groupVarName}.className = 'caption-group clip ${className}';`,
`${groupVarName}.dataset.start = '${groupStart}';`,
`${groupVarName}.dataset.end = '${groupEnd}';`,
`container.appendChild(${groupVarName});`,
wordLines.join("\n"),
positionLines,
`// Entrance: fade in at group start`,
`tl.to(${groupVarName}, { opacity: 1, duration: 0.2, ease: 'power2.out' }, ${groupStart});`,
`// Exit: fade out at group end`,
`tl.to(${groupVarName}, { opacity: 0, duration: 0.2, ease: 'power2.in' }, ${groupEnd} - 0.2);`,
].join("\n");
groupBlocks.push(block);
}
return `(function () {
const TRANSCRIPT = ${transcriptJson};
const container = document.getElementById('captions-container');
if (!container) return;
const tl = gsap.timeline({ paused: true });
${groupBlocks.join("\n\n ")}
if (!window.__timelines) window.__timelines = {};
window.__timelines["captions"] = tl;
})();`;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function indent(text: string, spaces: number): string {
const pad = " ".repeat(spaces);
return text
.split("\n")
.map((line) => (line.trim() === "" ? "" : pad + line))
.join("\n");
}
@@ -0,0 +1,163 @@
import { useCallback, useRef } from "react";
import { useCaptionStore } from "../store";
import { useMountEffect } from "../../hooks/useMountEffect";
import type { CaptionStyle } from "../types";
interface CaptionOverrideEntry {
wordId?: string;
wordIndex: number;
x?: number;
y?: number;
scale?: number;
rotation?: number;
activeColor?: string;
dimColor?: string;
opacity?: number;
fontSize?: number;
fontWeight?: number;
fontFamily?: string;
}
function buildOverrides(model: {
groupOrder: string[];
groups: Map<string, { segmentIds: string[] }>;
segments: Map<string, { wordId?: string; style: Partial<CaptionStyle> }>;
}): CaptionOverrideEntry[] {
const entries: CaptionOverrideEntry[] = [];
let globalWordIndex = 0;
for (const groupId of model.groupOrder) {
const group = model.groups.get(groupId);
if (!group) continue;
for (const segId of group.segmentIds) {
const seg = model.segments.get(segId);
if (seg && Object.keys(seg.style).length > 0) {
const entry: CaptionOverrideEntry = { wordIndex: globalWordIndex };
if (seg.wordId) entry.wordId = seg.wordId;
const s = seg.style;
if (s.x !== undefined) entry.x = s.x;
if (s.y !== undefined) entry.y = s.y;
if (s.scaleX !== undefined) entry.scale = s.scaleX;
if (s.rotation !== undefined) entry.rotation = s.rotation;
if (s.activeColor !== undefined) entry.activeColor = s.activeColor;
if (s.dimColor !== undefined) entry.dimColor = s.dimColor;
if (s.opacity !== undefined) entry.opacity = s.opacity;
if (s.fontSize !== undefined) entry.fontSize = s.fontSize;
if (s.fontWeight !== undefined) entry.fontWeight = s.fontWeight as number;
if (s.fontFamily !== undefined) entry.fontFamily = s.fontFamily;
entries.push(entry);
}
globalWordIndex++;
}
}
return entries;
}
/**
* Auto-saves caption overrides to caption-overrides.json on every model change.
* Also provides loadOverrides for reading existing overrides on edit mode entry.
*/
export function useCaptionSync(projectId: string | null) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flag to suppress auto-save during loadOverrides
const suppressSaveRef = useRef(false);
const save = useCallback(() => {
const state = useCaptionStore.getState();
if (!state.model || !state.sourceFilePath || !state.isEditMode) return;
const pid = projectIdRef.current;
if (!pid) return;
const overrides = buildOverrides(state.model);
fetch(
`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
{ method: "PUT", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(overrides, null, 2) },
).catch(() => {});
}, []);
// Auto-save on model changes with 800ms debounce
useMountEffect(() => {
let prevModel = useCaptionStore.getState().model;
const unsub = useCaptionStore.subscribe((state) => {
if (!state.isEditMode || state.model === prevModel || !state.model) return;
prevModel = state.model;
// Skip save when loadOverrides just updated the model
if (suppressSaveRef.current) {
suppressSaveRef.current = false;
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(save, 800);
});
return () => {
unsub();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
});
const loadOverrides = useCallback(async () => {
const state = useCaptionStore.getState();
if (!state.model || !state.sourceFilePath) return;
const pid = projectIdRef.current;
if (!pid) return;
try {
const res = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
);
const data = await res.json();
if (!data.content) return;
const overrides: CaptionOverrideEntry[] = JSON.parse(data.content);
if (!Array.isArray(overrides)) return;
const model = state.model;
const allSegIds: string[] = [];
for (const groupId of model.groupOrder) {
const group = model.groups.get(groupId);
if (!group) continue;
for (const segId of group.segmentIds) {
allSegIds.push(segId);
}
}
const newSegments = new Map(model.segments);
for (const override of overrides) {
const segId = allSegIds[override.wordIndex];
if (!segId) continue;
const seg = newSegments.get(segId);
if (!seg) continue;
const style: Partial<CaptionStyle> = { ...seg.style };
if (override.x !== undefined) style.x = override.x;
if (override.y !== undefined) style.y = override.y;
if (override.scale !== undefined) { style.scaleX = override.scale; style.scaleY = override.scale; }
if (override.rotation !== undefined) style.rotation = override.rotation;
if (override.activeColor !== undefined) style.activeColor = override.activeColor;
if (override.dimColor !== undefined) style.dimColor = override.dimColor;
if (override.opacity !== undefined) style.opacity = override.opacity;
if (override.fontSize !== undefined) style.fontSize = override.fontSize;
if (override.fontWeight !== undefined) style.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) style.fontFamily = override.fontFamily;
newSegments.set(segId, { ...seg, style });
}
suppressSaveRef.current = true;
useCaptionStore.getState().setModel({ ...model, segments: newSegments });
} catch {
// No overrides file
}
}, []);
return { save, loadOverrides };
}
+10
View File
@@ -0,0 +1,10 @@
export { useCaptionStore } from "./store";
export { parseCaptionComposition, extractTranscript, buildCaptionModel } from "./parser";
export type { TranscriptWord } from "./parser";
export { generateCaptionHtml } from "./generator";
export { CaptionOverlay } from "./components/CaptionOverlay";
export { CaptionPropertyPanel } from "./components/CaptionPropertyPanel";
export { CaptionAnimationPanel } from "./components/CaptionAnimationPanel";
export { CaptionTimeline } from "./components/CaptionTimeline";
export { useCaptionSync } from "./hooks/useCaptionSync";
export type * from "./types";
+377
View File
@@ -0,0 +1,377 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser.js";
import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const STANDARD_CAPTION_SOURCE = `
(function () {
const TRANSCRIPT = [
{ text: "We", start: 0.119, end: 0.259 },
{ text: "asked", start: 0.319, end: 0.479 },
{ text: "what", start: 0.519, end: 0.659 },
{ text: "you", start: 0.699, end: 0.819 },
{ text: "needed.", start: 0.859, end: 1.819 },
];
// rest of composition code ...
})();
`;
const SCRIPT_VARIABLE_SOURCE = `
(function () {
const script = [
{ text: "We", start: 0.119, end: 0.259 },
{ text: "asked", start: 0.319, end: 0.479 },
{ text: "what", start: 0.519, end: 0.659 },
];
// rest of composition code ...
})();
`;
const LET_TRANSCRIPT_SOURCE = `
(function () {
let TRANSCRIPT = [
{ text: "Hello", start: 0.0, end: 0.5 },
{ text: "world", start: 0.6, end: 1.0 },
];
})();
`;
const VAR_TRANSCRIPT_SOURCE = `
(function () {
var TRANSCRIPT = [
{ text: "Hello", start: 0.0, end: 0.5 },
];
})();
`;
const SINGLE_QUOTED_SOURCE = `
(function () {
const TRANSCRIPT = [
{ text: 'We', start: 0.119, end: 0.259 },
{ text: 'asked', start: 0.319, end: 0.479 },
];
})();
`;
const TRAILING_COMMA_SOURCE = `
(function () {
const TRANSCRIPT = [
{ text: "We", start: 0.119, end: 0.259, },
{ text: "asked", start: 0.319, end: 0.479, },
];
})();
`;
const NON_CAPTION_SOURCE = `
(function () {
const config = { fps: 30, duration: 10 };
const elements = ["title", "subtitle"];
gsap.to(".clip", { opacity: 1 });
})();
`;
const EMPTY_SOURCE = ``;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("extractTranscript", () => {
describe("TRANSCRIPT variable", () => {
it("extracts words from a standard TRANSCRIPT array", () => {
const words = extractTranscript(STANDARD_CAPTION_SOURCE);
expect(words).toHaveLength(5);
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
expect(words[1]).toEqual({ text: "asked", start: 0.319, end: 0.479 });
expect(words[4]).toEqual({ text: "needed.", start: 0.859, end: 1.819 });
});
it("handles let TRANSCRIPT declaration", () => {
const words = extractTranscript(LET_TRANSCRIPT_SOURCE);
expect(words).toHaveLength(2);
expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
expect(words[1]).toEqual({ text: "world", start: 0.6, end: 1.0 });
});
it("handles var TRANSCRIPT declaration", () => {
const words = extractTranscript(VAR_TRANSCRIPT_SOURCE);
expect(words).toHaveLength(1);
expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
});
});
describe("script variable name", () => {
it("extracts words from a const script array (warm-grain template variant)", () => {
const words = extractTranscript(SCRIPT_VARIABLE_SOURCE);
expect(words).toHaveLength(3);
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
expect(words[2]).toEqual({ text: "what", start: 0.519, end: 0.659 });
});
});
describe("non-caption source", () => {
it("returns empty array when no TRANSCRIPT or script variable is found", () => {
const words = extractTranscript(NON_CAPTION_SOURCE);
expect(words).toEqual([]);
});
it("returns empty array for an empty string", () => {
const words = extractTranscript(EMPTY_SOURCE);
expect(words).toEqual([]);
});
});
describe("single-quoted values", () => {
it("parses arrays with single-quoted text values", () => {
const words = extractTranscript(SINGLE_QUOTED_SOURCE);
expect(words).toHaveLength(2);
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
expect(words[1]).toEqual({ text: "asked", start: 0.319, end: 0.479 });
});
});
describe("trailing commas", () => {
it("handles trailing commas inside objects", () => {
const words = extractTranscript(TRAILING_COMMA_SOURCE);
expect(words).toHaveLength(2);
expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
});
});
describe("real-world source samples", () => {
it("handles a realistic production-style TRANSCRIPT block with many words", () => {
const source = `
(function() {
const TRANSCRIPT = [
{ text: "We", start: 0.119, end: 0.259 },
{ text: "asked", start: 0.319, end: 0.479 },
{ text: "what", start: 0.519, end: 0.659 },
{ text: "you", start: 0.699, end: 0.819 },
{ text: "needed.", start: 0.859, end: 1.819 },
{ text: "Forty-seven", start: 1.86, end: 2.299 },
{ text: "percent", start: 2.399, end: 2.679 },
{ text: "of", start: 2.7, end: 2.799 },
];
})();
`;
const words = extractTranscript(source);
expect(words).toHaveLength(8);
expect(words[5]).toEqual({ text: "Forty-seven", start: 1.86, end: 2.299 });
});
it("handles words with punctuation in text values", () => {
const source = `
const TRANSCRIPT = [
{ text: "graphics,", start: 3.579, end: 4.599 },
{ text: "you", start: 4.679, end: 5.179 },
{ text: "attention.", start: 5.299, end: 5.759 },
];
`;
const words = extractTranscript(source);
expect(words).toHaveLength(3);
expect(words[0].text).toBe("graphics,");
expect(words[2].text).toBe("attention.");
});
});
});
// ---------------------------------------------------------------------------
// buildCaptionModel tests
// ---------------------------------------------------------------------------
const SEVEN_WORDS: TranscriptWord[] = [
{ text: "We", start: 0.1, end: 0.3 },
{ text: "asked", start: 0.4, end: 0.6 },
{ text: "what", start: 0.7, end: 0.9 },
{ text: "you", start: 1.0, end: 1.2 },
{ text: "needed.", start: 1.3, end: 1.8 },
{ text: "Forty-seven", start: 1.9, end: 2.3 },
{ text: "percent", start: 2.4, end: 2.7 },
];
describe("buildCaptionModel", () => {
describe("grouping", () => {
it("produces 2 groups for 7 words with wordsPerGroup=5 (5 + 2)", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
expect(model.groupOrder).toHaveLength(2);
expect(model.groups.size).toBe(2);
});
it("first group has 5 segments and second group has 2 segments", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
const firstGroupId = model.groupOrder[0];
const secondGroupId = model.groupOrder[1];
expect(model.groups.get(firstGroupId)?.segmentIds).toHaveLength(5);
expect(model.groups.get(secondGroupId)?.segmentIds).toHaveLength(2);
});
it("uses default wordsPerGroup of 5 when not specified", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1280,
height: 720,
duration: 5,
});
expect(model.groupOrder).toHaveLength(2);
});
});
describe("segments", () => {
it("segments have correct text matching the transcript words", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
expect(model.segments.size).toBe(7);
const firstGroupId = model.groupOrder[0];
const firstGroup = model.groups.get(firstGroupId);
const firstSegmentId = firstGroup?.segmentIds[0];
const firstSegment = firstSegmentId ? model.segments.get(firstSegmentId) : undefined;
expect(firstSegment?.text).toBe("We");
const secondGroupId = model.groupOrder[1];
const secondGroup = model.groups.get(secondGroupId);
const sixthSegmentId = secondGroup?.segmentIds[0];
const sixthSegment = sixthSegmentId ? model.segments.get(sixthSegmentId) : undefined;
expect(sixthSegment?.text).toBe("Forty-seven");
});
it("segments have correct start and end timing from the transcript", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
const firstGroupId = model.groupOrder[0];
const firstGroup = model.groups.get(firstGroupId);
const segId = firstGroup?.segmentIds[4];
const fifthSegment = segId ? model.segments.get(segId) : undefined;
expect(fifthSegment?.start).toBe(1.3);
expect(fifthSegment?.end).toBe(1.8);
expect(fifthSegment?.text).toBe("needed.");
});
it("segments have correct groupIndex reflecting position within their group", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
const secondGroupId = model.groupOrder[1];
const secondGroup = model.groups.get(secondGroupId);
const segId = secondGroup?.segmentIds[1];
const segment = segId ? model.segments.get(segId) : undefined;
expect(segment?.groupIndex).toBe(1);
});
});
describe("model dimensions", () => {
it("stores correct width, height, and duration on the model", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 30.5,
wordsPerGroup: 5,
});
expect(model.width).toBe(1920);
expect(model.height).toBe(1080);
expect(model.duration).toBe(30.5);
});
});
describe("default styles", () => {
it("groups have DEFAULT_STYLE applied", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
const firstGroupId = model.groupOrder[0];
const group = model.groups.get(firstGroupId);
expect(group?.style).toEqual(DEFAULT_STYLE);
});
it("groups have DEFAULT_CONTAINER applied", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
const firstGroupId = model.groupOrder[0];
const group = model.groups.get(firstGroupId);
expect(group?.containerStyle).toEqual(DEFAULT_CONTAINER);
});
it("groups have DEFAULT_ANIMATION_SET applied", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
const firstGroupId = model.groupOrder[0];
const group = model.groups.get(firstGroupId);
expect(group?.animation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance);
expect(group?.animation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight);
expect(group?.animation.exit).toEqual(DEFAULT_ANIMATION_SET.exit);
});
it("model defaultAnimation matches DEFAULT_ANIMATION_SET", () => {
const model = buildCaptionModel(SEVEN_WORDS, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
expect(model.defaultAnimation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance);
expect(model.defaultAnimation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight);
expect(model.defaultAnimation.exit).toEqual(DEFAULT_ANIMATION_SET.exit);
});
});
describe("edge cases", () => {
it("handles an empty transcript returning a model with no segments or groups", () => {
const model = buildCaptionModel([], {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
expect(model.segments.size).toBe(0);
expect(model.groups.size).toBe(0);
expect(model.groupOrder).toHaveLength(0);
});
it("handles transcript with exactly wordsPerGroup words producing 1 group", () => {
const fiveWords = SEVEN_WORDS.slice(0, 5);
const model = buildCaptionModel(fiveWords, {
width: 1920,
height: 1080,
duration: 10,
wordsPerGroup: 5,
});
expect(model.groupOrder).toHaveLength(1);
expect(model.segments.size).toBe(5);
});
});
});
+312
View File
@@ -0,0 +1,312 @@
// Caption Parser — Extract Transcript & Build Caption Model
// Parses a caption composition's JavaScript source to extract the transcript word array,
// and builds a CaptionModel from a TranscriptWord array.
import {
CaptionModel,
CaptionSegment,
CaptionGroup,
CaptionStyle,
CaptionContainerStyle,
DEFAULT_STYLE,
DEFAULT_CONTAINER,
DEFAULT_ANIMATION_SET,
} from "./types.js";
export interface TranscriptWord {
id?: string;
text: string;
start: number;
end: number;
}
export interface BuildOptions {
width: number;
height: number;
duration: number;
wordsPerGroup?: number; // default 5
}
/**
* Builds a CaptionModel from a transcript word array and composition dimensions.
*
* Words are grouped into chunks of `wordsPerGroup` (default 5). Each word becomes a
* CaptionSegment with its original timing. Each chunk becomes a CaptionGroup with
* DEFAULT_STYLE, DEFAULT_ANIMATION_SET, and DEFAULT_CONTAINER.
*/
export function buildCaptionModel(
transcript: TranscriptWord[],
options: BuildOptions,
): CaptionModel {
const { width, height, duration, wordsPerGroup = 5 } = options;
const segments = new Map<string, CaptionSegment>();
const groups = new Map<string, CaptionGroup>();
const groupOrder: string[] = [];
// Chunk the transcript into groups of wordsPerGroup
for (let groupIdx = 0; groupIdx < transcript.length; groupIdx += wordsPerGroup) {
const chunk = transcript.slice(groupIdx, groupIdx + wordsPerGroup);
const groupId = `group-${groupIdx / wordsPerGroup}`;
const segmentIds: string[] = [];
chunk.forEach((word, wordIdx) => {
const segmentId = `segment-${groupIdx + wordIdx}`;
const segment: CaptionSegment = {
id: segmentId,
wordId: word.id ?? `w${groupIdx + wordIdx}`,
text: word.text,
start: word.start,
end: word.end,
groupIndex: wordIdx,
style: {},
animation: {},
};
segments.set(segmentId, segment);
segmentIds.push(segmentId);
});
const group: CaptionGroup = {
id: groupId,
segmentIds,
style: { ...DEFAULT_STYLE },
animation: {
entrance: { ...DEFAULT_ANIMATION_SET.entrance },
highlight: DEFAULT_ANIMATION_SET.highlight,
exit: { ...DEFAULT_ANIMATION_SET.exit },
},
containerStyle: { ...DEFAULT_CONTAINER },
};
groups.set(groupId, group);
groupOrder.push(groupId);
}
return {
width,
height,
duration,
segments,
groups,
groupOrder,
defaultAnimation: {
entrance: { ...DEFAULT_ANIMATION_SET.entrance },
highlight: DEFAULT_ANIMATION_SET.highlight,
exit: { ...DEFAULT_ANIMATION_SET.exit },
},
};
}
/**
* Extracts a transcript word array from caption composition source code.
*
* Looks for `const TRANSCRIPT = [...]` or `const script = [...]` (also let/var)
* and parses each `{ text, start, end }` object into TranscriptWord objects.
*
* Returns an empty array if no transcript is found or if parsing fails.
*/
export function extractTranscript(source: string): TranscriptWord[] {
// Match: (const|let|var) (TRANSCRIPT|script) = [...]
// The array may span multiple lines and contain trailing commas.
const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/;
const match = source.match(varPattern);
if (!match) {
return [];
}
const arrayLiteral = match[1];
try {
return parseTranscriptArray(arrayLiteral);
} catch {
return [];
}
}
/**
* Parses a caption composition from a live iframe DOM, extracting the transcript
* from the source and reading computed styles from rendered elements.
*
* Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM
* elements to build a fully-styled CaptionModel.
*
* Returns null if no transcript is found in the source.
*/
export function parseCaptionComposition(
iframeDoc: Document,
iframeWin: Window,
source: string,
compositionWidth: number,
compositionHeight: number,
compositionDuration: number,
): CaptionModel | null {
// Step 1: Extract transcript words from source
const transcript = extractTranscript(source);
if (transcript.length === 0) {
return null;
}
// Step 2: Look for grouping and word elements in the iframe DOM
const groupEls = iframeDoc.querySelectorAll(".caption-group, .caption-line, .caption-block");
const wordEls = iframeDoc.querySelectorAll(".word, .caption-word");
// Step 3: Infer wordsPerGroup from element counts
let wordsPerGroup = 5; // default
if (groupEls.length > 0 && wordEls.length > 0) {
wordsPerGroup = Math.round(wordEls.length / groupEls.length);
if (wordsPerGroup < 1) {
wordsPerGroup = 1;
}
}
// Step 4: Build the caption model with inferred grouping
const model = buildCaptionModel(transcript, {
width: compositionWidth,
height: compositionHeight,
duration: compositionDuration,
wordsPerGroup,
});
// Step 5: Read computed styles from the first word or group element
const firstWordEl = wordEls.item(0) as Element | null;
const firstGroupEl = groupEls.item(0) as Element | null;
const styleSourceEl = firstWordEl ?? firstGroupEl;
if (styleSourceEl) {
const computed = iframeWin.getComputedStyle(styleSourceEl);
// Build partial style overrides from computed values
const styleOverrides: Partial<CaptionStyle> = {};
const fontSize = parseFloat(computed.fontSize);
if (!isNaN(fontSize) && fontSize > 0) {
styleOverrides.fontSize = fontSize;
}
const fontWeight = computed.fontWeight;
if (fontWeight) {
const numericWeight = parseInt(fontWeight, 10);
styleOverrides.fontWeight = isNaN(numericWeight) ? fontWeight : numericWeight;
}
const fontFamily = computed.fontFamily;
if (fontFamily) {
styleOverrides.fontFamily = fontFamily;
}
const color = computed.color;
if (color) {
styleOverrides.color = color;
}
const textTransform = computed.textTransform as CaptionStyle["textTransform"];
if (
textTransform === "none" ||
textTransform === "uppercase" ||
textTransform === "lowercase" ||
textTransform === "capitalize"
) {
styleOverrides.textTransform = textTransform;
}
const letterSpacing = computed.letterSpacing;
if (letterSpacing && letterSpacing !== "normal") {
const lsPx = parseFloat(letterSpacing);
const fsPx = styleOverrides.fontSize ?? DEFAULT_STYLE.fontSize;
if (!isNaN(lsPx) && fsPx > 0) {
// Convert px to em
styleOverrides.letterSpacing = lsPx / fsPx;
}
}
// Step 6: Read container styles from group element (if visible background)
const containerOverrides: Partial<CaptionContainerStyle> = {};
if (firstGroupEl) {
const groupComputed = iframeWin.getComputedStyle(firstGroupEl);
const bgColor = groupComputed.backgroundColor;
// Only apply if it's not transparent/none
if (bgColor && bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent") {
containerOverrides.backgroundColor = bgColor;
containerOverrides.backgroundOpacity = 1;
}
const borderRadius = parseFloat(groupComputed.borderRadius);
if (!isNaN(borderRadius) && borderRadius > 0) {
containerOverrides.borderRadius = borderRadius;
}
// Parse padding shorthand or individual values
const paddingTop = parseFloat(groupComputed.paddingTop);
const paddingRight = parseFloat(groupComputed.paddingRight);
const paddingBottom = parseFloat(groupComputed.paddingBottom);
const paddingLeft = parseFloat(groupComputed.paddingLeft);
if (!isNaN(paddingTop)) containerOverrides.paddingTop = paddingTop;
if (!isNaN(paddingRight)) containerOverrides.paddingRight = paddingRight;
if (!isNaN(paddingBottom)) containerOverrides.paddingBottom = paddingBottom;
if (!isNaN(paddingLeft)) containerOverrides.paddingLeft = paddingLeft;
}
// Step 7: Apply extracted styles to all groups in the model
for (const groupId of model.groupOrder) {
const group = model.groups.get(groupId);
if (!group) continue;
group.style = { ...group.style, ...styleOverrides };
group.containerStyle = { ...group.containerStyle, ...containerOverrides };
}
}
return model;
}
/**
* Parses a JS array literal containing `{ text, start, end }` objects.
*
* Handles:
* - Double-quoted and single-quoted string values
* - Trailing commas after the last element or property
* - Unquoted property keys (standard JS object literal syntax)
* - Numeric values for start/end
*/
function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] {
// Try parsing as-is first (handles already-valid JSON)
let parsed: unknown;
try {
parsed = JSON.parse(arrayLiteral);
} catch {
// Not valid JSON — normalize single quotes, unquoted keys, trailing commas
let normalized = arrayLiteral;
normalized = normalized.replace(/'((?:[^'\\]|\\.)*)'/g, (_match, inner) => {
const escaped = inner.replace(/\\'/g, "'").replace(/"/g, '\\"');
return `"${escaped}"`;
});
normalized = normalized.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
normalized = normalized.replace(/,(\s*[}\]])/g, "$1");
parsed = JSON.parse(normalized);
}
if (!Array.isArray(parsed)) {
return [];
}
const words: TranscriptWord[] = [];
for (const item of parsed) {
if (
item !== null &&
typeof item === "object" &&
typeof (item as Record<string, unknown>).text === "string" &&
typeof (item as Record<string, unknown>).start === "number" &&
typeof (item as Record<string, unknown>).end === "number"
) {
const entry = item as Record<string, unknown>;
words.push({
text: entry.text as string,
start: entry.start as number,
end: entry.end as number,
});
}
}
return words;
}
+270
View File
@@ -0,0 +1,270 @@
import { create } from "zustand";
import {
CaptionAnimation,
CaptionAnimationSet,
CaptionContainerStyle,
CaptionModel,
CaptionStyle,
} from "./types";
interface CaptionState {
isEditMode: boolean;
model: CaptionModel | null;
selectedSegmentIds: Set<string>;
selectedGroupId: string | null;
sourceFilePath: string | null;
// Basic
setEditMode: (active: boolean) => void;
setModel: (model: CaptionModel | null) => void;
setSourceFilePath: (path: string | null) => void;
// Selection
selectSegment: (id: string, additive?: boolean) => void;
selectGroup: (id: string) => void;
selectAll: () => void;
clearSelection: () => void;
// Segment mutations
updateSegmentStyle: (segmentId: string, style: Partial<CaptionStyle>) => void;
updateSegmentText: (segmentId: string, text: string) => void;
updateSegmentTiming: (segmentId: string, start: number, end: number) => void;
// Group mutations
updateGroupStyle: (groupId: string, style: Partial<CaptionStyle>) => void;
updateGroupContainer: (groupId: string, container: Partial<CaptionContainerStyle>) => void;
updateGroupAnimation: (
groupId: string,
phase: keyof CaptionAnimationSet,
animation: Partial<CaptionAnimation>,
) => void;
splitGroup: (groupId: string, atSegmentId: string) => void;
mergeGroups: (groupId1: string, groupId2: string) => void;
// Bulk
updateSelectedStyle: (style: Partial<CaptionStyle>) => void;
applyAnimationToAll: (animation: CaptionAnimationSet) => void;
// Reset
reset: () => void;
}
const initialState = {
isEditMode: false,
model: null,
selectedSegmentIds: new Set<string>(),
selectedGroupId: null,
sourceFilePath: null,
};
export const useCaptionStore = create<CaptionState>((set, get) => ({
...initialState,
// Basic
setEditMode: (active) => set({ isEditMode: active }),
setModel: (model) => set({ model }),
setSourceFilePath: (path) => set({ sourceFilePath: path }),
// Selection
selectSegment: (id, additive = false) =>
set((state) => {
if (additive) {
const next = new Set(state.selectedSegmentIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedSegmentIds: next, selectedGroupId: null };
}
return { selectedSegmentIds: new Set([id]), selectedGroupId: null };
}),
selectGroup: (id) =>
set((state) => {
const group = state.model?.groups.get(id);
if (!group) return {};
return {
selectedSegmentIds: new Set(group.segmentIds),
selectedGroupId: id,
};
}),
selectAll: () =>
set((state) => {
if (!state.model) return {};
return {
selectedSegmentIds: new Set(state.model.segments.keys()),
selectedGroupId: null,
};
}),
clearSelection: () => set({ selectedSegmentIds: new Set(), selectedGroupId: null }),
// Segment mutations
updateSegmentStyle: (segmentId, style) =>
set((state) => {
if (!state.model) return {};
const segment = state.model.segments.get(segmentId);
if (!segment) return {};
const segments = new Map(state.model.segments);
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
return { model: { ...state.model, segments } };
}),
updateSegmentText: (segmentId, text) =>
set((state) => {
if (!state.model) return {};
const segment = state.model.segments.get(segmentId);
if (!segment) return {};
const segments = new Map(state.model.segments);
segments.set(segmentId, { ...segment, text });
return { model: { ...state.model, segments } };
}),
updateSegmentTiming: (segmentId, start, end) =>
set((state) => {
if (!state.model) return {};
const segment = state.model.segments.get(segmentId);
if (!segment) return {};
const segments = new Map(state.model.segments);
segments.set(segmentId, { ...segment, start, end });
return { model: { ...state.model, segments } };
}),
// Group mutations
updateGroupStyle: (groupId, style) =>
set((state) => {
if (!state.model) return {};
const group = state.model.groups.get(groupId);
if (!group) return {};
const groups = new Map(state.model.groups);
groups.set(groupId, { ...group, style: { ...group.style, ...style } });
return { model: { ...state.model, groups } };
}),
updateGroupContainer: (groupId, container) =>
set((state) => {
if (!state.model) return {};
const group = state.model.groups.get(groupId);
if (!group) return {};
const groups = new Map(state.model.groups);
groups.set(groupId, {
...group,
containerStyle: { ...group.containerStyle, ...container },
});
return { model: { ...state.model, groups } };
}),
updateGroupAnimation: (groupId, phase, animation) =>
set((state) => {
if (!state.model) return {};
const group = state.model.groups.get(groupId);
if (!group) return {};
const groups = new Map(state.model.groups);
const existingPhase = group.animation[phase];
const mergedPhase =
existingPhase !== null
? { ...existingPhase, ...animation }
: (animation as CaptionAnimation);
groups.set(groupId, {
...group,
animation: { ...group.animation, [phase]: mergedPhase },
});
return { model: { ...state.model, groups } };
}),
splitGroup: (groupId, atSegmentId) =>
set((state) => {
if (!state.model) return {};
const group = state.model.groups.get(groupId);
if (!group) return {};
const splitIndex = group.segmentIds.indexOf(atSegmentId);
if (splitIndex <= 0) return {};
const firstIds = group.segmentIds.slice(0, splitIndex);
const secondIds = group.segmentIds.slice(splitIndex);
const newGroupId = `group-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const groups = new Map(state.model.groups);
groups.set(groupId, { ...group, segmentIds: firstIds });
groups.set(newGroupId, { ...group, id: newGroupId, segmentIds: secondIds });
const orderIndex = state.model.groupOrder.indexOf(groupId);
const groupOrder = [...state.model.groupOrder];
groupOrder.splice(orderIndex + 1, 0, newGroupId);
// Update groupIndex for segments in the new second group
const segments = new Map(state.model.segments);
secondIds.forEach((segId, idx) => {
const seg = segments.get(segId);
if (seg) {
segments.set(segId, { ...seg, groupIndex: idx });
}
});
return { model: { ...state.model, groups, segments, groupOrder } };
}),
mergeGroups: (groupId1, groupId2) =>
set((state) => {
if (!state.model) return {};
const group1 = state.model.groups.get(groupId1);
const group2 = state.model.groups.get(groupId2);
if (!group1 || !group2) return {};
const mergedSegmentIds = [...group1.segmentIds, ...group2.segmentIds];
const groups = new Map(state.model.groups);
groups.set(groupId1, { ...group1, segmentIds: mergedSegmentIds });
groups.delete(groupId2);
const groupOrder = state.model.groupOrder.filter((id) => id !== groupId2);
// Update groupIndex for segments from group2
const segments = new Map(state.model.segments);
group2.segmentIds.forEach((segId, idx) => {
const seg = segments.get(segId);
if (seg) {
segments.set(segId, { ...seg, groupIndex: group1.segmentIds.length + idx });
}
});
// Clear selection if it referenced group2
const selectedGroupId = get().selectedGroupId === groupId2 ? null : get().selectedGroupId;
return { model: { ...state.model, groups, segments, groupOrder }, selectedGroupId };
}),
// Bulk
updateSelectedStyle: (style) =>
set((state) => {
if (!state.model || state.selectedSegmentIds.size === 0) return {};
const segments = new Map(state.model.segments);
for (const segmentId of state.selectedSegmentIds) {
const segment = segments.get(segmentId);
if (segment) {
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
}
}
return { model: { ...state.model, segments } };
}),
applyAnimationToAll: (animation) =>
set((state) => {
if (!state.model) return {};
const groups = new Map(state.model.groups);
for (const [id, group] of groups) {
groups.set(id, { ...group, animation });
}
return { model: { ...state.model, groups } };
}),
// Reset
reset: () =>
set({
...initialState,
selectedSegmentIds: new Set<string>(),
}),
}));
+207
View File
@@ -0,0 +1,207 @@
// Caption Designer — Core Types
// Foundation types for the caption designer feature in HyperFrames Studio.
// ---------------------------------------------------------------------------
// Primitive visual style types
// ---------------------------------------------------------------------------
export interface CaptionGradient {
type: "linear" | "radial";
/** Angle in degrees (only meaningful for linear gradients) */
angle: number;
stops: Array<{ offset: number; color: string }>;
}
export interface CaptionShadow {
offsetX: number;
offsetY: number;
blur: number;
color: string;
}
export interface CaptionGlow {
blur: number;
color: string;
opacity: number;
}
// ---------------------------------------------------------------------------
// Style types
// ---------------------------------------------------------------------------
export interface CaptionStyle {
// Typography
fontFamily: string;
fontSize: number; // px
fontWeight: number | string;
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline" | "line-through" | "underline line-through";
textTransform: "none" | "uppercase" | "lowercase" | "capitalize";
letterSpacing: number; // em
lineHeight: number; // unitless multiplier
// Color / fill
color: string;
/** Color when the word is being spoken (karaoke active) */
activeColor: string;
/** Color before/after the word is spoken (dim/inactive) */
dimColor: string;
opacity: number; // 01
gradientFill: CaptionGradient | null;
// Stroke
strokeWidth: number;
strokeColor: string;
// Effects
shadows: CaptionShadow[];
glow: CaptionGlow | null;
// Transform
x: number; // px
y: number; // px
rotation: number; // degrees
scaleX: number;
scaleY: number;
skewX: number; // degrees
skewY: number; // degrees
transformOrigin: string; // e.g. "center center"
// Composite
blendMode: string; // CSS mix-blend-mode value
}
export interface CaptionContainerStyle {
backgroundColor: string;
backgroundOpacity: number; // 01
paddingTop: number; // px
paddingRight: number; // px
paddingBottom: number; // px
paddingLeft: number; // px
borderRadius: number; // px
borderWidth: number; // px
borderColor: string;
borderStyle: string; // CSS border-style value
boxShadow: string; // raw CSS box-shadow value
}
// ---------------------------------------------------------------------------
// Animation types
// ---------------------------------------------------------------------------
export interface CaptionAnimation {
preset: string; // e.g. "fade", "slide-up", "scale", "none"
duration: number; // seconds
ease: string; // GSAP ease string, e.g. "power2.out"
stagger: number; // seconds between word animations
staggerDirection: "start" | "end" | "center" | "random";
intensity: number; // 01 scale factor for presets that support it
}
export interface CaptionAnimationSet {
entrance: CaptionAnimation;
highlight: CaptionAnimation | null;
exit: CaptionAnimation;
}
// ---------------------------------------------------------------------------
// Segment & Group types
// ---------------------------------------------------------------------------
/** A single timed word / token within a caption group. */
export interface CaptionSegment {
id: string;
/** Stable word ID from transcript.json (e.g. "w0"). Used for caption-overrides.json. */
wordId?: string;
text: string;
start: number; // seconds
end: number; // seconds
groupIndex: number; // index within its parent group
style: Partial<CaptionStyle>;
animation: Partial<CaptionAnimationSet>;
}
/** A group of segments rendered together as a caption line / block. */
export interface CaptionGroup {
id: string;
segmentIds: string[];
style: CaptionStyle;
animation: CaptionAnimationSet;
containerStyle: CaptionContainerStyle;
}
// ---------------------------------------------------------------------------
// Top-level model
// ---------------------------------------------------------------------------
export interface CaptionModel {
width: number; // composition width in px
height: number; // composition height in px
duration: number; // composition duration in seconds
segments: Map<string, CaptionSegment>;
groups: Map<string, CaptionGroup>;
groupOrder: string[]; // ordered group ids
defaultAnimation: CaptionAnimationSet;
}
// ---------------------------------------------------------------------------
// Default constants
// ---------------------------------------------------------------------------
export const DEFAULT_STYLE: CaptionStyle = {
fontFamily: "sans-serif",
fontSize: 48,
fontWeight: 700,
fontStyle: "normal",
textDecoration: "none",
textTransform: "none",
letterSpacing: 0,
lineHeight: 1.2,
color: "#ffffff",
activeColor: "#ffffff",
dimColor: "rgba(255, 255, 255, 0.3)",
opacity: 1,
gradientFill: null,
strokeWidth: 0,
strokeColor: "#000000",
shadows: [],
glow: null,
x: 0,
y: 0,
rotation: 0,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
transformOrigin: "center center",
blendMode: "normal",
};
export const DEFAULT_CONTAINER: CaptionContainerStyle = {
backgroundColor: "transparent",
backgroundOpacity: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
borderRadius: 0,
borderWidth: 0,
borderColor: "transparent",
borderStyle: "solid",
boxShadow: "none",
};
export const DEFAULT_ANIMATION: CaptionAnimation = {
preset: "fade",
duration: 0.2,
ease: "power2.out",
stagger: 0,
staggerDirection: "start",
intensity: 1,
};
export const DEFAULT_ANIMATION_SET: CaptionAnimationSet = {
entrance: DEFAULT_ANIMATION,
highlight: null,
exit: { ...DEFAULT_ANIMATION },
};
+17 -1
View File
@@ -146,7 +146,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
return mod.lintHyperframeHtml(html, opts);
},
runtimeUrl: "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js",
runtimeUrl: "/api/runtime.js",
rendersDir: () => resolve(dataDir, "../renders"),
@@ -363,6 +363,22 @@ function devProjectApi(): Plugin {
return _api;
};
// Serve the local runtime IIFE so compositions don't depend on CDN
const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js");
server.middlewares.use((req, res, next) => {
if (req.url !== "/api/runtime.js") return next();
if (!existsSync(runtimePath)) {
res.writeHead(404);
res.end("runtime not built — run pnpm build in packages/core");
return;
}
res.writeHead(200, {
"Content-Type": "text/javascript",
"Cache-Control": "no-store",
});
res.end(readFileSync(runtimePath, "utf-8"));
});
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/api/")) return next();
+2
View File
@@ -151,3 +151,5 @@ For audio-driven animation (beat sync, glow, pulse), see the `audio-reactive` sk
- [ ] `window.__timelines` registered for every composition
- [ ] 100% deterministic — no randomness
- [ ] Each composition includes GSAP: `<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>`
- [ ] `npx hyperframes lint` passes with 0 errors
- [ ] `npx hyperframes validate` passes with 0 errors (run both before opening the studio)