mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user