refactor: move zoom suggestion logic into timeline util

This commit is contained in:
Yusuf Mohsinally
2026-02-28 00:03:33 -08:00
parent a2b9eea90a
commit 4ecd18086c
2 changed files with 78 additions and 50 deletions
@@ -20,15 +20,13 @@ import {
import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
const MIN_DWELL_DURATION_MS = 450;
const MAX_DWELL_DURATION_MS = 2600;
const DWELL_MOVE_THRESHOLD = 0.02;
const SUGGESTION_SPACING_MS = 1800;
interface TimelineEditorProps {
@@ -750,14 +748,7 @@ export default function TimelineEditor({
.map((region) => ({ start: region.startMs, end: region.endMs }))
.sort((a, b) => a.start - b.start);
const normalizedSamples = [...cursorTelemetry]
.filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy))
.sort((a, b) => a.timeMs - b.timeMs)
.map((sample) => ({
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
cx: Math.max(0, Math.min(sample.cx, 1)),
cy: Math.max(0, Math.min(sample.cy, 1)),
}));
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
if (normalizedSamples.length < 2) {
toast.info("No usable cursor telemetry", {
@@ -766,45 +757,7 @@ export default function TimelineEditor({
return;
}
const dwellCandidates: Array<{ centerTimeMs: number; focus: ZoomFocus; strength: number }> = [];
let runStart = 0;
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
if (endIndexExclusive - startIndex < 2) {
return;
}
const start = normalizedSamples[startIndex];
const end = normalizedSamples[endIndexExclusive - 1];
const runDuration = end.timeMs - start.timeMs;
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
return;
}
const runSamples = normalizedSamples.slice(startIndex, endIndexExclusive);
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
dwellCandidates.push({
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
focus: { cx: avgCx, cy: avgCy },
strength: runDuration,
});
};
for (let index = 1; index < normalizedSamples.length; index += 1) {
const prev = normalizedSamples[index - 1];
const curr = normalizedSamples[index];
const dx = curr.cx - prev.cx;
const dy = curr.cy - prev.cy;
const distance = Math.hypot(dx, dy);
if (distance > DWELL_MOVE_THRESHOLD) {
pushRunIfDwell(runStart, index);
runStart = index;
}
}
pushRunIfDwell(runStart, normalizedSamples.length);
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
if (dwellCandidates.length === 0) {
toast.info("No clear cursor dwell moments found", {
@@ -0,0 +1,75 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
export const MIN_DWELL_DURATION_MS = 450;
export const MAX_DWELL_DURATION_MS = 2600;
export const DWELL_MOVE_THRESHOLD = 0.02;
export interface ZoomDwellCandidate {
centerTimeMs: number;
focus: ZoomFocus;
strength: number;
}
function normalizeTelemetrySample(sample: CursorTelemetryPoint, totalMs: number): CursorTelemetryPoint {
return {
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
cx: Math.max(0, Math.min(sample.cx, 1)),
cy: Math.max(0, Math.min(sample.cy, 1)),
};
}
export function normalizeCursorTelemetry(
telemetry: CursorTelemetryPoint[],
totalMs: number,
): CursorTelemetryPoint[] {
return [...telemetry]
.filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy))
.sort((a, b) => a.timeMs - b.timeMs)
.map((sample) => normalizeTelemetrySample(sample, totalMs));
}
export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
if (samples.length < 2) {
return [];
}
const dwellCandidates: ZoomDwellCandidate[] = [];
let runStart = 0;
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
if (endIndexExclusive - startIndex < 2) {
return;
}
const start = samples[startIndex];
const end = samples[endIndexExclusive - 1];
const runDuration = end.timeMs - start.timeMs;
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
return;
}
const runSamples = samples.slice(startIndex, endIndexExclusive);
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
dwellCandidates.push({
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
focus: { cx: avgCx, cy: avgCy },
strength: runDuration,
});
};
for (let index = 1; index < samples.length; index += 1) {
const prev = samples[index - 1];
const curr = samples[index];
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
if (distance > DWELL_MOVE_THRESHOLD) {
pushRunIfDwell(runStart, index);
runStart = index;
}
}
pushRunIfDwell(runStart, samples.length);
return dwellCandidates;
}