From cf441e79e95495728ee161286106f7b87e444df6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 12 Oct 2026 19:59:40 +0200 Subject: [PATCH] fix(cursor): play click sounds for every clip of a recording clickPlacement returned on the first clip whose kept source window held the click, so a pasted copy of a take got no clicks in the export or the preview. It now places the click once per clip that keeps it, each through that clip's trims and speed regions. The preview's clip-less fallback list keeps each cue once. Fixes #1146 --- src/lib/ai-edition/clickSound.test.ts | 42 +++++++++++++++++++++++++++ src/lib/ai-edition/clickSound.ts | 24 ++++++++------- 2 files changed, 56 insertions(+), 10 deletions(-) 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 —