mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>(),
|
||||
}),
|
||||
}));
|
||||
@@ -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; // 0–1
|
||||
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; // 0–1
|
||||
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; // 0–1 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 },
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user