import { useCallback, useRef } from "react"; import { useCaptionStore } from "../store"; import { useMountEffect } from "../../hooks/useMountEffect"; import { trackEvent } from "../../telemetry/client"; 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; segments: Map }>; }): 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 | 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((error: unknown) => { // Caption auto-save is a data-loss path; surface failures via telemetry // so a silently-dropped edit isn't invisible (no console in studio). trackEvent("studio_caption_autosave_failed", { error: String(error) }); }); }, []); // 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")}`, ); if (!res.ok) return; 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[] = []; const segIdByWordId = new Map(); 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 seg = model.segments.get(segId); if (seg?.wordId) segIdByWordId.set(seg.wordId, segId); } } const newSegments = new Map(model.segments); for (const override of overrides) { const segId = (override.wordId ? segIdByWordId.get(override.wordId) : undefined) ?? allSegIds[override.wordIndex]; if (!segId) continue; const seg = newSegments.get(segId); if (!seg) continue; const style: Partial = { ...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 }; }