diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 1b51a7a9..db9b46e1 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -4,6 +4,7 @@ use crate::ffi::*; use crate::regions::SpeedSegment; +use crate::scene::SceneAudio; use anyhow::{bail, Result}; use std::f32::consts::PI; use std::ffi::CString; @@ -26,6 +27,44 @@ const PASSTHROUGH_EPSILON: f64 = 1e-3; pub type PlanarPcm = Vec>; +/// Apply the editor's output trim to the assembled timeline. +/// +/// One stage, and keeping it that way takes some resisting. This runs on the assembled +/// timeline — trimmed, speed-adjusted, concatenated — while the editor preview plays the +/// untouched SOURCE file, seeked. A linear gain is the only operation that means the same +/// thing on both, which is what lets the editor claim that what you hear is what you export. +/// +/// Three things that look like they belong here and do not: +/// - a filter or a compressor, which carries state across cuts here and not in the preview; +/// - a loudness normaliser, whose makeup is a single scalar measured over the whole assembled +/// programme — the preview never holds that programme, and the value moves with every trim; +/// - a sync offset, which shipped here once. It is expressed in TIMELINE seconds at this +/// point in the pipeline, but the preview would apply it in SOURCE seconds, so a 2x speed +/// region halved it; and because the shift is uniform over the assembled programme, near a +/// cut the export pulls audio across the junction while the preview only has the active +/// asset loaded. +/// +/// Any of them means either rendering the export's audio assembly preview-side, or accepting +/// and documenting a divergence — not a quiet extra stage in this function. +/// +/// The bound mirrors `AUDIO_GAIN_DB_LIMIT` in editorSettings.ts. The result stays the same +/// length so video and following clips cannot drift. +pub fn finish_audio(mut pcm: PlanarPcm, settings: SceneAudio) -> PlanarPcm { + let samples = pcm.first().map(Vec::len).unwrap_or(0); + if samples == 0 { + return pcm; + } + for channel in pcm.iter_mut() { + channel.resize(samples, 0.0); + } + + let trim = 10.0f32.powf(settings.gain_db.clamp(-12.0, 12.0) / 20.0); + for sample in pcm.iter_mut().flatten() { + *sample = (*sample * trim).clamp(-1.0, 1.0); + } + pcm +} + extern "C" { fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; // bindgen rend `AVFormatContext` opaque (atteinte seulement par pointeur), d'où l'accesseur @@ -1063,4 +1102,48 @@ mod tests { let mixed = mix_aligned_tracks(&[(-0.001, &early)], 0.0, 8); assert_eq!(mixed[0], vec![0.5; 8]); } + + /// The gain must be the SAME scalar the editor preview feeds its GainNode + /// (`10 ** (dB / 20)`), because that identity is the whole parity guarantee: nothing + /// else stands between what the editor plays and what this writes. + #[test] + fn output_trim_is_the_same_scalar_the_preview_applies() { + for gain_db in [-12.0f32, -6.0206, 0.0, 6.0206, 12.0] { + let result = finish_audio( + planar(&[0.25, -0.25]), + SceneAudio { gain_db }, + ); + let expected = (0.25 * 10.0f32.powf(gain_db / 20.0)).clamp(-1.0, 1.0); + assert!( + (result[0][0] - expected).abs() < 1e-6, + "gain {gain_db} dB: got {}, want {expected}", + result[0][0] + ); + assert!((result[0][1] + expected).abs() < 1e-6); + } + } + + #[test] + fn out_of_range_gain_is_clamped_to_the_editor_bound() { + // A hand-edited project, the AI edition agent, or a future UI change must not be + // able to ask for a gain the slider cannot display. + let quiet = finish_audio(planar(&[0.5]), SceneAudio { gain_db: -99.0 }); + let floor = 0.5 * 10.0f32.powf(-12.0 / 20.0); + assert!((quiet[0][0] - floor).abs() < 1e-6); + + let loud = finish_audio(planar(&[0.1]), SceneAudio { gain_db: 99.0 }); + let ceiling = 0.1 * 10.0f32.powf(12.0 / 20.0); + assert!((loud[0][0] - ceiling).abs() < 1e-6); + } + + #[test] + fn output_is_clipped_to_full_scale_and_keeps_its_length() { + // The trim can push a hot signal past full scale; the timeline must come back the + // same length either way, or video and the following clips drift against it. + let result = finish_audio(planar(&[0.9, -0.9, 0.1]), SceneAudio { gain_db: 12.0 }); + assert_eq!(result[0].len(), 3); + assert_eq!(result[0][0], 1.0); + assert_eq!(result[0][1], -1.0); + assert!((result[0][2] - 0.1 * 10.0f32.powf(12.0 / 20.0)).abs() < 1e-6); + } } diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index ea9849d0..47d1adc4 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -1230,9 +1230,12 @@ impl Compositor { // `cover_crop_uv` est la primitive partagee que macOS et Windows // utilisent ; elle rend le rect inchange quand il a deja le bon // ratio, donc aucun placement correct ne bouge. - let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv( + let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect( [wcw, wch], [wtw as f32, wth as f32], + scene_ref + .as_ref() + .and_then(|scene| scene.layout.webcam_crop), g.w_px[0] / g.w_px[1].max(0.0001), ); // MIROIR : on inverse l'intervalle u. Le VS interpole `src` diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 352fcb57..2430e348 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1601,9 +1601,10 @@ impl Compositor { // --- caméra : ombre PiP puis vidéo --- let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; if let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) { - let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv( + let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect( [wcw, wch], [wtw as f32, wth as f32], + scene_ref.as_ref().and_then(|scene| scene.layout.webcam_crop), g.w_px[0] / g.w_px[1].max(0.0001), ); let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) }; diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 95d40af4..26e8e902 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1517,9 +1517,12 @@ impl Compositor { // // Le center-crop carré de square/circle en est un cas particulier (boîte 1:1) — il n'a // plus besoin d'être traité à part. - let (su0, sv0, su1, sv1) = cover_crop_uv( + let [su0, sv0, su1, sv1] = crate::frame_geometry::webcam_source_rect( [wcw, wch], [wtw as f32, wth as f32], + scene_ref + .as_ref() + .and_then(|scene| scene.layout.webcam_crop), w_px[0] / w_px[1].max(0.0001), ); // miroir = échanger les bornes u du rect source (flip horizontal). diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 902ba1d8..655b746b 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -284,6 +284,23 @@ pub(crate) fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f let [u0, v0, u1, v1] = cover_uv_rect(full, tex, box_ar); (u0, v0, u1, v1) } + +/// Camera equivalent of the screen crop pipeline: apply the user crop first, then a centred +/// cover-crop inside that authored window so arbitrary layout slots never stretch the image. +pub(crate) fn webcam_source_rect( + visible: [f32; 2], + tex: [f32; 2], + crop: Option, + box_ar: f32, +) -> [f32; 4] { + let u_max = visible[0].max(1.0) / tex[0].max(1.0); + let v_max = visible[1].max(1.0) / tex[1].max(1.0); + cover_uv_rect( + screen_source_rect(u_max, v_max, crop, 1.0, [0.5, 0.5]), + tex, + box_ar, + ) +} /// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son /// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la /// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui @@ -1873,4 +1890,22 @@ mod tests { assert!((su0 - (960.0 - 720.0) * 0.5 / tex[0]).abs() < 1e-6); assert!((su1 - (960.0 + 720.0) * 0.5 / tex[0]).abs() < 1e-6); } + + #[test] + fn webcam_crop_identity_keeps_the_full_visible_frame() { + let uv = webcam_source_rect([1280.0, 720.0], [2048.0, 1024.0], None, 16.0 / 9.0); + assert_rect(uv, [0.0, 0.0, 1280.0 / 2048.0, 720.0 / 1024.0]); + } + + #[test] + fn webcam_crop_applies_authored_zoom_and_pan_before_layout_cover() { + let crop = SceneCrop { + x: 0.25, + y: 0.20, + width: 0.50, + height: 0.60, + }; + let uv = webcam_source_rect([100.0, 100.0], [100.0, 100.0], Some(crop), 0.50 / 0.60); + assert_rect(uv, [0.25, 0.20, 0.75, 0.80]); + } } diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 0d0d9804..910738fc 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -21,7 +21,7 @@ use std::ffi::CString; use std::ptr; use crate::audio::{ - assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, }; use crate::config::Cfg; @@ -457,6 +457,7 @@ pub fn run_composited_multi( let mut clip_frame_counts: Vec = vec![0; clips.len()]; let scene = comp.scene_snapshot(); + let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default(); // Ring de staging a 2 : l'export ne veut que du debit, une frame de latence // ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour // la raison pour laquelle la preview, elle, reste a 1. @@ -533,7 +534,10 @@ pub fn run_composited_multi( // raccourci voit son audio raccourci d'autant), puis un seul encode AAC. let declared_audio: Vec = clips.iter().map(|c| c.has_audio).collect(); let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); - audio_encoder.encode(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?; + audio_encoder.encode( + &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings), + octx, + )?; crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?; crate::ffi::avio_closep(&mut pb); crate::ffi::avformat_free_context(octx); diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs index 2ad12b66..10de3fac 100644 --- a/crates/compositor/src/pipeline_macos.rs +++ b/crates/compositor/src/pipeline_macos.rs @@ -30,7 +30,7 @@ //! décodeurs, symétrique. use crate::audio::{ - assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, }; use crate::compositor::Compositor; @@ -1076,6 +1076,7 @@ pub fn run_composited_multi( // exactement le bug de troncature en slow-motion que la doc de `walk_composited_timeline` // raconte avoir déjà coûté une fois. let scene = comp.scene_snapshot(); + let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default(); frames = unsafe { crate::timeline_walk::walk_composited_timeline( clips, @@ -1130,7 +1131,10 @@ pub fn run_composited_multi( // d'autant, sinon la piste dérive pour tous les suivants. let declared_audio: Vec = clips.iter().map(|clip| clip.has_audio).collect(); let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); - audio_encoder.encode(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?; + audio_encoder.encode( + &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings), + octx, + )?; crate::ffi::averr( crate::ffi::av_write_trailer(octx), @@ -1188,4 +1192,4 @@ pub fn probe_frame_count(_path: &str) -> Result { // cfg-re-export `crate::compositor::Compositor`, et cette fonction helper garantit // que le type reste référencé. #[allow(dead_code)] -fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {} \ No newline at end of file +fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {} diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index f5b02ac2..11738bcd 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -3,7 +3,7 @@ //! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps. use crate::audio::{ - assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio, stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, }; use crate::compositor::{Compositor, OUT_H, OUT_W}; @@ -1342,6 +1342,7 @@ unsafe fn run_multi_inner( // La scène (déjà posée par l'appelant via comp.set_scene) pilote le curseur et le // fenêtrage par clip ; `walk_composited_timeline` s'en charge. let scene = comp.scene_snapshot(); + let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default(); // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- // Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur @@ -1465,7 +1466,10 @@ unsafe fn run_multi_inner( &declared_audio, out_fps as f64, ); - let assembled_audio = assemble_concatenated_pcm(&clip_pcm, &audio_plan); + let assembled_audio = finish_audio( + assemble_concatenated_pcm(&clip_pcm, &audio_plan), + audio_settings, + ); audio_encoder.encode(&assembled_audio, octx)?; averr(av_write_trailer(octx), "write_trailer")?; diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 2de56951..ec0fdd82 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -45,6 +45,9 @@ pub struct SceneLayout { pub webcam_position: Option, /// la webcam rétrécit pendant un zoom actif. pub webcam_reactive_zoom: bool, + /// User-authored source crop for the camera. Absent keeps the full frame. + #[serde(default)] + pub webcam_crop: Option, /// Rect webcam résolu côté app (0..1 fractions du cadre de sortie), en PARITÉ EXACTE avec /// `computeCompositeLayout` (TS). Permet à TS et Rust de partager la même source de vérité : /// le natif ne dérive PLUS ses propres placements pour PiP/dual-frame/vertical-stack — il @@ -361,6 +364,19 @@ pub struct SceneCrop { pub height: f32, } +/// Audio finishing. Deliberately limited to a linear gain — the one operation the editor +/// preview can apply identically to the source file it plays. See `audio::finish_audio` +/// before adding a field here; a sync offset was tried and removed. +/// +/// The field carries `#[serde(default)]`: a payload from a build that predates it must +/// degrade to "that stage is neutral", not fail the whole scene. +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAudio { + #[serde(default)] + pub gain_db: f32, +} + #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SceneOutput { @@ -389,6 +405,9 @@ pub struct Scene { #[serde(default)] pub camera_fullscreen_regions: Vec, pub cursor: SceneCursor, + /// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible. + #[serde(default)] + pub audio: SceneAudio, /// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS). #[serde(default)] pub crop_by_clip: Vec>, diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 19928dbe..833dda36 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -2094,6 +2094,26 @@ color: var(--danger); } +.secondaryBtn { + margin: 4px var(--sp-4) 12px; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface-2); + color: var(--fg-2); + font: 500 12px/1 var(--font-body); + cursor: pointer; +} +.secondaryBtn:hover { + background: var(--surface-3); + color: var(--fg); +} +.secondaryBtn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .authPanel { padding: 14px 16px; border: 1px solid var(--brand); diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b28f8558..236c8452 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -172,7 +172,7 @@ export function NewEditorShell() { // don't race each other's save and overwrite one another in the // store. The hook reads the doc inside the chain (after awaiting the // previous save) — see its source for the race this fixes. - const { apply: applyTimelineOp } = useSequentialTimelineOps({ + const { apply: applyTimelineOp, enqueue: enqueueTimelineWrite } = useSequentialTimelineOps({ fallbackDocument: document, saveDocument, }); @@ -318,6 +318,7 @@ export function NewEditorShell() { if (!document) return []; return document.assets.map((asset) => ({ id: asset.id, + filePath: /^(https?|blob|data):/.test(asset.originalPath) ? undefined : asset.originalPath, // Real Electron assets are filesystem paths and go through toFileUrl. // In the browser preview an asset can already point at an http(s)/ // blob/data URL served by Vite; toFileUrl would mangle those into a @@ -392,11 +393,33 @@ export function NewEditorShell() { [setCurrentTime], ); + // Same race as `useSequentialTimelineOps` — see that file's header. `insertClipAt` is a + // read-modify-write of the whole document, so two adds in flight at once both read the + // pre-insert doc and the second `saveDocument` clobbers the first, silently dropping a + // clip. Two adds is one double-click on **Add to timeline** (the button has no pending + // state) or two quick drags. + // + // It goes on that hook's queue rather than one of its own: a second queue serialises + // adds against adds and nothing else, so an add still clobbers a trim landing at the + // same moment. It can't route through `apply()` — inserting a clip is not an + // AxcutTimelineOperation, it carries its own background duration probe — hence + // `enqueue`, which is the same chain without that constraint. + // + // The append index is read INSIDE the chain for the same reason the doc is: off the + // closure, `clips.length` stays frozen at the last render, so the second add lands + // before the first instead of after it. const handleDropAsset = useCallback( - (assetId: string) => { - void tl.insertClipAt(assetId, clips.length); - }, - [tl, clips.length], + (assetId: string) => + enqueueTimelineWrite(() => { + const at = useProjectStore.getState().document?.timeline.clips.length ?? 0; + return tl.insertClipAt(assetId, at); + }).catch((error) => { + toast.error(te("mediaStage.couldNotAddAsset"), { + description: error instanceof Error ? error.message : String(error), + }); + throw error; + }), + [tl, te, enqueueTimelineWrite], ); // Ref so the 'ended' listener below always sees the latest clips without tearing @@ -1196,7 +1219,7 @@ export function NewEditorShell() { /> ) : mode === "media" ? ( - + ) : ( void handleNewRecording()} diff --git a/src/components/ai-edition/RightPanes.layout.test.tsx b/src/components/ai-edition/RightPanes.layout.test.tsx index 4056d13c..ced0b265 100644 --- a/src/components/ai-edition/RightPanes.layout.test.tsx +++ b/src/components/ai-edition/RightPanes.layout.test.tsx @@ -118,7 +118,9 @@ describe("LayoutPane camera availability", () => { expect(screen.getByText("Shrink on Zoom")).toBeInTheDocument(); expect(screen.getByText("Webcam Size")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Rounded" })).toBeEnabled(); - expect(screen.getByRole("slider")).toBeEnabled(); + // Named, because the pane holds four sliders now — webcam size plus the three + // webcam-framing ones. This one is the size slider the line above just found. + expect(screen.getByRole("slider", { name: "Webcam Size" })).toBeEnabled(); // The camera-less hint must not leak into the normal case. await user.click(screen.getByRole("button", { name: "Help" })); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 30206f58..bbdec9cd 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -6,6 +6,7 @@ // self-sufficient). import { + AudioLines, FileText, HelpCircle, Layout as LayoutIcon, @@ -41,6 +42,7 @@ import type { AxcutTrimRange, AxcutWord, } from "@/lib/ai-edition/schema"; +import { AUDIO_GAIN_DB_LIMIT } from "@/lib/ai-edition/store/editorSettings"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { @@ -1538,6 +1540,23 @@ export function LayoutPane() { // still on disk. Say so, otherwise the only signal the user gets is their setting // apparently having been thrown away. const helpText = hasDocument && !hasAnyCamera ? ts("layout.helpNoWebcam") : ts("layout.help"); + const webcamCrop = settings.webcamCropRegion; + const cropZoomPct = Math.round(100 / webcamCrop.width); + const cropPanX = webcamCrop.width >= 0.999 ? 50 : (webcamCrop.x / (1 - webcamCrop.width)) * 100; + const cropPanY = webcamCrop.height >= 0.999 ? 50 : (webcamCrop.y / (1 - webcamCrop.height)) * 100; + const setCropZoom = (zoomPct: number) => { + const size = 100 / Math.max(100, zoomPct); + const centerX = webcamCrop.x + webcamCrop.width / 2; + const centerY = webcamCrop.y + webcamCrop.height / 2; + setLive({ + webcamCropRegion: { + x: Math.min(1 - size, Math.max(0, centerX - size / 2)), + y: Math.min(1 - size, Math.max(0, centerY - size / 2)), + width: size, + height: size, + }, + }); + }; return ( } helpText={helpText}>
{ts("layout.preset")}
@@ -1651,6 +1670,7 @@ export function LayoutPane() { {Math.round(settings.webcamSizePreset)}% ) : null} +
{ts("layout.webcamFraming")}
+
+ void commit()} + /> + = 0.999} + onChange={(value) => + setLive({ + webcamCropRegion: { ...webcamCrop, x: (value / 100) * (1 - webcamCrop.width) }, + }) + } + onCommit={() => void commit()} + /> + = 0.999} + onChange={(value) => + setLive({ + webcamCropRegion: { ...webcamCrop, y: (value / 100) * (1 - webcamCrop.height) }, + }) + } + onCommit={() => void commit()} + /> +
+
+ ); +} + +// ─── Audio ──────────────────────────────────────────────────────── + +export function AudioPane() { + const ts = useScopedT("settings"); + const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); + return ( + } helpText={ts("audio.help")}> +
+ setLive({ audioGainDb: value })} + onCommit={() => void commit()} + /> +
+
); } @@ -1911,7 +2005,12 @@ export function SliderCell({ ) : null} + {/* The visible label is a , not a