diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 02f4a360..144913c4 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -24,7 +24,7 @@ import KeyframeMarkers from "./KeyframeMarkers"; import type { Range, Span } from "dnd-timeline"; import type { ZoomRegion, TrimRegion, ClipRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus } from "../types"; import { toFileUrl } from "../projectPersistence"; -import { detectInteractionCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; +import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils"; import { useAudioPeaks, type AudioPeaksData } from "./useAudioPeaks"; import AudioWaveform from "./AudioWaveform"; @@ -34,9 +34,6 @@ const ANNOTATION_ROW_ID = "row-annotation"; const AUDIO_ROW_ID = "row-audio"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; -const SUGGESTION_SPACING_MS = 1800; -const MERGE_NEARBY_GAP_MS = 1500; - interface TimelineEditorProps { videoDuration: number; currentTime: number; @@ -1072,81 +1069,41 @@ export default function TimelineEditor({ return; } - const reservedSpans = [...zoomRegions] - .map((region) => ({ start: region.startMs, end: region.endMs })) - .sort((a, b) => a.start - b.start); + const result = buildInteractionZoomSuggestions({ + cursorTelemetry, + totalMs, + defaultDurationMs: defaultDuration, + reservedSpans: zoomRegions + .map((region) => ({ start: region.startMs, end: region.endMs })) + .sort((a, b) => a.start - b.start), + }); - const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); - - if (normalizedSamples.length < 2) { + if (result.status === 'no-telemetry') { toast.info("No usable cursor telemetry", { description: "The recording does not include enough cursor movement data.", }); return; } - const dwellCandidates = detectInteractionCandidates(normalizedSamples); - - if (dwellCandidates.length === 0) { + if (result.status === 'no-interactions') { toast.info("No clear interaction moments found", { description: "Try a recording with pauses or clicks around important actions.", }); return; } - const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength); - const acceptedCenters: number[] = []; - const accepted: { start: number; end: number; focus: ZoomFocus }[] = []; - - sortedCandidates.forEach((candidate) => { - const tooCloseToAccepted = acceptedCenters.some( - (center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS, - ); - - if (tooCloseToAccepted) { - return; - } - - const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2); - const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration)); - const candidateEnd = candidateStart + defaultDuration; - const hasOverlap = reservedSpans.some( - (span) => candidateEnd > span.start && candidateStart < span.end, - ); - - if (hasOverlap) { - return; - } - - reservedSpans.push({ start: candidateStart, end: candidateEnd }); - acceptedCenters.push(candidate.centerTimeMs); - accepted.push({ start: candidateStart, end: candidateEnd, focus: candidate.focus }); - }); - - // Merge nearby accepted regions (gap ≤ MERGE_NEARBY_GAP_MS) into single spans - const sorted = [...accepted].sort((a, b) => a.start - b.start); - const merged: typeof sorted = []; - for (const region of sorted) { - const prev = merged[merged.length - 1]; - if (prev && region.start - prev.end <= MERGE_NEARBY_GAP_MS) { - prev.end = Math.max(prev.end, region.end); - } else { - merged.push({ ...region }); - } - } - - if (merged.length === 0) { + if (result.status === 'no-slots' || result.suggestions.length === 0) { toast.info("No auto-zoom slots available", { description: "Detected dwell points overlap existing zoom regions.", }); return; } - for (const region of merged) { + for (const region of result.suggestions) { onZoomSuggested({ start: region.start, end: region.end }, region.focus); } - toast.success(`Added ${merged.length} interaction-based zoom suggestion${merged.length === 1 ? "" : "s"}`); + toast.success(`Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`); }, [ videoDuration, totalMs, diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts index 02dada5c..1c38a32d 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -14,6 +14,26 @@ export interface CursorInteractionCandidate extends ZoomDwellCandidate { kind: 'dwell' | 'click-like' | 'double-click-like' | 'text-focus-like' | 'dropdown-open' | 'text-selection' | 'text-field-click'; } +export interface SuggestedZoomRegion { + start: number; + end: number; + focus: ZoomFocus; +} + +export type InteractionZoomSuggestionStatus = + | 'ok' + | 'no-telemetry' + | 'no-interactions' + | 'no-slots'; + +export interface InteractionZoomSuggestionResult { + status: InteractionZoomSuggestionStatus; + suggestions: SuggestedZoomRegion[]; +} + +const DEFAULT_SUGGESTION_SPACING_MS = 1800; +const DEFAULT_MERGE_NEARBY_GAP_MS = 1500; + function normalizeTelemetrySample(sample: CursorTelemetryPoint, totalMs: number): CursorTelemetryPoint { return { timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)), @@ -188,6 +208,91 @@ export function detectInteractionCandidates(samples: CursorTelemetryPoint[]): Cu return [...explicitInteractionCandidates, ...dwellCandidates, ...doubleClickCandidates]; } +export function buildInteractionZoomSuggestions(params: { + cursorTelemetry: CursorTelemetryPoint[]; + totalMs: number; + defaultDurationMs: number; + reservedSpans?: Array<{ start: number; end: number }>; + spacingMs?: number; + mergeGapMs?: number; +}): InteractionZoomSuggestionResult { + const { + cursorTelemetry, + totalMs, + defaultDurationMs, + reservedSpans = [], + spacingMs = DEFAULT_SUGGESTION_SPACING_MS, + mergeGapMs = DEFAULT_MERGE_NEARBY_GAP_MS, + } = params; + + const defaultDuration = Math.min(defaultDurationMs, totalMs); + if (defaultDuration <= 0) { + return { status: 'no-slots', suggestions: [] }; + } + + const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); + if (normalizedSamples.length < 2) { + return { status: 'no-telemetry', suggestions: [] }; + } + + const interactionCandidates = detectInteractionCandidates(normalizedSamples); + if (interactionCandidates.length === 0) { + return { status: 'no-interactions', suggestions: [] }; + } + + const sortedCandidates = [...interactionCandidates].sort((a, b) => b.strength - a.strength); + const acceptedCenters: number[] = []; + const accepted: SuggestedZoomRegion[] = []; + const reserved = [...reservedSpans].sort((a, b) => a.start - b.start); + + sortedCandidates.forEach((candidate) => { + const tooCloseToAccepted = acceptedCenters.some( + (center) => Math.abs(center - candidate.centerTimeMs) < spacingMs, + ); + + if (tooCloseToAccepted) { + return; + } + + const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2); + const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration)); + const candidateEnd = candidateStart + defaultDuration; + const hasOverlap = reserved.some( + (span) => candidateEnd > span.start && candidateStart < span.end, + ); + + if (hasOverlap) { + return; + } + + reserved.push({ start: candidateStart, end: candidateEnd }); + acceptedCenters.push(candidate.centerTimeMs); + accepted.push({ + start: candidateStart, + end: candidateEnd, + focus: candidate.focus, + }); + }); + + const sortedAccepted = [...accepted].sort((a, b) => a.start - b.start); + const merged: SuggestedZoomRegion[] = []; + for (const region of sortedAccepted) { + const previous = merged[merged.length - 1]; + if (previous && region.start - previous.end <= mergeGapMs) { + previous.end = Math.max(previous.end, region.end); + continue; + } + + merged.push({ ...region }); + } + + if (merged.length === 0) { + return { status: 'no-slots', suggestions: [] }; + } + + return { status: 'ok', suggestions: merged }; +} + /** * Analyzes cursor movement after a click to classify the interaction pattern. *