Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion src/lib/ai-edition/timeline/virtual-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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();
});
Expand Down Expand Up @@ -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"
Expand Down
56 changes: 33 additions & 23 deletions src/lib/ai-edition/timeline/virtual-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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));
Expand Down Expand Up @@ -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(
Expand Down
Loading