{
if (builtClips.length === 0) {
throw new Error("The project's timeline has no visible clips to export");
}
+ // The scene carries the click hits, whose cues and staged samples are read out of caches a
+ // scene build cannot await — without this a cold CLI export builds its scene from empty
+ // caches and comes out silent. Clicks that will not load must not cost the export.
+ await prepareClickSound(axcutDocument).catch(() => undefined);
const sceneDesc = buildSceneDescription(axcutDocument);
// The webcam background effect is applied by the compositor from the scene, so the clip
diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx
index 195cb22bf..a336901d8 100644
--- a/src/components/ai-edition/ExportDialog.tsx
+++ b/src/components/ai-edition/ExportDialog.tsx
@@ -10,6 +10,7 @@ import { Download, FileVideo, FolderOpen, Loader2, Star } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
+import { prepareClickSound } from "@/lib/ai-edition/clickSound";
import {
collectEffectiveClipDims,
type Dims,
@@ -372,6 +373,13 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
// invites. Background/layout/webcam/cursor/effects come from the same scene
// as the live preview, so an export can no longer disagree with what the
// user previewed.
+ //
+ // The click sound's hits are read out of the take's sidecar into a cache the scene build
+ // reads synchronously, so the read happens here — before anything is composed. Clicks
+ // that will not load must not cost the user their export.
+ await prepareClickSound(document).catch(() => undefined);
+ // A close during that read has already run the cleanup, which had no job to cancel yet.
+ if (generation !== pickerGeneration.current) return;
{
const job: ActiveExport = { id: crypto.randomUUID(), cancelRequested: false };
activeExport.current = job;
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 73c1fcd83..17f335048 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -148,6 +148,7 @@ import {
import { ROUNDNESS_SLIDER_MAX_PX } from "@/native/paramUnits";
import { wallpaperAcceptsMotion } from "@/native/sceneDescription";
import { ASPECT_RATIO_PRESETS, type AspectRatio } from "@/utils/aspectRatioUtils";
+import { useClickSound } from "../../hooks/useClickSound";
import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport";
import { CaptionsPane } from "./CaptionsPane";
import { ColorField } from "./ColorField";
@@ -3705,6 +3706,7 @@ export function CursorPane() {
[settings.cursorTheme],
);
const recordedTypes = useRecordedCursorTypes();
+ const clickSound = useClickSound();
// Each kind the video shows, pictured by the first of its states it shows.
const recordedKinds = CURSOR_KIND_IDS.flatMap((kind) => {
const type = CURSOR_KINDS[kind].find((state) => recordedTypes?.has(state));
@@ -3947,6 +3949,48 @@ export function CursorPane() {
/>
) : null}
+ {/* The same clicks, heard. Offered only once the take is known to contain some. It is a
+ setting rather than a track on the timeline: the hits are placed by the same
+ projection the click zoom uses, so they follow every edit instead of going stale. */}
+ {settings.cursorShow && clickSound.hasClicks ? (
+ <>
+
+ {ts("cursor.clickSound")}
+ {
+ void set({ cursor: { clickSound: v } });
+ // Read the cues and stage the samples on the way in, so the first
+ // export asked for after this is not a race with a background read.
+ if (v) void clickSound.prepare();
+ }}
+ />
+
+ {/* How loud. Offered only once there is a sound to turn down, and bounded by the
+ same table the mixer reads, so the slider cannot ask for a level the export
+ would flatten. */}
+ {settings.cursor.clickSound ? (
+
+ setLive({ cursor: { clickSoundGainDb } })}
+ onCommit={() => void commit()}
+ />
+
+ ) : null}
+ >
+ ) : null}
);
}
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index c24d24191..4e4ec9e55 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -4,6 +4,12 @@ import {
DEFAULT_CROP_REGION,
MAX_NATIVE_PLAYBACK_RATE,
} from "@/components/video-editor/types";
+import {
+ clickHitBuffers,
+ playClickHits,
+ resetClickPlayhead,
+ takeCrossedClickHits,
+} from "@/lib/ai-edition/clickSound";
import {
collapseTracksToPills,
resolveFadeSecs,
@@ -1216,6 +1222,34 @@ export function VirtualPreview({
// where a reload has to put the playhead back if it cannot
// resolve one from the timeline.
lastGoodSourceTimeRef.current = sourceTime;
+ // The click sound, heard live. Read off the SOURCE clock — the one the cursor sprite
+ // above is sampled on — because a click is recorded in the take's own seconds: this
+ // clock races with the picture under a 2x region, so a hit fired where it passes a
+ // click lands on the click being drawn. Which clicks exist is the export's own
+ // placement, narrowed to the clip on screen, so a cut cannot sound here and go
+ // missing from the file — nor the other way round when a twin clip keeps that
+ // stretch. A jump wider than the seek threshold — a scrub, a clip swap — is read as
+ // one and fires nothing, and while stopped the anchor is dropped, so the next run
+ // does not repay a stack of old clicks.
+ // The cues are the primary take's, so they only belong over that take's picture.
+ if (v.paused) {
+ resetClickPlayhead();
+ } else {
+ const crossed = takeCrossedClickHits(
+ sourceTime,
+ videoSourcesRef.current[sourceIndexRef.current]?.filePath,
+ activeClipIdRef.current,
+ );
+ const clickGraph = audioGraphRef.current;
+ if (crossed.length && clickGraph) {
+ playClickHits(
+ clickGraph.context,
+ clickGraph.gain,
+ clickHitBuffers(clickGraph.context),
+ crossed,
+ );
+ }
+ }
}
// À L'ARRÊT, le `