diff --git a/src/lib/ai-edition/clickSound.test.ts b/src/lib/ai-edition/clickSound.test.ts index 2ab987154..0dffa2343 100644 --- a/src/lib/ai-edition/clickSound.test.ts +++ b/src/lib/ai-edition/clickSound.test.ts @@ -129,6 +129,25 @@ describe("placeClickHits", () => { it("drops a click the cut took out of the film and pulls the later ones up", () => { expect(at(doc([clip()], [], [cut(2, 2)]), [cue(1), cue(3), cue(5)])).toEqual([1, 3]); }); + + it("plays a click once for every clip of the take that keeps it", () => { + // The take, then a copy of its first 4 s pasted after it, each with its own 2x region over + // 1..2 s of the source. Each clip is half a second shorter for its region, so the copy starts + // at 9.5 s of the programme: its click at 3 s sits at 9.5 + 1 + 0.5 + 1. The click at 5 s is + // past the copy's end and sounds once, in the first clip only. + const document = doc( + [clip(), clip({ id: "clip_2", timelineStartSec: 10, timelineEndSec: 14, sourceEndSec: 4 })], + [ + { startMs: 1000, endMs: 2000, speed: 2 }, + { startMs: 11000, endMs: 12000, speed: 2 }, + ], + ); + expect(at(document, [cue(3), cue(5)])).toEqual([2.5, 4.5, 12]); + expect(placedClickHits(document, asset as never, [cue(3)]).map((p) => p.clipId)).toEqual([ + "clip_1", + "clip_2", + ]); + }); }); describe("placedClickHits", () => { @@ -204,6 +223,29 @@ describe("takeCrossedClickHits with a mounted clip", () => { getTelemetry.mockRestore(); } }); + + it("fires a click over each clip that keeps it, and once when the clip is unresolved", async () => { + const getTelemetry = vi + .spyOn(nativeBridgeClient.cursor, "getTelemetry") + .mockResolvedValue([point(2050, "click")]); + try { + await loadClickCues(asset.originalPath); + // No cut: both copies keep the click. + clickSoundForDocument(doc([clip(), clip({ id: "clip_2", timelineStartSec: 10 })]), { + clickSound: true, + clickSoundGainDb: 0, + }); + for (const clipId of ["clip_1", "clip_2", undefined]) { + resetClickPlayhead(); + takeCrossedClickHits(2, asset.originalPath, clipId); + expect( + takeCrossedClickHits(2.06, asset.originalPath, clipId).map((c) => c.timeSec), + ).toEqual([2.05]); + } + } finally { + getTelemetry.mockRestore(); + } + }); }); describe("levelClickCues", () => { diff --git a/src/lib/ai-edition/clickSound.ts b/src/lib/ai-edition/clickSound.ts index 34fbbd3f8..369da2530 100644 --- a/src/lib/ai-edition/clickSound.ts +++ b/src/lib/ai-edition/clickSound.ts @@ -230,8 +230,9 @@ export function clickSoundForDocument( } /** - * Where this edit plays a click recorded at `cue.timeSec` of the take: the clip that shows it and - * the PROGRAMME second the compositor mixes onto, or null when nothing is on screen there. Three + * Where this edit plays a click recorded at `cue.timeSec` of the take: every clip that shows it, + * each with the PROGRAMME second the compositor mixes onto, and none when nothing is on screen + * there. A take pasted twice plays its click twice, once per copy that keeps it. Three * clocks are in play, so there are three steps: a click is recorded in the take's own source * seconds, the timeline plays a clip's source window at that clip's position (a take trimmed at the * head, or repeated across clips, is not at ruler zero), and only then do trims and speed regions @@ -249,7 +250,7 @@ export function clickSoundForDocument( function clickPlacement( document: AxcutDocument, take: AxcutAsset, -): (cue: ClickCue) => { clipId: string; sec: number } | null { +): (cue: ClickCue) => Array<{ clipId: string; sec: number }> { const clips = document.timeline.clips; const trims = document.timeline.trimRanges; const speeds = readSpeedRegions(document); @@ -263,16 +264,17 @@ function clickPlacement( at: clip.timelineStartSec, })); return (cue) => { + const placements: Array<{ clipId: string; sec: number }> = []; for (const window of windows) { if (cue.timeSec < window.from || cue.timeSec >= window.to) continue; const rawSec = window.at + (cue.timeSec - window.from); if (removed.some((span) => rawSec >= span.startSec && rawSec < span.endSec)) continue; - return { + placements.push({ clipId: window.id, sec: projectRawTimelineSecToPlayback(clips, trims, rawSec, speeds), - }; + }); } - return null; + return placements; }; } @@ -285,8 +287,7 @@ export function placedClickHits( const place = clickPlacement(document, take); const placed: Array<{ clipId: string; sec: number; cue: ClickCue }> = []; for (const cue of cues) { - const at = place(cue); - if (at) placed.push({ ...at, cue }); + for (const at of place(cue)) placed.push({ ...at, cue }); } return placed.sort((a, b) => a.sec - b.sec); } @@ -346,14 +347,17 @@ function stashPreviewCues( ): void { previewEdit = { document, take }; previewTake = take.originalPath; - previewCues = []; previewByClip.clear(); + // A cue two clips keep is one recorded click: the clip-less list, crossed on the source clock, + // holds it once so a crossing fires it once. + const kept = new Set(); for (const placed of placedClickHits(document, take, leveledCues)) { - previewCues.push(placed.cue); + kept.add(placed.cue); const bucket = previewByClip.get(placed.clipId) ?? []; bucket.push(placed.cue); previewByClip.set(placed.clipId, bucket); } + previewCues = [...kept].sort((x, y) => x.timeSec - y.timeSec); } /** The hits the playhead passed since the last call, measured in the take's own source seconds —