import { useEffect, useMemo, useState } from "react"; import { useEditor } from "./EditorContext"; import { generateVoiceoverScript, localizeCopy } from "../../lib/api"; import type { Hotspot } from "../../types/annotations"; type HotspotSuggestion = { id: string; hotspot: Hotspot; label: string; timestamp_ms: number; }; const DEFAULT_HOTSPOT_BG = "#5E6AD2"; const DEFAULT_HOTSPOT_TEXT = "#FFFFFF"; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); function formatTime(seconds: number) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; } const isHexColor = (value: string) => /^#[0-9a-fA-F]{6}$/.test(value.trim()); const normalizeHexColor = (value: string): string | null => { const trimmed = value.trim(); if (isHexColor(trimmed)) return trimmed; if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`; if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) { const [r, g, b] = trimmed.slice(1).split(""); if (!r || !g || !b) return null; return `#${r}${r}${g}${g}${b}${b}`; } if (/^[0-9a-fA-F]{3}$/.test(trimmed)) { const [r, g, b] = trimmed.split(""); if (!r || !g || !b) return null; return `#${r}${r}${g}${g}${b}${b}`; } return null; }; const DEFAULT_ASSIST_LANGUAGE = "es"; const DEFAULT_VOICEOVER_TONE = "confident and concise"; const normalizeLanguageTag = (value: string): string => { const normalized = value.trim().toLowerCase(); return normalized || DEFAULT_ASSIST_LANGUAGE; }; const errorMessage = (error: unknown): string => { if (error instanceof Error && error.message) return error.message; return String(error); }; const EditorHotspotInspector = () => { const { fps, totalFrames, clicks, markers, hotspots, steps, selectedHotspotId, setSelectedHotspotId, placingHotspot, setPlacingHotspot, selectedStep, selectedStepId, seekToTimestamp, handleAddHotspot, handleUpdateHotspot, handleDeleteHotspot, setEditingHotspots, setWalkthroughMode, } = useEditor(); const [suggestions, setSuggestions] = useState([]); const [suggesting, setSuggesting] = useState(false); const [backgroundDraft, setBackgroundDraft] = useState(DEFAULT_HOTSPOT_BG); const [textDraft, setTextDraft] = useState(DEFAULT_HOTSPOT_TEXT); const [assistLanguage, setAssistLanguage] = useState(DEFAULT_ASSIST_LANGUAGE); const [aiBusy, setAiBusy] = useState(null); const [aiError, setAiError] = useState(null); const [aiNotice, setAiNotice] = useState(null); const selectedHotspot = useMemo( () => hotspots.find((hotspot) => hotspot.id === selectedHotspotId) ?? null, [hotspots, selectedHotspotId] ); const normalizedAssistLanguage = useMemo( () => normalizeLanguageTag(assistLanguage), [assistLanguage] ); const languageLabel = useMemo( () => normalizedAssistLanguage.toUpperCase(), [normalizedAssistLanguage] ); const localizedCopyPreview = useMemo(() => { if (!selectedHotspot) return ""; return selectedHotspot.translations?.[normalizedAssistLanguage]?.text ?? ""; }, [selectedHotspot, normalizedAssistLanguage]); const localizedVoiceoverPreview = useMemo(() => { if (!selectedHotspot) return ""; return ( selectedHotspot.translations?.[normalizedAssistLanguage]?.voiceover ?? selectedHotspot.voiceoverScript ?? "" ); }, [selectedHotspot, normalizedAssistLanguage]); useEffect(() => { if (!selectedHotspot) return; setBackgroundDraft(selectedHotspot.backgroundColor ?? DEFAULT_HOTSPOT_BG); setTextDraft(selectedHotspot.textColor ?? DEFAULT_HOTSPOT_TEXT); }, [selectedHotspotId, selectedHotspot?.backgroundColor, selectedHotspot?.textColor]); useEffect(() => { setAiError(null); setAiNotice(null); }, [selectedHotspotId]); const stepWindow = useMemo(() => { if (!selectedStep) return null; return { start_ms: selectedStep.start_ms, end_ms: selectedStep.end_ms }; }, [selectedStep]); const suggestionsDisabledReason = useMemo(() => { if (!selectedStep) return "Select a step to generate suggestions."; if (!stepWindow) return "Select a step to generate suggestions."; const clicksInStep = clicks.filter( (c) => c.timestamp_ms >= stepWindow.start_ms && c.timestamp_ms <= stepWindow.end_ms ); if (clicksInStep.length === 0) return "No clicks captured in this step."; return null; }, [clicks, selectedStep, stepWindow]); const buildSuggestions = () => { if (!selectedStep || !stepWindow) return []; const clicksInStep = clicks .filter( (c) => c.timestamp_ms >= stepWindow.start_ms && c.timestamp_ms <= stepWindow.end_ms ) .sort((a, b) => a.timestamp_ms - b.timestamp_ms); if (clicksInStep.length === 0) return []; const maxSuggestions = 3; const picked: typeof clicksInStep = []; const minMsDelta = 650; const minDist = 3.5; // percent distance in the stage coordinates for (const click of clicksInStep) { const last = picked[picked.length - 1]; if (last) { const dt = Math.abs(click.timestamp_ms - last.timestamp_ms); const dx = click.x - last.x; const dy = click.y - last.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dt < minMsDelta && dist < minDist) continue; } picked.push(click); if (picked.length >= maxSuggestions) break; } const nearestClickLabel = (timestampMs: number) => { let best: { delta: number; label: string } | null = null; for (const marker of markers) { if (marker.type !== "click") continue; const delta = Math.abs(marker.timestamp_ms - timestampMs); if (delta > 1600) continue; if (!best || delta < best.delta) { best = { delta, label: marker.label }; } } const label = best?.label?.trim() ?? ""; if (!label) return "Click here"; if (label.toLowerCase() === "click") return "Click here"; return label; }; return picked.map((click) => { const frame = clamp(Math.floor((click.timestamp_ms / 1000) * fps), 0, totalFrames); const suggested: Hotspot = { id: `hotspot-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, frameStart: frame, frameEnd: clamp(frame + 90, frame, totalFrames), x: clamp(click.x, 0, 100), y: clamp(click.y, 0, 100), width: 4, height: 4, style: "pulse", text: nearestClickLabel(click.timestamp_ms), action: { type: "next_step" }, }; return { id: `suggestion-${click.timestamp_ms}-${Math.random().toString(16).slice(2, 6)}`, hotspot: suggested, label: suggested.text?.trim() ? suggested.text : "Hotspot", timestamp_ms: click.timestamp_ms, }; }); }; const runSuggestions = () => { if (suggestionsDisabledReason) return; setSuggesting(true); try { setSuggestions(buildSuggestions()); } finally { setSuggesting(false); } }; const closeInspector = () => { setWalkthroughMode(false); setPlacingHotspot(false); if (selectedHotspotId) { setSelectedHotspotId(null); return; } setEditingHotspots(false); }; const updateSelected = (updater: (hotspot: Hotspot) => Hotspot) => { if (!selectedHotspot) return; handleUpdateHotspot(selectedHotspot.id, updater); }; const commitColorDraft = (kind: "background" | "text") => { if (!selectedHotspot) return; const draft = kind === "background" ? backgroundDraft : textDraft; const normalized = normalizeHexColor(draft); if (!normalized) { if (kind === "background") { setBackgroundDraft(selectedHotspot.backgroundColor ?? DEFAULT_HOTSPOT_BG); } else { setTextDraft(selectedHotspot.textColor ?? DEFAULT_HOTSPOT_TEXT); } return; } if (kind === "background") { setBackgroundDraft(normalized); updateSelected((c) => ({ ...c, backgroundColor: normalized })); } else { setTextDraft(normalized); updateSelected((c) => ({ ...c, textColor: normalized })); } }; const runTranslateHotspotCopy = async () => { if (!selectedHotspot) return; const source = (selectedHotspot.text ?? "").trim(); if (!source) { setAiError("Add hotspot text before running translation."); setAiNotice(null); return; } setAiBusy("translate"); setAiError(null); setAiNotice(null); try { const response = await localizeCopy(source, normalizedAssistLanguage); const language = normalizeLanguageTag(response.target_language || normalizedAssistLanguage); updateSelected((current) => { const translations = { ...(current.translations ?? {}) }; const existing = { ...(translations[language] ?? {}) }; translations[language] = { ...existing, text: response.text, }; return { ...current, translations, }; }); setAiNotice(`Localized copy saved for ${language.toUpperCase()}.`); } catch (error) { setAiError(errorMessage(error)); } finally { setAiBusy(null); } }; const runGenerateVoiceover = async () => { if (!selectedHotspot) return; const translated = selectedHotspot.translations?.[normalizedAssistLanguage]?.text?.trim(); const source = translated || (selectedHotspot.text ?? "").trim(); if (!source) { setAiError("Add hotspot text before generating a voiceover script."); setAiNotice(null); return; } setAiBusy("voiceover"); setAiError(null); setAiNotice(null); try { const response = await generateVoiceoverScript(source, { language: normalizedAssistLanguage, tone: DEFAULT_VOICEOVER_TONE, maxSeconds: 12, }); updateSelected((current) => { const translations = { ...(current.translations ?? {}) }; const existing = { ...(translations[normalizedAssistLanguage] ?? {}) }; const script = response.script.trim(); translations[normalizedAssistLanguage] = { ...existing, voiceover: script, }; return { ...current, translations, voiceoverScript: normalizedAssistLanguage === "en" ? script : (current.voiceoverScript ?? undefined), }; }); setAiNotice(`Voiceover script saved for ${languageLabel}.`); } catch (error) { setAiError(errorMessage(error)); } finally { setAiBusy(null); } }; const updateLocalizedCopy = (nextText: string) => { if (!selectedHotspot) return; updateSelected((current) => { const translations = { ...(current.translations ?? {}) }; const existing = { ...(translations[normalizedAssistLanguage] ?? {}) }; translations[normalizedAssistLanguage] = { ...existing, text: nextText, }; return { ...current, translations, }; }); }; const updateLocalizedVoiceover = (nextText: string) => { if (!selectedHotspot) return; updateSelected((current) => { const translations = { ...(current.translations ?? {}) }; const existing = { ...(translations[normalizedAssistLanguage] ?? {}) }; translations[normalizedAssistLanguage] = { ...existing, voiceover: nextText, }; return { ...current, translations, voiceoverScript: normalizedAssistLanguage === "en" ? nextText : (current.voiceoverScript ?? undefined), }; }); }; const styleLabel = (style: Hotspot["style"]) => { if (style === "rectangle") return "Area"; if (style === "callout") return "Callout"; return "Hotspot"; }; return (