mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
refactor(timeline): extract zoom suggestion logic into standalone utility
- Move zoom suggestion computation out of TimelineEditor into zoomSuggestionUtils - Simplify TimelineEditor by delegating zoom calculations
This commit is contained in:
1 parent
6af018ec01
commit
072ca8a014
2 files changed
+119
-57
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user