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

## Summary

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

## Test plan

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-04-02 00:47:43 -07:00
committed by GitHub
parent ad2d63db32
commit d36c1785b9
22 changed files with 3881 additions and 14 deletions
@@ -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>
);
});