Skip to content
Merged
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
42 changes: 42 additions & 0 deletions src/lib/ai-edition/clickSound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
24 changes: 14 additions & 10 deletions src/lib/ai-edition/clickSound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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;
};
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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<ClickCue>();
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 —
Expand Down
Loading