diff --git a/src/lib/ai-edition/timeline/virtual-preview.test.ts b/src/lib/ai-edition/timeline/virtual-preview.test.ts index f3dd64888..587d6dc5d 100644 --- a/src/lib/ai-edition/timeline/virtual-preview.test.ts +++ b/src/lib/ai-edition/timeline/virtual-preview.test.ts @@ -39,8 +39,9 @@ const clips: AxcutClip[] = [ ]; describe("virtual-preview pure functions", () => { - it("totalVirtualDuration returns the last clip's timelineEndSec", () => { + it("totalVirtualDuration returns the greatest timelineEndSec", () => { expect(totalVirtualDuration(clips)).toBe(20); + expect(totalVirtualDuration([...clips].reverse())).toBe(20); expect(totalVirtualDuration([])).toBe(0); }); @@ -57,6 +58,14 @@ describe("virtual-preview pure functions", () => { expect(pos?.sourceTimeSec).toBe(22); }); + it("locateVirtualPosition does not depend on clip array order", () => { + const reversed = [...clips].reverse(); + const pos = locateVirtualPosition(reversed, 12); + expect(pos?.clip.id).toBe("clip_2"); + expect(pos?.clipIndex).toBe(0); + expect(pos?.sourceTimeSec).toBe(22); + }); + it("locateVirtualPosition returns null for empty clips", () => { expect(locateVirtualPosition([], 0)).toBeNull(); }); @@ -428,6 +437,49 @@ describe("virtual-preview pure functions", () => { expect(getRawVirtualStartTime(nextSeg!, rawClips)).toBe(10.7); }); + it("findNextKeptSegment chooses the earliest next segment when playback clips are unordered", () => { + const rawClips: AxcutClip[] = [ + { ...clips[0] }, + { ...clips[1] }, + { + ...clips[1], + id: "clip_3", + sourceStartSec: 40, + sourceEndSec: 50, + timelineStartSec: 20, + timelineEndSec: 30, + }, + ]; + const playbackClips = [rawClips[2], rawClips[1], rawClips[0]]; + + const next = findNextKeptSegment(playbackClips, rawClips, 5); + + expect(next?.id).toBe("clip_2"); + expect(getRawVirtualStartTime(next!, rawClips)).toBe(10); + }); + + it("findNextKeptSegment breaks tied raw starts by playback order, not input order", () => { + const earlier: AxcutClip = { + ...clips[1], + id: "clip_2_seg1", + timelineStartSec: 10, + timelineEndSec: 12, + }; + const later: AxcutClip = { + ...clips[1], + id: "clip_2_seg2", + timelineStartSec: 12, + timelineEndSec: 14, + }; + + for (const playbackClips of [ + [earlier, later], + [later, earlier], + ]) { + expect(findNextKeptSegment(playbackClips, clips, 5)?.id).toBe("clip_2_seg1"); + } + }); + describe("findNextKeptSegment never goes backwards", () => { // A slice from LATE in the recording laid down first, then a slice from early in // it, cut at source 5–10. Both draw on the same asset, so "later in source time" diff --git a/src/lib/ai-edition/timeline/virtual-preview.ts b/src/lib/ai-edition/timeline/virtual-preview.ts index 1cdc5d2f6..d094eef2c 100644 --- a/src/lib/ai-edition/timeline/virtual-preview.ts +++ b/src/lib/ai-edition/timeline/virtual-preview.ts @@ -11,7 +11,7 @@ export type VirtualPosition = { }; export function totalVirtualDuration(clips: AxcutClip[]): number { - return clips.at(-1)?.timelineEndSec ?? 0; + return clips.reduce((duration, clip) => Math.max(duration, clip.timelineEndSec), 0); } export function clampVirtualTime(clips: AxcutClip[], value: number): number { @@ -25,11 +25,18 @@ export function locateVirtualPosition( ): VirtualPosition | null { if (clips.length === 0) return null; const clamped = clampVirtualTime(clips, virtualTimeSec); - const clipIndex = clips.findIndex((clip, index) => { - const isLast = index === clips.length - 1; - return clamped >= clip.timelineStartSec && (clamped < clip.timelineEndSec || isLast); - }); - const resolvedIndex = clipIndex >= 0 ? clipIndex : clips.length - 1; + const ordered = clips + .map((clip, clipIndex) => ({ clip, clipIndex })) + .sort( + (a, b) => + a.clip.timelineStartSec - b.clip.timelineStartSec || + a.clip.timelineEndSec - b.clip.timelineEndSec || + a.clip.id.localeCompare(b.clip.id), + ); + const resolved = + ordered.find(({ clip }) => clamped >= clip.timelineStartSec && clamped < clip.timelineEndSec) ?? + ordered.at(-1)!; + const resolvedIndex = resolved.clipIndex; const clip = clips[resolvedIndex]; const clipDuration = (clip.sourceEndSec ?? 0) - clip.sourceStartSec; const clipOffset = Math.max(0, Math.min(clipDuration, clamped - clip.timelineStartSec)); @@ -103,23 +110,26 @@ export function findNextKeptSegment( currentSourceTime?: number, activeClipId?: string, ): AxcutClip | undefined { - for (const seg of playbackClips) { - const segRawStart = getRawVirtualStartTime(seg, rawClips); - if (segRawStart > currentRawTime + 0.001) { - return seg; - } - if ( - activeSourceId && - activeClipId && - currentSourceTime !== undefined && - seg.assetId === activeSourceId && - findRawClipForSegment(seg, rawClips)?.id === activeClipId && - seg.sourceStartSec > currentSourceTime + 0.001 - ) { - return seg; - } - } - return undefined; + const nextByRawTime = playbackClips + .map((segment) => ({ segment, rawStart: getRawVirtualStartTime(segment, rawClips) })) + .filter(({ rawStart }) => rawStart > currentRawTime + 0.001) + .sort( + (a, b) => + a.rawStart - b.rawStart || + a.segment.timelineStartSec - b.segment.timelineStartSec || + a.segment.id.localeCompare(b.segment.id), + )[0]?.segment; + if (nextByRawTime) return nextByRawTime; + + if (!activeSourceId || !activeClipId || currentSourceTime === undefined) return undefined; + return playbackClips + .filter( + (segment) => + segment.assetId === activeSourceId && + findRawClipForSegment(segment, rawClips)?.id === activeClipId && + segment.sourceStartSec > currentSourceTime + 0.001, + ) + .sort((a, b) => a.sourceStartSec - b.sourceStartSec)[0]; } function toPositionAt(