make loop cursor trim-window aware

This commit is contained in:
webadderall
2026-03-15 13:45:50 +11:00
parent 8eb1f99ab7
commit 852fdd39fb
2 changed files with 87 additions and 19 deletions
+18 -11
View File
@@ -53,7 +53,7 @@ import { getAssetPath } from "@/lib/assetPath";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
import { buildLoopedCursorTelemetry } from "./videoPlayback/cursorLoopTelemetry";
import { buildLoopedCursorTelemetry, getDisplayedTimelineWindowMs } from "./videoPlayback/cursorLoopTelemetry";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
const LOOP_CURSOR_END_WINDOW_MS = 670;
@@ -613,39 +613,46 @@ export default function VideoEditor() {
return normalizeCursorTelemetry(cursorTelemetry, totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER);
}, [cursorTelemetry, duration]);
const displayedTimelineWindow = useMemo(() => {
const totalMs = Math.max(0, Math.round(duration * 1000));
return getDisplayedTimelineWindowMs(totalMs, trimRegions);
}, [duration, trimRegions]);
const effectiveCursorTelemetry = useMemo(() => {
if (!loopCursor) {
return normalizedCursorTelemetry;
}
const totalMs = Math.max(0, Math.round(duration * 1000));
if (normalizedCursorTelemetry.length < 2 || totalMs <= 0) {
if (normalizedCursorTelemetry.length < 2 || displayedTimelineWindow.endMs <= displayedTimelineWindow.startMs) {
return normalizedCursorTelemetry;
}
return buildLoopedCursorTelemetry(normalizedCursorTelemetry, totalMs);
}, [loopCursor, normalizedCursorTelemetry, duration]);
return buildLoopedCursorTelemetry(
normalizedCursorTelemetry,
displayedTimelineWindow.endMs,
displayedTimelineWindow.startMs,
);
}, [loopCursor, normalizedCursorTelemetry, displayedTimelineWindow]);
const effectiveZoomRegions = useMemo(() => {
if (!loopCursor || zoomRegions.length === 0) {
return zoomRegions;
}
const totalMs = Math.max(0, Math.round(duration * 1000));
if (totalMs <= 0) {
if (displayedTimelineWindow.endMs <= displayedTimelineWindow.startMs) {
return zoomRegions;
}
const dominantAtStart = findDominantRegion(zoomRegions, 0, { connectZooms }).region;
const dominantAtStart = findDominantRegion(zoomRegions, displayedTimelineWindow.startMs, { connectZooms }).region;
if (!dominantAtStart) {
return zoomRegions;
}
const endWindowStartMs = Math.max(0, totalMs - LOOP_CURSOR_END_WINDOW_MS);
const endWindowStartMs = Math.max(displayedTimelineWindow.startMs, displayedTimelineWindow.endMs - LOOP_CURSOR_END_WINDOW_MS);
const loopEndRegion: ZoomRegion = {
id: `${dominantAtStart.id}__loop-end-sync`,
startMs: endWindowStartMs,
endMs: totalMs,
endMs: displayedTimelineWindow.endMs,
depth: dominantAtStart.depth,
focus: {
cx: dominantAtStart.focus.cx,
@@ -657,7 +664,7 @@ export default function VideoEditor() {
...zoomRegions.filter((region) => region.id !== loopEndRegion.id),
loopEndRegion,
];
}, [loopCursor, zoomRegions, duration, connectZooms]);
}, [loopCursor, zoomRegions, displayedTimelineWindow, connectZooms]);
useEffect(() => {
if (!videoPath || duration <= 0 || zoomRegions.length > 0 || normalizedCursorTelemetry.length < 2) {
@@ -1,4 +1,4 @@
import type { CursorTelemetryPoint } from '../types';
import type { CursorTelemetryPoint, TrimRegion } from '../types';
const LOOP_CURSOR_FREEZE_DURATION_MS = 670;
const LOOP_CURSOR_RETURN_STEPS = 20;
@@ -141,21 +141,30 @@ function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
export function buildLoopedCursorTelemetry(
samples: CursorTelemetryPoint[],
totalDurationMs: number,
timelineStartMs = 0,
): CursorTelemetryPoint[] {
if (!samples || samples.length === 0) {
return [];
}
const timelineEndMs = Math.max(0, Math.round(totalDurationMs));
if (timelineEndMs <= 0) {
const timelineStartClampedMs = clamp(Math.round(timelineStartMs), 0, timelineEndMs);
if (timelineEndMs <= timelineStartClampedMs) {
return samples;
}
const firstSample = samples[0];
const lastSample = samples[samples.length - 1];
const boundedSamples = samples.filter(
(sample) => sample.timeMs >= timelineStartClampedMs && sample.timeMs <= timelineEndMs,
);
if (boundedSamples.length === 0) {
return samples;
}
const firstSample = boundedSamples[0];
const lastSample = boundedSamples[boundedSamples.length - 1];
const maxFreezeWindowMs = timelineEndMs - firstSample.timeMs;
if (maxFreezeWindowMs <= 1) {
return samples;
return boundedSamples;
}
const freezeDurationMs = Math.min(LOOP_CURSOR_FREEZE_DURATION_MS, maxFreezeWindowMs);
@@ -163,10 +172,10 @@ export function buildLoopedCursorTelemetry(
const clampedSettleDurationMs = Math.min(LOOP_CURSOR_SETTLE_DURATION_MS, Math.max(0, freezeDurationMs - 1));
const returnMotionDurationMs = Math.max(1, freezeDurationMs - clampedSettleDurationMs);
const sourceStartMs = firstSample.timeMs;
const sourceEndMs = Math.max(sourceStartMs, findLastMovingSampleTime(samples));
const sourceEndMs = Math.max(sourceStartMs, findLastMovingSampleTime(boundedSamples));
const sourceDurationMs = Math.max(1, sourceEndMs - sourceStartMs);
const playbackWindowMs = Math.max(1, motionEndMs);
const startingCursorType = findFirstStableCursorType(samples);
const startingCursorType = findFirstStableCursorType(boundedSamples);
const loopedSamples: CursorTelemetryPoint[] = [
{
...firstSample,
@@ -176,7 +185,7 @@ export function buildLoopedCursorTelemetry(
},
];
for (const sample of samples) {
for (const sample of boundedSamples) {
const progress = clamp((sample.timeMs - sourceStartMs) / sourceDurationMs, 0, 1);
const mappedTimeMs = Math.round(playbackWindowMs * progress);
@@ -230,4 +239,56 @@ export function buildLoopedCursorTelemetry(
}
return loopedSamples;
}
export function getDisplayedTimelineEndMs(totalDurationMs: number, trimRegions: TrimRegion[]) {
return getDisplayedTimelineWindowMs(totalDurationMs, trimRegions).endMs;
}
export function getDisplayedTimelineWindowMs(totalDurationMs: number, trimRegions: TrimRegion[]) {
const durationMs = Math.max(0, Math.round(totalDurationMs));
if (durationMs <= 0) {
return { startMs: 0, endMs: 0 };
}
if (!trimRegions || trimRegions.length === 0) {
return { startMs: 0, endMs: durationMs };
}
const sortedTrimRegions = trimRegions
.map((region) => ({
startMs: clamp(Math.round(region.startMs), 0, durationMs),
endMs: clamp(Math.round(region.endMs), 0, durationMs),
}))
.filter((region) => region.endMs > region.startMs)
.sort((a, b) => a.startMs - b.startMs);
if (sortedTrimRegions.length === 0) {
return { startMs: 0, endMs: durationMs };
}
let cursorMs = 0;
let firstVisibleStartMs: number | null = null;
let lastVisibleEndMs = 0;
for (const trimRegion of sortedTrimRegions) {
if (trimRegion.startMs > cursorMs) {
if (firstVisibleStartMs === null) {
firstVisibleStartMs = cursorMs;
}
lastVisibleEndMs = trimRegion.startMs;
}
cursorMs = Math.max(cursorMs, trimRegion.endMs);
}
if (cursorMs < durationMs) {
if (firstVisibleStartMs === null) {
firstVisibleStartMs = cursorMs;
}
lastVisibleEndMs = durationMs;
}
const clampedEndMs = clamp(lastVisibleEndMs, 0, durationMs);
const clampedStartMs = clamp(firstVisibleStartMs ?? 0, 0, clampedEndMs);
return { startMs: clampedStartMs, endMs: clampedEndMs };
}