import { useMemo, useState, useEffect, useRef, useCallback } from "react"; import { useParams } from "react-router-dom"; import { convertFileSrc } from "@tauri-apps/api/core"; import { exists, readTextFile } from "@tauri-apps/plugin-fs"; import { DEFAULT_FPS, useVideoFrames } from "../../hooks/useVideoFrames"; import { analyzeSession, exportMarkdown, exportMp4, extractClickOcr, getSessionClicks, getSessionSteps, getTranscript, loadSession, logMarker, onProcessingComplete, regenerateStepScreenshots, revealInFileManager, saveHotspots, saveSteps, transcribeSession, type ClickEvent, type SessionMetadata, type StepCompletionMode, type SessionStep, type SessionStepRecord, type StepsDocument, type TimelineMarker, type Transcript, } from "../../lib/api"; import { trackWorkflowEvent } from "../../lib/usageMetrics"; import { scanSessionForSensitiveData } from "../../lib/privacyScan"; import { Hotspot } from "../../types/annotations"; import { AutoStepsFeedback, EditorMode, ExportFeedback, MediaTab } from "./EditorContext"; import { generateStepsFromActivity, isPlaceholderStepsDocument, normalizeTimelineMarkers, } from "./activityHeuristics"; const DEFAULT_HOTSPOT_BG = "#5E6AD2"; const DEFAULT_HOTSPOT_TEXT = "#FFFFFF"; const DEFAULT_SPOTLIGHT_OVERLAY = "#0B1020"; const DEFAULT_SPOTLIGHT_OVERLAY_OPACITY = 0.58; const DEFAULT_SPOTLIGHT_BORDER_OPACITY = 1; const EDITOR_HISTORY_LIMIT = 100; const EDIT_TELEMETRY_COALESCE_MS = 900; const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/; type EditorHistorySnapshot = { stepsDocument: StepsDocument | null; hotspots: Hotspot[]; selectedStepId: string | null; selectedHotspotId: string | null; }; const isHotspotStyle = (value: string): value is Hotspot["style"] => { return value === "circle" || value === "rectangle" || value === "pulse" || value === "callout"; }; const isStepCompletionMode = (value: string): value is StepCompletionMode => { return value === "none" || value === "any" || value === "all"; }; const normalizeStepRecord = (step: SessionStepRecord): SessionStepRecord => { const mode = isStepCompletionMode(String(step.completion_mode ?? "none")) ? (step.completion_mode as StepCompletionMode) : "none"; const linkedHotspotIds = step.hotspot_ids ?? []; const requiredHotspotIds = (step.required_hotspot_ids ?? []).length > 0 ? (step.required_hotspot_ids ?? []).filter((id) => linkedHotspotIds.includes(id)) : linkedHotspotIds; return { ...step, completion_mode: mode, required_hotspot_ids: Array.from(new Set(requiredHotspotIds)), autoplay: Boolean(step.autoplay), }; }; const normalizeStepsDocument = (document: StepsDocument): StepsDocument => ({ ...document, steps: document.steps.map(normalizeStepRecord), }); const isAbsolutePath = (value: string): boolean => value.startsWith("/") || value.startsWith("\\\\") || WINDOWS_ABSOLUTE_PATH.test(value); const getSessionDirectory = (recordingPath: string | null | undefined): string | null => { if (!recordingPath) return null; const slash = Math.max(recordingPath.lastIndexOf("/"), recordingPath.lastIndexOf("\\")); if (slash < 0) return null; return recordingPath.slice(0, slash); }; const joinPath = (base: string, relative: string): string => { const normalizedBase = base.replace(/[\\/]+$/, ""); const normalizedRelative = relative.replace(/^\.?[\\/]+/, ""); return `${normalizedBase}/${normalizedRelative}`; }; const fromFileUrlToPath = (value: string): string => { try { const url = new URL(value); const decoded = decodeURIComponent(url.pathname); if (WINDOWS_ABSOLUTE_PATH.test(decoded.slice(1))) { return decoded.slice(1); } return decoded; } catch { return value.replace(/^file:\/+/, "/"); } }; const resolveScreenshotPath = ( screenshotPath: string | null | undefined, recordingPath: string | null | undefined ): string | null => { if (!screenshotPath) return null; const path = screenshotPath.startsWith("file://") ? fromFileUrlToPath(screenshotPath) : screenshotPath; if (isAbsolutePath(path)) return path; const sessionDir = getSessionDirectory(recordingPath); return sessionDir ? joinPath(sessionDir, path) : path; }; function normalizeHotspot(hotspot: Hotspot): Hotspot { const legacyText = hotspot.action.type === "tooltip" ? hotspot.action.text : ""; const normalizedAction = hotspot.action.type === "tooltip" ? { type: "none" as const } : hotspot.action; return { ...hotspot, style: isHotspotStyle(hotspot.style) ? hotspot.style : "pulse", text: hotspot.text ?? legacyText ?? "", backgroundColor: hotspot.backgroundColor ?? DEFAULT_HOTSPOT_BG, textColor: hotspot.textColor ?? DEFAULT_HOTSPOT_TEXT, spotlight: Boolean(hotspot.spotlight), spotlightShape: hotspot.spotlightShape === "square" || hotspot.spotlightShape === "rounded" || hotspot.spotlightShape === "circle" ? hotspot.spotlightShape : "rounded", spotlightOverlayColor: hotspot.spotlightOverlayColor ?? DEFAULT_SPOTLIGHT_OVERLAY, spotlightOverlayOpacity: typeof hotspot.spotlightOverlayOpacity === "number" ? Math.min(0.9, Math.max(0, hotspot.spotlightOverlayOpacity)) : DEFAULT_SPOTLIGHT_OVERLAY_OPACITY, spotlightBorderOpacity: typeof hotspot.spotlightBorderOpacity === "number" ? Math.min(1, Math.max(0.1, hotspot.spotlightBorderOpacity)) : DEFAULT_SPOTLIGHT_BORDER_OPACITY, action: normalizedAction, }; } function createHotspot(frame: number, x: number, y: number, style: Hotspot["style"]): Hotspot { return { id: `hotspot-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, frameStart: Math.max(0, frame), frameEnd: Math.max(0, frame + 90), x, y, width: 4, height: 4, style, text: "New hotspot", backgroundColor: DEFAULT_HOTSPOT_BG, textColor: DEFAULT_HOTSPOT_TEXT, spotlight: false, spotlightShape: "rounded", spotlightOverlayColor: DEFAULT_SPOTLIGHT_OVERLAY, spotlightOverlayOpacity: DEFAULT_SPOTLIGHT_OVERLAY_OPACITY, spotlightBorderOpacity: DEFAULT_SPOTLIGHT_BORDER_OPACITY, action: { type: "next_step" }, }; } function createStepId(): string { return `step_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; } function toWalkthroughSteps(steps: SessionStepRecord[]): SessionStep[] { return steps.map((step) => ({ id: step.id, title: step.title, description: step.body, timestamp_start_ms: step.start_ms, timestamp_end_ms: Math.max(step.end_ms, step.start_ms), completion_mode: step.completion_mode ?? "none", required_hotspot_ids: step.required_hotspot_ids ?? step.hotspot_ids ?? [], hotspot_ids: step.hotspot_ids ?? [], autoplay: Boolean(step.autoplay), })); } function parseEventType(raw: unknown): TimelineMarker["type"] | null { if ( raw === "command" || raw === "click" || raw === "file_change" || raw === "marker" || raw === "keypress" || raw === "shortcut" || raw === "mouse_scroll" || raw === "app_focus" || raw === "dom_action" || raw === "dom_navigation" ) { return raw; } return null; } function errorMessage(err: unknown): string { if (err instanceof Error && err.message) return err.message; return String(err); } function safeFileName(value: string): string { const trimmed = value.trim(); if (!trimmed) return "session"; const sanitized = trimmed .replace(/[^a-zA-Z0-9\-_\s]/g, "") .trim() .replace(/\s+/g, "-"); return sanitized || "session"; } function buildSessionExportMp4Path(metadata: SessionMetadata): string { const recordingPath = metadata.recording_path; const slash = Math.max(recordingPath.lastIndexOf("/"), recordingPath.lastIndexOf("\\")); const sessionDir = slash >= 0 ? recordingPath.slice(0, slash) : recordingPath; const base = safeFileName(metadata.title ?? metadata.session_id); return `${sessionDir}/exports/ShowRunner-${base}.mp4`; } async function resolvePlayableVideoSource(recordingPath: string): Promise { const candidates = [recordingPath]; if (recordingPath.endsWith(".mov")) { candidates.push(recordingPath.slice(0, -4) + ".mp4"); } else if (recordingPath.endsWith(".mp4")) { candidates.push(recordingPath.slice(0, -4) + ".mov"); } for (const path of candidates) { if (await exists(path)) { return convertFileSrc(path); } } return null; } function ensureHotspotLinks(document: StepsDocument, hotspots: Hotspot[]): StepsDocument { const normalized = normalizeStepsDocument(document); if (!document.steps.length || !hotspots.length) { return normalized; } const hasLinkedHotspots = normalized.steps.some((step) => (step.hotspot_ids ?? []).length > 0); if (hasLinkedHotspots) { return normalized; } const hotspotIds = hotspots.map((hotspot) => hotspot.id); return { ...normalized, steps: normalized.steps.map((step, idx) => idx === 0 ? { ...step, hotspot_ids: hotspotIds, required_hotspot_ids: hotspotIds } : step ), }; } function sampleClickTimestamps(clicks: ClickEvent[]): number[] { const sorted = [...clicks].sort((a, b) => a.timestamp_ms - b.timestamp_ms); const selected: number[] = []; let last = -Infinity; for (const click of sorted) { if (click.timestamp_ms - last < 1200) continue; selected.push(click.timestamp_ms); last = click.timestamp_ms; if (selected.length >= 10) break; } return selected; } function inferClickLabelFromOcr(text: string): string | null { const normalized = text.toLowerCase(); if (normalized.includes("gmail") && normalized.includes("inbox")) { return "Open Gmail inbox"; } if (normalized.includes("inbox") && normalized.includes("compose")) { return "Review email inbox"; } if (normalized.includes("search mail")) { return "Search in Gmail"; } if (normalized.includes("unread") || normalized.includes("starred")) { return "Open email thread"; } if (normalized.includes("linkedin")) { return "Open LinkedIn item"; } if (text.trim().length >= 16) { return `Click: ${text.trim().slice(0, 72)}`; } return null; } function applyOcrHintsToClickMarkers( markers: TimelineMarker[], hints: Array<{ timestamp_ms: number; text: string }> ): TimelineMarker[] { if (hints.length === 0) return markers; return markers.map((marker) => { if (marker.type !== "click") return marker; let best: { timestamp_ms: number; text: string } | null = null; let bestDelta = Infinity; for (const hint of hints) { const delta = Math.abs(hint.timestamp_ms - marker.timestamp_ms); if (delta < bestDelta && delta <= 1500) { best = hint; bestDelta = delta; } } if (!best) return marker; const inferred = inferClickLabelFromOcr(best.text); if (!inferred) return marker; return { ...marker, label: inferred }; }); } export const useEditorState = () => { const { sessionId } = useParams<{ sessionId: string }>(); const [sharing, setSharing] = useState(false); const [metadata, setMetadata] = useState(null); const videoRef = useRef(null); const [editorMode, setEditorMode] = useState("edit"); const [videoUrl, setVideoUrl] = useState(null); const [videoFileUrl, setVideoFileUrl] = useState(null); const blobUrlRef = useRef(null); const [terminalUrl, setTerminalUrl] = useState(null); const [playing, setPlaying] = useState(false); const [activeTab, setActiveTab] = useState("video"); const [hasTerminal, setHasTerminal] = useState(false); const [fps] = useState(DEFAULT_FPS); const [markers, setMarkers] = useState([]); const [allHotspots, setAllHotspots] = useState([]); const [selectedHotspotId, setSelectedHotspotId] = useState(null); const [editingHotspots, setEditingHotspots] = useState(false); const [placingHotspot, setPlacingHotspot] = useState(false); const [placingHotspotStyle, setPlacingHotspotStyle] = useState("pulse"); const [hotspotsLoaded, setHotspotsLoaded] = useState(false); const [stepsDocument, setStepsDocument] = useState(null); const [stepsLoaded, setStepsLoaded] = useState(false); const [selectedStepId, setSelectedStepId] = useState(null); const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [walkthroughMode, setWalkthroughMode] = useState(false); const [walkthroughSteps, setWalkthroughSteps] = useState([]); const [walkthroughLoading, setWalkthroughLoading] = useState(false); const [clicks, setClicks] = useState([]); const [showClickRipples, setShowClickRipples] = useState(true); const [exporting, setExporting] = useState(false); const [lastExportPath, setLastExportPath] = useState(null); const [exportFeedback, setExportFeedback] = useState(null); const [pendingExportPrivacyOverride, setPendingExportPrivacyOverride] = useState< "markdown" | "mp4" | null >(null); const [autoStepsFeedback, setAutoStepsFeedback] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [transcript, setTranscript] = useState(null); const [transcribing, setTranscribing] = useState(false); const [transcriptError, setTranscriptError] = useState(null); const { currentFrame, currentTime, duration, seekToFrame, seekToTimestamp, stepFrames, totalFrames, } = useVideoFrames(videoRef, fps); const steps = useMemo(() => stepsDocument?.steps ?? [], [stepsDocument]); const selectedStep = useMemo( () => steps.find((step) => step.id === selectedStepId) ?? null, [steps, selectedStepId] ); const telemetrySessionIdRef = useRef(sessionId ?? null); const pendingEditorEditsRef = useRef< Map< string, { action: string; stepId: string | null; count: number; firstTimestampMs: number; lastTimestampMs: number; } > >(new Map()); const editorTelemetryFlushTimerRef = useRef | null>(null); const flushCoalescedEditorEdits = useCallback(() => { if (editorTelemetryFlushTimerRef.current) { clearTimeout(editorTelemetryFlushTimerRef.current); editorTelemetryFlushTimerRef.current = null; } const activeSessionId = telemetrySessionIdRef.current; if (!activeSessionId) { pendingEditorEditsRef.current.clear(); return; } for (const bucket of pendingEditorEditsRef.current.values()) { trackWorkflowEvent("editor_step_edit", { sessionId: activeSessionId, metadata: { action: bucket.action, stepId: bucket.stepId, editCount: bucket.count, coalesced: bucket.count > 1, windowMs: Math.max(0, bucket.lastTimestampMs - bucket.firstTimestampMs), }, }); } pendingEditorEditsRef.current.clear(); }, []); const scheduleEditorTelemetryFlush = useCallback(() => { if (editorTelemetryFlushTimerRef.current) return; editorTelemetryFlushTimerRef.current = setTimeout(() => { editorTelemetryFlushTimerRef.current = null; flushCoalescedEditorEdits(); }, EDIT_TELEMETRY_COALESCE_MS); }, [flushCoalescedEditorEdits]); const trackEditorEdit = useCallback( (action: string, stepId?: string) => { const activeSessionId = telemetrySessionIdRef.current; if (!activeSessionId) return; const normalizedStepId = stepId ?? null; const key = `${action}::${normalizedStepId ?? "session"}`; const now = Date.now(); const existing = pendingEditorEditsRef.current.get(key); if (existing) { existing.count += 1; existing.lastTimestampMs = now; } else { pendingEditorEditsRef.current.set(key, { action, stepId: normalizedStepId, count: 1, firstTimestampMs: now, lastTimestampMs: now, }); } scheduleEditorTelemetryFlush(); }, [scheduleEditorTelemetryFlush] ); const hotspots = useMemo(() => { if (!selectedStep) { return allHotspots; } const linked = new Set(selectedStep.hotspot_ids ?? []); return allHotspots.filter((hotspot) => linked.has(hotspot.id)); }, [allHotspots, selectedStep]); useEffect(() => { setWalkthroughSteps(toWalkthroughSteps(steps)); }, [steps]); const stepsDocumentRef = useRef(stepsDocument); const hotspotsRef = useRef(allHotspots); const selectedStepIdRef = useRef(selectedStepId); const selectedHotspotIdRef = useRef(selectedHotspotId); const screenshotHealthCheckRef = useRef(null); const setSelectedHotspotIdAndSync = useCallback((id: string | null) => { selectedHotspotIdRef.current = id; setSelectedHotspotId(id); }, []); useEffect(() => { stepsDocumentRef.current = stepsDocument; }, [stepsDocument]); useEffect(() => { hotspotsRef.current = allHotspots; }, [allHotspots]); useEffect(() => { selectedStepIdRef.current = selectedStepId; }, [selectedStepId]); useEffect(() => { selectedHotspotIdRef.current = selectedHotspotId; }, [selectedHotspotId]); useEffect(() => { const nextSessionId = sessionId ?? null; if (telemetrySessionIdRef.current === nextSessionId) return; // Flush pending edits under the previous session before swapping context. flushCoalescedEditorEdits(); telemetrySessionIdRef.current = nextSessionId; pendingEditorEditsRef.current.clear(); }, [sessionId, flushCoalescedEditorEdits]); useEffect(() => { return () => { flushCoalescedEditorEdits(); }; }, [flushCoalescedEditorEdits]); const getEditorSnapshot = useCallback( (): EditorHistorySnapshot => ({ stepsDocument: stepsDocumentRef.current, hotspots: hotspotsRef.current, selectedStepId: selectedStepIdRef.current, selectedHotspotId: selectedHotspotIdRef.current, }), [] ); const applyEditorSnapshot = useCallback((snapshot: EditorHistorySnapshot) => { stepsDocumentRef.current = snapshot.stepsDocument; hotspotsRef.current = snapshot.hotspots; selectedStepIdRef.current = snapshot.selectedStepId; selectedHotspotIdRef.current = snapshot.selectedHotspotId; setStepsDocument(snapshot.stepsDocument); setAllHotspots(snapshot.hotspots); setSelectedStepId(snapshot.selectedStepId); setSelectedHotspotId(snapshot.selectedHotspotId); }, []); const resetEditorHistory = useCallback(() => { setUndoStack([]); setRedoStack([]); }, []); const commitEditorChange = useCallback( (updater: (current: EditorHistorySnapshot) => EditorHistorySnapshot) => { const current = getEditorSnapshot(); const next = updater(current); const hasChanges = next.stepsDocument !== current.stepsDocument || next.hotspots !== current.hotspots || next.selectedStepId !== current.selectedStepId || next.selectedHotspotId !== current.selectedHotspotId; if (!hasChanges) return false; setUndoStack((prev) => { const appended = [...prev, current]; return appended.length > EDITOR_HISTORY_LIMIT ? appended.slice(-EDITOR_HISTORY_LIMIT) : appended; }); setRedoStack([]); applyEditorSnapshot(next); return true; }, [applyEditorSnapshot, getEditorSnapshot] ); const canUndo = undoStack.length > 0; const canRedo = redoStack.length > 0; const undo = useCallback(() => { const previous = undoStack[undoStack.length - 1]; if (!previous) return; const current = getEditorSnapshot(); setUndoStack((prev) => prev.slice(0, -1)); setRedoStack((prev) => { const next = [current, ...prev]; return next.length > EDITOR_HISTORY_LIMIT ? next.slice(0, EDITOR_HISTORY_LIMIT) : next; }); applyEditorSnapshot(previous); setWalkthroughMode(false); setPlacingHotspot(false); }, [applyEditorSnapshot, getEditorSnapshot, undoStack]); const redo = useCallback(() => { const nextSnapshot = redoStack[0]; if (!nextSnapshot) return; const current = getEditorSnapshot(); setRedoStack((prev) => prev.slice(1)); setUndoStack((prev) => { const next = [...prev, current]; return next.length > EDITOR_HISTORY_LIMIT ? next.slice(-EDITOR_HISTORY_LIMIT) : next; }); applyEditorSnapshot(nextSnapshot); setWalkthroughMode(false); setPlacingHotspot(false); }, [applyEditorSnapshot, getEditorSnapshot, redoStack]); useEffect(() => { let mounted = true; async function setupMedia() { if (!sessionId) return; try { const loaded = await loadSession(sessionId); if (!mounted) return; const normalizedHotspots = (loaded.metadata.hotspots ?? []).map(normalizeHotspot); const hydratedSteps = ensureHotspotLinks(loaded.steps, normalizedHotspots); const preferredStepId = selectedStepIdRef.current; const nextSelectedStepId = preferredStepId && hydratedSteps.steps.some((step) => step.id === preferredStepId) ? preferredStepId : (hydratedSteps.steps[0]?.id ?? null); setMetadata(loaded.metadata); applyEditorSnapshot({ stepsDocument: hydratedSteps, hotspots: normalizedHotspots, selectedStepId: nextSelectedStepId, selectedHotspotId: null, }); resetEditorHistory(); setHotspotsLoaded(true); setWalkthroughSteps(toWalkthroughSteps(hydratedSteps.steps)); setStepsLoaded(true); // Check if session is still converting (persisted state) const isConverting = loaded.metadata.processing_status === "converting"; const videoSrc = await resolvePlayableVideoSource(loaded.metadata.recording_path); if (videoSrc && !isConverting) { setVideoFileUrl(videoSrc); setVideoUrl(videoSrc); setIsProcessing(false); } else { setIsProcessing(true); setVideoUrl(isConverting ? null : videoSrc); } if (loaded.metadata.has_terminal) { setHasTerminal(true); const termPath = loaded.metadata.events_path.replace( "events.ndjson", "terminal.cast" ); setTerminalUrl(convertFileSrc(termPath)); } let parsedMarkers: TimelineMarker[] = []; try { const eventsExist = await exists(loaded.metadata.events_path); if (eventsExist) { const content = await readTextFile(loaded.metadata.events_path); const lines = content.trim().split("\n"); parsedMarkers = lines .filter((line) => line.trim()) .map((line, idx) => { try { const event = JSON.parse(line) as Record; const type = parseEventType(event.type); if (!type) return null; const timestamp = Number( event.ts_ms ?? event.timestamp_ms ?? event.timestamp ?? 0 ) || 0; const label = (() => { if (typeof event.label === "string" && event.label.trim()) { return event.label; } if (type === "command") { return String(event.data ?? event.text ?? "command"); } if (type === "dom_navigation") { const url = typeof event.url === "string" ? event.url : ""; const title = typeof event.title === "string" ? event.title : ""; let host = ""; try { host = url ? new URL(url).hostname : ""; } catch { host = ""; } const shortTitle = title.length > 80 ? `${title.slice(0, 77)}...` : title; if (host && shortTitle) return `Navigate: ${host} (${shortTitle})`; if (host) return `Navigate: ${host}`; return "Navigate"; } if (type === "dom_action") { const action = typeof event.action === "string" ? event.action : "action"; const target = typeof event.target === "object" && event.target ? (event.target as Record) : null; const name = target && typeof target.name === "string" ? target.name : ""; const role = target && typeof target.role === "string" ? target.role : ""; const suffix = name ? `: ${name}` : role ? ` (${role})` : ""; if (action === "click") return `Click${suffix}`; if (action === "submit") return `Submit${suffix}`; if (action === "enter") return `Enter${suffix}`; if (action === "input_change") return `Input${suffix}`; return `Action${suffix}`; } if (type === "shortcut") { const modifiers = Array.isArray(event.modifiers) ? event.modifiers .filter( (item): item is string => typeof item === "string" ) .map((item) => item.toLowerCase()) : []; const key = typeof event.key === "string" ? event.key : "key"; const suffix = key === "" ? "key" : key; return modifiers.length ? `${modifiers.join("+")}+${suffix}` : suffix; } if (type === "keypress") { const key = typeof event.key === "string" ? event.key : "key"; return key === "" ? "key press" : `key ${key}`; } if (type === "mouse_scroll") { const direction = typeof event.direction === "string" ? event.direction : ""; return direction ? `scroll ${direction}` : "scroll"; } if (type === "app_focus") { const app = typeof event.app === "string" ? event.app : "Unknown app"; const windowTitle = typeof event.window === "string" ? event.window : ""; return windowTitle && windowTitle !== app ? `${app}: ${windowTitle}` : app; } return type; })(); const marker: TimelineMarker = { id: (typeof event.event_id === "string" && event.event_id.trim()) || `event-${Math.max( 1, Number(event.sequence ?? idx + 1) || idx + 1 )}`, timestamp_ms: timestamp, label, type, }; if (type === "dom_action" || type === "dom_navigation") { marker.meta = event as Record; } return marker; } catch { return null; } }) .filter((item): item is TimelineMarker => item !== null); } } catch (err) { console.error("Failed to load events/markers:", err); } // Try to load existing transcript try { const existingTranscript = await getTranscript(sessionId); if (existingTranscript) { setTranscript(existingTranscript); } } catch (err) { console.error("Failed to load existing transcript:", err); } try { const clickEvents = await getSessionClicks(sessionId); setClicks(clickEvents); const sampledTimestamps = sampleClickTimestamps(clickEvents); if (sampledTimestamps.length > 0 && parsedMarkers.length > 0) { try { const ocrHints = await extractClickOcr(sessionId, sampledTimestamps); parsedMarkers = applyOcrHintsToClickMarkers(parsedMarkers, ocrHints); } catch (err) { console.error("Failed to enrich click markers with OCR:", err); } } const normalizedMarkers = normalizeTimelineMarkers(parsedMarkers); setMarkers(normalizedMarkers); const metadataDurationMs = loaded.metadata.duration_ms ?? 0; const hasRichEvents = normalizedMarkers.length >= 8; const hasPlaceholderSteps = isPlaceholderStepsDocument(hydratedSteps.steps); if (metadataDurationMs > 0 && hasRichEvents && hasPlaceholderSteps) { const generated = generateStepsFromActivity( normalizedMarkers, clickEvents, metadataDurationMs ); const nextDocument = ensureHotspotLinks( { version: 1, steps: generated }, loaded.metadata.hotspots ?? [] ); const preferredStepId = selectedStepIdRef.current; const nextSelectedStepId = preferredStepId && nextDocument.steps.some((step) => step.id === preferredStepId) ? preferredStepId : (nextDocument.steps[0]?.id ?? null); applyEditorSnapshot({ stepsDocument: nextDocument, hotspots: hotspotsRef.current, selectedStepId: nextSelectedStepId, selectedHotspotId: selectedHotspotIdRef.current, }); setWalkthroughSteps(toWalkthroughSteps(nextDocument.steps)); } } catch (err) { console.error("Failed to load click events:", err); setMarkers(normalizeTimelineMarkers(parsedMarkers)); } } catch (err) { console.error("Failed to load session metadata/media:", err); } } setupMedia(); return () => { mounted = false; if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; } }; }, [sessionId, applyEditorSnapshot, resetEditorHistory]); useEffect(() => { if (!sessionId || !isProcessing) return; let unlisten: (() => void) | undefined; onProcessingComplete((completedSessionId) => { if (completedSessionId !== sessionId) return; setIsProcessing(false); if (!metadata) return; setTimeout(async () => { try { const refreshedSrc = await resolvePlayableVideoSource(metadata.recording_path); if (refreshedSrc) { setVideoFileUrl(refreshedSrc); setVideoUrl(refreshedSrc); } } catch (err) { console.error("Failed to refresh processed video source:", err); } try { const loaded = await loadSession(sessionId); const normalizedHotspots = (loaded.metadata.hotspots ?? []).map( normalizeHotspot ); setMetadata(loaded.metadata); const hydratedSteps = ensureHotspotLinks(loaded.steps, normalizedHotspots); const preferredStepId = selectedStepIdRef.current; const nextSelectedStepId = preferredStepId && hydratedSteps.steps.some((step) => step.id === preferredStepId) ? preferredStepId : (hydratedSteps.steps[0]?.id ?? null); applyEditorSnapshot({ stepsDocument: hydratedSteps, hotspots: normalizedHotspots, selectedStepId: nextSelectedStepId, selectedHotspotId: null, }); resetEditorHistory(); setWalkthroughSteps(toWalkthroughSteps(hydratedSteps.steps)); } catch (err) { console.error("Failed to reload session after processing:", err); } }, 500); }).then((fn) => { unlisten = fn; }); return () => { if (unlisten) unlisten(); }; }, [sessionId, isProcessing, metadata, applyEditorSnapshot, resetEditorHistory]); useEffect(() => { if (!sessionId || !hotspotsLoaded) return; const timeout = window.setTimeout(() => { saveHotspots(sessionId, allHotspots).catch((err) => { console.error("Failed to save hotspots:", err); }); }, 300); return () => window.clearTimeout(timeout); }, [sessionId, allHotspots, hotspotsLoaded]); useEffect(() => { const isTauriRuntime = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; if (!isTauriRuntime || !sessionId || isProcessing || !stepsDocument) return; const stepKey = `${sessionId}:${stepsDocument.steps .map((step) => `${step.id}:${step.screenshot?.path ?? ""}`) .join("|")}`; if (screenshotHealthCheckRef.current === stepKey) return; screenshotHealthCheckRef.current = stepKey; let cancelled = false; const run = async () => { const needsRepairChecks = await Promise.all( stepsDocument.steps.map(async (step) => { if (!step.screenshot?.path) return true; const resolved = resolveScreenshotPath( step.screenshot.path, metadata?.recording_path ?? null ); if (!resolved) return true; try { return !(await exists(resolved)); } catch { return true; } }) ); const needsRepair = needsRepairChecks.some(Boolean); if (!needsRepair) return; try { const regenerated = await regenerateStepScreenshots(sessionId, stepsDocument); if (cancelled) return; const screenshotByStepId = new Map( regenerated.steps.map((step) => [step.id, step.screenshot]) ); setStepsDocument((current) => { if (!current) return regenerated; let changed = false; const nextSteps = current.steps.map((step) => { const nextScreenshot = screenshotByStepId.get(step.id); if (nextScreenshot === undefined) return step; const samePath = step.screenshot?.path === nextScreenshot?.path; const sameTimestamp = step.screenshot?.timestamp_ms === nextScreenshot?.timestamp_ms; if (samePath && sameTimestamp) return step; changed = true; return { ...step, screenshot: nextScreenshot ?? null }; }); if (!changed) return current; return { ...current, steps: nextSteps }; }); } catch (err) { console.error("Failed to repair missing step screenshots:", err); } }; void run(); return () => { cancelled = true; }; }, [sessionId, isProcessing, stepsDocument, metadata?.recording_path]); useEffect(() => { if (!sessionId || !stepsLoaded || !stepsDocument) return; const timeout = window.setTimeout(() => { saveSteps(sessionId, stepsDocument).catch((err) => { console.error("Failed to save steps:", err); }); }, 300); return () => window.clearTimeout(timeout); }, [sessionId, stepsLoaded, stepsDocument]); useEffect(() => { if (!selectedStepId || !selectedHotspotId) return; const linkedIds = new Set(selectedStep?.hotspot_ids ?? []); if (!linkedIds.has(selectedHotspotId)) { setSelectedHotspotIdAndSync(null); } }, [selectedStepId, selectedStep, selectedHotspotId, setSelectedHotspotIdAndSync]); const togglePlay = () => { const video = videoRef.current; if (!video || !video.src) return; if (video.paused) { if (video.error) { console.warn("Skipping play; video is in error state", video.error); setPlaying(false); return; } const playPromise = video.play(); if (playPromise && typeof playPromise.catch === "function") { playPromise.catch((err) => { console.error("Video play failed:", err); setPlaying(false); }); } return; } video.pause(); }; const handleSelectStep = (stepId: string) => { selectedStepIdRef.current = stepId; setSelectedStepId(stepId); const step = steps.find((item) => item.id === stepId); if (step) { seekToTimestamp(step.start_ms / 1000); setWalkthroughMode(false); } }; const handleRenameStep = (stepId: string, title: string) => { const nextTitle = title.trim(); if (!nextTitle) return; const changed = commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; let didChange = false; const nextSteps = doc.steps.map((step) => { if (step.id !== stepId) return step; if (step.title === nextTitle) return step; didChange = true; return { ...step, title: nextTitle }; }); if (!didChange) return current; return { ...current, stepsDocument: { ...doc, steps: nextSteps, }, }; }); if (changed) { trackEditorEdit("rename_step", stepId); } }; const updateStepConfig = ( stepId: string, updater: (step: SessionStepRecord) => SessionStepRecord ) => { const changed = commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; let didChange = false; const nextSteps = doc.steps.map((step) => { if (step.id !== stepId) return step; const next = normalizeStepRecord(updater(step)); if (next !== step) didChange = true; return next; }); if (!didChange) return current; return { ...current, stepsDocument: { ...doc, steps: nextSteps, }, }; }); if (changed) { trackEditorEdit("update_step_config", stepId); } }; const applyHotspotStylePreset = ( scope: "step" | "session", target: "hotspot" | "callout", patch: { backgroundColor?: string; textColor?: string } ) => { commitEditorChange((current) => { const scopedHotspotIds = scope === "session" ? null : new Set( current.stepsDocument?.steps.find( (step) => step.id === current.selectedStepId )?.hotspot_ids ?? [] ); let didChange = false; const nextHotspots = current.hotspots.map((hotspot) => { if (scopedHotspotIds && !scopedHotspotIds.has(hotspot.id)) { return hotspot; } const isCallout = hotspot.style === "callout"; if (target === "callout" && !isCallout) return hotspot; if (target === "hotspot" && isCallout) return hotspot; const nextBackground = patch.backgroundColor ?? hotspot.backgroundColor; const nextTextColor = patch.textColor ?? hotspot.textColor; if ( nextBackground === hotspot.backgroundColor && nextTextColor === hotspot.textColor ) { return hotspot; } didChange = true; return { ...hotspot, backgroundColor: nextBackground, textColor: nextTextColor, }; }); if (!didChange) return current; return { ...current, hotspots: nextHotspots, }; }); }; const moveStep = (stepId: string, direction: -1 | 1) => { const changed = commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; const index = doc.steps.findIndex((step) => step.id === stepId); if (index < 0) return current; const swapIndex = index + direction; if (swapIndex < 0 || swapIndex >= doc.steps.length) return current; const reordered = [...doc.steps]; const [step] = reordered.splice(index, 1); reordered.splice(swapIndex, 0, step); return { ...current, stepsDocument: { ...doc, steps: reordered, }, }; }); if (changed) { trackEditorEdit(direction === -1 ? "move_step_up" : "move_step_down", stepId); } }; const duplicateStep = (stepId: string) => { const duplicateId = createStepId(); let didDuplicate = false; commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; const index = doc.steps.findIndex((step) => step.id === stepId); if (index < 0) return current; const source = doc.steps[index]; const duplicatedStep: SessionStepRecord = { ...source, id: duplicateId, title: source.title.endsWith(" (Copy)") ? source.title : `${source.title} (Copy)`, }; didDuplicate = true; const nextSteps = [...doc.steps]; nextSteps.splice(index + 1, 0, duplicatedStep); return { ...current, stepsDocument: { ...doc, steps: nextSteps, }, selectedStepId: duplicateId, }; }); if (didDuplicate) setWalkthroughMode(false); if (didDuplicate) { trackEditorEdit("duplicate_step", stepId); } }; const deleteStep = (stepId: string) => { let deleted = false; commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; const index = doc.steps.findIndex((step) => step.id === stepId); if (index < 0) return current; const deletedStep = doc.steps[index]; const nextSteps = doc.steps.filter((step) => step.id !== stepId); let nextSelectedStepId = current.selectedStepId; if (current.selectedStepId === stepId) { nextSelectedStepId = nextSteps[index]?.id ?? nextSteps[index - 1]?.id ?? null; } else if ( current.selectedStepId && !nextSteps.some((step) => step.id === current.selectedStepId) ) { nextSelectedStepId = nextSteps[0]?.id ?? null; } const stillLinkedHotspotIds = new Set( nextSteps.flatMap((step) => step.hotspot_ids ?? []) ); const orphanedHotspotIds = new Set(); for (const hotspotId of deletedStep.hotspot_ids ?? []) { if (!stillLinkedHotspotIds.has(hotspotId)) { orphanedHotspotIds.add(hotspotId); } } const nextHotspots = orphanedHotspotIds.size > 0 ? current.hotspots.filter((hotspot) => !orphanedHotspotIds.has(hotspot.id)) : current.hotspots; deleted = true; return { ...current, stepsDocument: { ...doc, steps: nextSteps, }, hotspots: nextHotspots, selectedStepId: nextSelectedStepId, selectedHotspotId: current.selectedHotspotId && orphanedHotspotIds.has(current.selectedHotspotId) ? null : current.selectedHotspotId, }; }); if (deleted) setWalkthroughMode(false); if (deleted) { trackEditorEdit("delete_step", stepId); } }; const splitSelectedStepAtCurrentFrame = () => { const stepIdToSplit = selectedStepIdRef.current; if (!stepIdToSplit) return; const splitMs = Math.round(currentTime * 1000); const changed = commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; const index = doc.steps.findIndex((step) => step.id === stepIdToSplit); if (index < 0) return current; const target = doc.steps[index]; if (splitMs <= target.start_ms || splitMs >= target.end_ms) { return current; } const first: SessionStepRecord = { ...target, end_ms: splitMs, title: `${target.title} (Part 1)`, }; const second: SessionStepRecord = { ...target, id: `step_${Date.now()}`, start_ms: splitMs, title: `${target.title} (Part 2)`, }; const nextSteps = [...doc.steps]; nextSteps.splice(index, 1, first, second); return { ...current, stepsDocument: { ...doc, steps: nextSteps, }, }; }); if (changed) { trackEditorEdit("split_step", stepIdToSplit); } }; const mergeSelectedStepWithNext = () => { const stepIdToMerge = selectedStepIdRef.current; if (!stepIdToMerge) return; const changed = commitEditorChange((current) => { const doc = current.stepsDocument; if (!doc) return current; const index = doc.steps.findIndex((step) => step.id === stepIdToMerge); if (index < 0 || index >= doc.steps.length - 1) return current; const base = doc.steps[index]; const next = doc.steps[index + 1]; const merged: SessionStepRecord = { ...base, end_ms: Math.max(base.end_ms, next.end_ms), body: [base.body, next.body].filter(Boolean).join("\n\n").trim(), hotspot_ids: Array.from( new Set([...(base.hotspot_ids ?? []), ...(next.hotspot_ids ?? [])]) ), title: base.title.replace(/\s+\(Part 1\)$/i, ""), }; const nextSteps = [...doc.steps]; nextSteps.splice(index, 2, merged); return { ...current, stepsDocument: { ...doc, steps: nextSteps, }, }; }); if (changed) { trackEditorEdit("merge_step", stepIdToMerge); } }; const handleStageClick = (position: { x: number; y: number }) => { if (!placingHotspot) return; const style = placingHotspotStyle === "rectangle" ? "pulse" : placingHotspotStyle; const newHotspot = createHotspot(currentFrame, position.x, position.y, style); handleAddHotspot(newHotspot, selectedStepId); }; const handleAddHotspot = (hotspot: Hotspot, stepId?: string | null) => { const normalized = normalizeHotspot(hotspot); commitEditorChange((current) => { const targetStepId = stepId ?? current.selectedStepId ?? current.stepsDocument?.steps[0]?.id; let linkedToStep = false; const nextDocument = current.stepsDocument ? { ...current.stepsDocument, steps: current.stepsDocument.steps.map((step) => { if (step.id !== targetStepId) return step; linkedToStep = true; return { ...step, hotspot_ids: Array.from( new Set([...(step.hotspot_ids ?? []), normalized.id]) ), required_hotspot_ids: Array.from( new Set([...(step.required_hotspot_ids ?? []), normalized.id]) ), }; }), } : current.stepsDocument; return { ...current, hotspots: [...current.hotspots, normalized], stepsDocument: nextDocument, selectedStepId: !current.selectedStepId && linkedToStep ? (targetStepId ?? null) : current.selectedStepId, selectedHotspotId: normalized.id, }; }); setEditingHotspots(true); setPlacingHotspot(false); }; const handleUpdateHotspot = (id: string, updater: (hotspot: Hotspot) => Hotspot) => { commitEditorChange((current) => { let changed = false; const nextHotspots = current.hotspots.map((item) => { if (item.id !== id) return item; const updated = updater(item); if (updated !== item) changed = true; return updated; }); if (!changed) return current; return { ...current, hotspots: nextHotspots, }; }); }; const handleDeleteHotspot = (id: string) => { commitEditorChange((current) => { if (!current.hotspots.some((item) => item.id === id)) return current; return { ...current, hotspots: current.hotspots.filter((item) => item.id !== id), stepsDocument: current.stepsDocument ? { ...current.stepsDocument, steps: current.stepsDocument.steps.map((step) => ({ ...step, hotspot_ids: (step.hotspot_ids ?? []).filter( (hotspotId) => hotspotId !== id ), required_hotspot_ids: (step.required_hotspot_ids ?? []).filter( (hotspotId) => hotspotId !== id ), })), } : current.stepsDocument, selectedHotspotId: current.selectedHotspotId === id ? null : current.selectedHotspotId, }; }); }; const startWalkthrough = async () => { if (!sessionId || walkthroughLoading) return; try { setWalkthroughLoading(true); let nextSteps = toWalkthroughSteps(steps); if (!nextSteps.length) { nextSteps = await getSessionSteps(sessionId); } if (!nextSteps.length) { alert("No walkthrough steps found."); return; } setWalkthroughSteps(nextSteps); setWalkthroughMode(true); setPlaying(true); } catch (err) { alert(`Failed to load walkthrough: ${err}`); } finally { setWalkthroughLoading(false); } }; const runExportPrivacyCheck = useCallback( async (kind: "markdown" | "mp4"): Promise => { if (!metadata) return true; if (pendingExportPrivacyOverride === kind) { setPendingExportPrivacyOverride(null); return true; } try { const scan = await scanSessionForSensitiveData(metadata); if (scan.highSeverityCount > 0) { setPendingExportPrivacyOverride(kind); setExportFeedback({ status: "error", kind, path: null, message: `Potential secrets detected (${scan.highSeverityCount}). Review/redact first, then run ${kind.toUpperCase()} export again to confirm override.`, }); return false; } return true; } catch (err) { setExportFeedback({ status: "error", kind, path: null, message: `Unable to run privacy check: ${errorMessage(err)}`, }); return false; } }, [metadata, pendingExportPrivacyOverride] ); const handleExportMarkdown = async () => { if (!sessionId || exporting) return; flushCoalescedEditorEdits(); const canExport = await runExportPrivacyCheck("markdown"); if (!canExport) return; try { setExporting(true); setExportFeedback(null); const outputPath = await exportMarkdown(sessionId); setLastExportPath(outputPath); setExportFeedback({ status: "success", kind: "markdown", path: outputPath, message: "Script exported.", }); trackWorkflowEvent("session_exported", { sessionId, metadata: { format: "markdown" }, }); revealInFileManager(outputPath).catch((err) => { console.warn("Failed to reveal markdown export:", err); }); } catch (err) { setExportFeedback({ status: "error", kind: "markdown", path: null, message: `Script export failed: ${errorMessage(err)}`, }); } finally { setExporting(false); } }; const handleExportMp4 = async (includeChapterCards: boolean) => { if (!sessionId || exporting || !metadata) return; flushCoalescedEditorEdits(); const canExport = await runExportPrivacyCheck("mp4"); if (!canExport) return; try { setExporting(true); setExportFeedback(null); const destination = buildSessionExportMp4Path(metadata); const outputPath = await exportMp4(sessionId, destination, { include_chapter_cards: includeChapterCards, }); setLastExportPath(outputPath); setExportFeedback({ status: "success", kind: "mp4", path: outputPath, message: "MP4 export complete.", }); trackWorkflowEvent("session_exported", { sessionId, metadata: { format: "mp4", includeChapterCards, }, }); revealInFileManager(outputPath).catch((err) => { console.warn("Failed to reveal MP4 export:", err); }); } catch (err) { setExportFeedback({ status: "error", kind: "mp4", path: null, message: `MP4 export failed: ${errorMessage(err)}`, }); } finally { setExporting(false); } }; const handleAnalyzeSession = async () => { if (!sessionId || isAnalyzing) return; try { setIsAnalyzing(true); const analyzed = await analyzeSession(sessionId); const mapped: SessionStepRecord[] = analyzed.map((step, idx) => ({ id: step.id || `step_${String(idx + 1).padStart(3, "0")}`, start_ms: step.timestamp_start_ms, end_ms: Math.max(step.timestamp_end_ms, step.timestamp_start_ms), title: step.title, body: step.description, tags: [], hotspot_ids: [], completion_mode: "none", required_hotspot_ids: [], autoplay: false, screenshot: null, })); const document = ensureHotspotLinks({ version: 1, steps: mapped }, hotspotsRef.current); commitEditorChange((current) => ({ ...current, stepsDocument: document, selectedStepId: mapped[0]?.id ?? null, })); trackEditorEdit("analyze_session_steps"); setWalkthroughSteps(toWalkthroughSteps(document.steps)); setWalkthroughMode(true); regenerateStepScreenshots(sessionId, document) .then((withScreenshots) => { const screenshotByStepId = new Map( withScreenshots.steps.map((step) => [step.id, step.screenshot]) ); setStepsDocument((current) => { if (!current) return withScreenshots; let changed = false; const nextSteps = current.steps.map((step) => { const nextScreenshot = screenshotByStepId.get(step.id); if (nextScreenshot === undefined) return step; const samePath = step.screenshot?.path === nextScreenshot?.path; const sameTimestamp = step.screenshot?.timestamp_ms === nextScreenshot?.timestamp_ms; if (samePath && sameTimestamp) return step; changed = true; return { ...step, screenshot: nextScreenshot ?? null }; }); if (!changed) return current; return { ...current, steps: nextSteps }; }); }) .catch((err) => { console.error("Failed to regenerate analyzed step screenshots:", err); }); alert(`Analysis complete! Found ${mapped.length} steps.`); } catch (err) { alert(`Analysis failed: ${err}`); } finally { setIsAnalyzing(false); } }; const handleTranscribe = async () => { if (!sessionId || transcribing) return; try { setTranscribing(true); setTranscriptError(null); const result = await transcribeSession(sessionId); setTranscript(result); } catch (err) { setTranscriptError(errorMessage(err)); } finally { setTranscribing(false); } }; const handleGenerateStepsFromActivity = () => { if (!sessionId) return; const durationFromMetadata = metadata?.duration_ms ?? 0; const durationFromVideo = Math.round(duration * 1000); const totalDurationMs = Math.max(durationFromMetadata, durationFromVideo); if (totalDurationMs <= 0) { setAutoStepsFeedback({ status: "error", message: "No recording duration found yet. Finish processing, then retry Auto Steps.", }); return; } const generated = generateStepsFromActivity(markers, clicks, totalDurationMs); const document = ensureHotspotLinks({ version: 1, steps: generated }, hotspotsRef.current); commitEditorChange((current) => ({ ...current, stepsDocument: document, selectedStepId: document.steps[0]?.id ?? null, })); trackEditorEdit("generate_steps_from_activity"); setWalkthroughSteps(toWalkthroughSteps(document.steps)); setWalkthroughMode(false); setAutoStepsFeedback({ status: "success", message: `Generated ${document.steps.length} steps from ${markers.length} timeline events and ${clicks.length} clicks.`, }); regenerateStepScreenshots(sessionId, document) .then((withScreenshots) => { const screenshotByStepId = new Map( withScreenshots.steps.map((step) => [step.id, step.screenshot]) ); setStepsDocument((current) => { if (!current) return withScreenshots; let changed = false; const nextSteps = current.steps.map((step) => { const nextScreenshot = screenshotByStepId.get(step.id); if (nextScreenshot === undefined) return step; const samePath = step.screenshot?.path === nextScreenshot?.path; const sameTimestamp = step.screenshot?.timestamp_ms === nextScreenshot?.timestamp_ms; if (samePath && sameTimestamp) return step; changed = true; return { ...step, screenshot: nextScreenshot ?? null }; }); if (!changed) return current; return { ...current, steps: nextSteps }; }); }) .catch((err) => { console.error("Failed to regenerate activity step screenshots:", err); }); console.info( `[Auto Steps] generated=${document.steps.length} markers=${markers.length} clicks=${clicks.length} durationMs=${totalDurationMs}` ); }; const handleMarkStep = async () => { if (!sessionId) return; const timestampMs = Math.round(currentTime * 1000); try { await logMarker(sessionId, "Manual Step", timestampMs); const marker: TimelineMarker = { id: `manual-marker-${Date.now()}`, timestamp_ms: timestampMs, label: "Manual Step", type: "marker", }; setMarkers((prev) => normalizeTimelineMarkers([...prev, marker])); } catch (err) { console.error("Failed to record manual step marker:", err); alert(`Failed to mark step: ${err}`); } }; const handleVideoLoadError = async () => { setPlaying(false); setVideoUrl((current) => { if (!current || !current.startsWith("blob:")) return current; if (!videoFileUrl || videoFileUrl === current) return current; return videoFileUrl; }); if (!sessionId) return; try { const loaded = await loadSession(sessionId); const refreshedSrc = await resolvePlayableVideoSource(loaded.metadata.recording_path); if (refreshedSrc) { setVideoFileUrl(refreshedSrc); setVideoUrl(refreshedSrc); } } catch (err) { console.error("Failed to recover video source after load error:", err); } }; return { sessionId, sharing, setSharing, editorMode, setEditorMode, metadata, videoRef, videoUrl, terminalUrl, playing, setPlaying, activeTab, setActiveTab, hasTerminal, fps, markers, allHotspots, hotspots, selectedHotspotId, setSelectedHotspotId: setSelectedHotspotIdAndSync, editingHotspots, setEditingHotspots, placingHotspot, setPlacingHotspot, placingHotspotStyle, setPlacingHotspotStyle, walkthroughMode, setWalkthroughMode, walkthroughSteps, walkthroughLoading, exporting, lastExportPath, exportFeedback, autoStepsFeedback, isProcessing, isAnalyzing, currentFrame, currentTime, duration, seekToFrame, seekToTimestamp, stepFrames, totalFrames, togglePlay, undo, redo, canUndo, canRedo, handleStageClick, handleAddHotspot, handleUpdateHotspot, handleDeleteHotspot, startWalkthrough, handleExportMarkdown, handleExportMp4, clearExportFeedback: () => setExportFeedback(null), clearAutoStepsFeedback: () => setAutoStepsFeedback(null), handleVideoLoadError, handleAnalyzeSession, handleGenerateStepsFromActivity, handleMarkStep, handleTranscribe, transcript, transcribing, transcriptError, clicks, showClickRipples, setShowClickRipples, steps, selectedStepId, selectedStep, handleSelectStep, handleRenameStep, updateStepConfig, moveStepUp: (stepId: string) => moveStep(stepId, -1), moveStepDown: (stepId: string) => moveStep(stepId, 1), duplicateStep, deleteStep, splitSelectedStepAtCurrentFrame, mergeSelectedStepWithNext, applyHotspotStylePreset, }; };