From ddaf92daa112be1eddcd0d153d80dba69652943e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 22:10:26 +0200 Subject: [PATCH 1/2] fix(export): stop drawing the screen inside the PiP box on camera-less clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout preset is global — one panel for the whole timeline — but the camera is per clip: a project mixes a screen+webcam recording with a plain import without one. `LiveParams::has_webcam` already carried that distinction, but only `live.rs` derived it, so the preview was right and every export was wrong. An export sets its `LiveParams` once for the whole timeline (`compositor-view-napi`), keeping the `true` default. And `ExportDialog` sends the SCREEN path as `webcamPath` when a clip has no camera, purely so the decoder has something valid to open — so the PiP box was drawn with the screen recording behind it, duplicated into its own corner. That is the mirror reported in #248, which the greyed-out Layout panel (correctly gated on `hasAnyClipWithCamera`) then left no way to turn off. `webcam_is_real` moves next to the field it decides, and `walk_composited_timeline` — shared by MP4 and GIF on all three backends — rebinds it per clip. A targeted `set_has_webcam` rather than a per-clip `set_live_params`, which would clobber the settings the caller posted. Verified on a real export (`run_composited_multi`, h264_amf) with the camera path equal to the screen path: the thumbnail is gone. The bench gains a `--webcam` override because the no-camera case is not different *content* but an identical *path*, and cannot be replayed otherwise. Refs #248 --- crates/compositor/src/compositor_linux.rs | 6 ++++ crates/compositor/src/compositor_macos.rs | 6 ++++ crates/compositor/src/compositor_windows.rs | 8 ++++++ crates/compositor/src/frame_geometry.rs | 31 +++++++++++++++++++-- crates/compositor/src/live.rs | 21 +------------- crates/compositor/src/timeline_walk.rs | 8 ++++++ crates/poc-d3d/src/bench.rs | 7 ++++- 7 files changed, 63 insertions(+), 24 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index adefba877..fc32ecaff 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -726,6 +726,12 @@ impl Compositor { *self.live_params.borrow_mut() = p; } + /// Cf. `compositor_windows::set_has_webcam` — le seul champ de `LiveParams` qui dépend du + /// clip courant, rebranché par `walk_composited_timeline` sans écraser le reste. + pub fn set_has_webcam(&self, v: bool) { + self.live_params.borrow_mut().has_webcam = v; + } + pub fn set_scene(&self, s: Option) { *self.scene.borrow_mut() = s; } diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index cd553fb94..4ea09a42c 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -577,6 +577,12 @@ impl Compositor { *self.live_params.borrow_mut() = p; } + /// Cf. `compositor_windows::set_has_webcam` — le seul champ de `LiveParams` qui dépend du + /// clip courant, rebranché par `walk_composited_timeline` sans écraser le reste. + pub fn set_has_webcam(&self, v: bool) { + self.live_params.borrow_mut().has_webcam = v; + } + pub fn set_scene(&self, s: Option) { *self.scene.borrow_mut() = s; } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 09381e454..b4f19b9be 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -596,6 +596,14 @@ impl Compositor { *self.live_params.borrow_mut() = p; } + /// Rebranche le seul champ qui dépend du CLIP et non des réglages (cf. `LiveParams::has_webcam`). + /// L'export pose ses `LiveParams` une fois pour toute la timeline, mais chaque clip a sa propre + /// réponse à « y a-t-il une caméra ? » : d'où un setter ciblé plutôt qu'un `set_live_params` + /// par clip, qui écraserait les réglages posés par l'appelant. + pub fn set_has_webcam(&self, v: bool) { + self.live_params.borrow_mut().has_webcam = v; + } + /// Installe (ou retire) la scène de l'app. Présente → `compose_frame` prend ses placements /// depuis le layout preset au lieu du planning fixture. pub fn set_scene(&self, s: Option) { diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 0addb485d..738a14f8e 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -601,12 +601,37 @@ pub struct LiveParams { /// False when the "webcam" decoder is actually just the screen video again (the TS side /// falls `webcamPath` back to the screen asset's own path when a clip has no real camera, /// purely so the decoder pipeline has something valid to open) — drawing the PiP box in - /// that case duplicates the screen video into its own corner. Live-only: derived in - /// `live.rs` by comparing the active clip's screen/webcam paths; defaults `true` (draw) - /// so fixture/bench renders and any caller that never sets it keep their old behavior. + /// that case duplicates the screen video into its own corner. Derived per clip from the + /// screen/webcam paths via `webcam_is_real`: in `live.rs` for the preview, in + /// `timeline_walk.rs` for every export. Defaults `true` (draw) so fixture/bench renders + /// and any caller that never sets it keep their old behavior. pub has_webcam: bool, } +fn same_source_path(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +/// True when this clip really has a camera to draw. +/// +/// TWO ways the app says "no camera", and both must be caught here, because the +/// webcam decoder is opened either way — the live path falls back to the SCREEN +/// file when the webcam path won't open, and `ExportDialog` sends the screen path +/// outright, so the decoder always yields frames. Whether those frames are the +/// camera or a second copy of the screen is decided HERE and nowhere else. +/// +/// - the empty string, which is what `sceneDescription.ts` and +/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`; +/// - the screen's own path, which `ExportDialog.tsx` sends and which older +/// scenes still use. +/// +/// Missing the empty-string case is what put the screen recording inside the PiP +/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind +/// it was the screen fallback. +pub fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool { + !webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path) +} + impl Default for LiveParams { fn default() -> Self { Self { diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 9c1c8f382..015d01d78 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -28,6 +28,7 @@ use crate::scene::Scene; use crate::config::{self, Cfg}; use crate::cursor::CursorTrack; use crate::d3d::Gpu; +use crate::frame_geometry::webcam_is_real; use crate::pipeline::Decoder; use crate::timeline_walk::{frame_step, FrameStep, NextFrameTime}; use anyhow::Result; @@ -590,26 +591,6 @@ fn same_source_path(a: &str, b: &str) -> bool { a.eq_ignore_ascii_case(b) } -/// True when the active clip really has a camera to draw. -/// -/// TWO ways the app says "no camera", and both must be caught here, because the -/// webcam decoder is opened either way — `open_and_seek_clip` falls back to the -/// SCREEN file when the webcam path won't open, so `wdec` always yields frames. -/// Whether those frames are the camera or a second copy of the screen is decided -/// HERE and nowhere else. -/// -/// - the empty string, which is what `sceneDescription.ts` and -/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`; -/// - the screen's own path, the older convention kept working for scenes that -/// still use it. -/// -/// Missing the empty-string case is what put the screen recording inside the PiP -/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind -/// it was the screen fallback. -fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool { - !webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path) -} - fn scene_clip_matches( clip: &crate::scene::SceneClip, screen_path: &str, diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index 0e0efdad6..adde1d52b 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -17,6 +17,7 @@ use crate::compositor::Compositor; use crate::config::Cfg; use crate::cursor::CursorTrack; use crate::d3d::Gpu; +use crate::frame_geometry::webcam_is_real; use crate::pipeline::{ClipSource, Decoder}; use crate::regions::{speed_segments_for_window, SpeedSegment}; use crate::scene::Scene; @@ -164,6 +165,13 @@ pub(crate) unsafe fn walk_composited_timeline( let mut frames: u64 = 0; for (clip_index, clip) in clips.iter().enumerate() { + // Le preset de layout est GLOBAL (un seul panneau pour toute la timeline) mais la + // caméra est PAR CLIP : un projet mélange sans problème un enregistrement avec webcam + // et un import qui n'en a pas. Le preset ne doit donc s'appliquer qu'aux clips qui ont + // vraiment une caméra — sinon la boîte PiP est dessinée avec, derrière, le décodeur de + // repli, c'est-à-dire l'écran lui-même recopié dans son propre coin (issue #248). + // La preview vive fait exactement ça dans `live.rs` ; c'est ici l'équivalent export. + comp.set_has_webcam(webcam_is_real(&clip.webcam, &clip.screen)); if !screen_decs.contains_key(&clip.screen) { screen_decs.insert(clip.screen.clone(), Decoder::open(&clip.screen, gpu)?); } diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs index cb45254bd..aa2de7d60 100644 --- a/crates/poc-d3d/src/bench.rs +++ b/crates/poc-d3d/src/bench.rs @@ -48,6 +48,7 @@ pub fn run() -> Result<()> { // poc-d3d.exe --cfg C0..C8 --fixture --repeat 3 --out out/ // --cfg GIF → bench natif GIF (slice 1) +// --webcam → force le chemin caméra (défaut `/webcam.mp4`) fn run_bench(args: &[String]) -> Result<()> { let get = |k: &str, d: &str| -> String { arg(args, k, d) }; let fixture = get("--fixture", "fixture"); @@ -56,7 +57,11 @@ fn run_bench(args: &[String]) -> Result<()> { let cfg_arg = get("--cfg", "C0..C8"); let screen = format!("{fixture}/screen.mp4"); - let webcam = format!("{fixture}/webcam.mp4"); + // Override explicite parce que le cas « pas de caméra » n'est PAS un fichier + // différent : l'app renvoie le chemin de l'écran lui-même (`ExportDialog`) ou la + // chaîne vide (`sceneDescription`). Le reproduire demande donc de piloter le chemin, + // pas le contenu — `--webcam ` rejoue exactement l'issue #248. + let webcam = get("--webcam", &format!("{fixture}/webcam.mp4")); std::fs::create_dir_all(&out).ok(); // sélection des cfg From 67df6b59d879d255617e768af53fc058ccc1be8c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 22:43:46 +0200 Subject: [PATCH 2/2] fix(layout): resolve the webcam layout per clip, not once per timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preset is global — one panel for the whole timeline — but the camera is per clip. `layoutByClip` already carried a resolved layout per visible clip; it just never asked whether that clip had a camera, so every clip got the preset whether or not it had anything to put in it. Gating the camera's draw (`has_webcam`, previous commit) is not enough, and this is the half that actually shows. The block presets — `dual-frame`, `vertical-stack` — size the SCREEN off the block: they reserve the camera's half of the frame. A camera-less clip therefore kept its screen squeezed into that half with nothing beside it, which no draw-time gate can undo. Under picture-in-picture the defect is invisible, since the screen stays full-frame there and only a thumbnail is added. So a clip with no camera now lays out as if the preset were "no-webcam" — which `computeCompositeLayout` already implements (full-frame screen, no webcam rect). The predicate matches the `webcamPath` sent with the clip exactly, so the layout and the decoder cannot disagree. It is deliberately NOT `hasAnyClipWithCamera`, which gates the Layout panel and ignores `visible` on purpose so the panel stays reachable to un-hide a camera. `PreviewCanvas` had the same hole, and its own comment named it: it hid the webcam SLOT for a camera-less clip but left the screen geometry alone. Five regression tests, all of which fail on the parent commit: one per preset for "only the clip that has a camera gets a webcam rect", and one per block preset for "the camera-less clip gets its full frame back". The existing webcamRect test asserted a PiP rect for an asset with no camera — that premise was the bug, so its fixture gains the camera its preset presupposes and it goes on testing the px→fraction conversion it describes. Refs #248 --- src/components/ai-edition/PreviewCanvas.tsx | 32 +++++---- src/native/sceneDescription.test.ts | 74 +++++++++++++++++++++ src/native/sceneDescription.ts | 40 +++++++---- 3 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 4e430d5e8..86879fd12 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -214,8 +214,21 @@ export function PreviewCanvas(props: PreviewCanvasProps) { ); const cropRegion: CropRegion = activeClip?.cropRegion ?? DEFAULT_CROP_REGION; + // P4 — the layout preset is global (one panel for the whole timeline) but the camera + // is per clip, so the layout has to be resolved against the clip under the playhead. + const activeCameraTrack = useMemo( + () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec), + [assets, props.clips, props.currentTimeSec], + ); + const activeClipHasCamera = Boolean(activeCameraTrack?.visible && activeCameraTrack.sourcePath); + const layout = useMemo(() => { - const preset = settings.webcamLayoutPreset as WebcamLayoutPreset; + // A clip with no camera lays out as "no-webcam", whatever the panel says. Hiding + // only the webcam slot is not enough: the block presets size the SCREEN off the + // block, so the screen stayed squeezed into its half with nothing beside it. + const preset = ( + activeClipHasCamera ? settings.webcamLayoutPreset : "no-webcam" + ) as WebcamLayoutPreset; const mask = settings.webcamMaskShape as WebcamMaskShape; // ponytail: padding shrinks the available content area for ALL layouts // (PiP/dual/stack) so the screen doesn't fill the canvas edge-to-edge. @@ -240,7 +253,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { canvasSize: frameSize, maxContentSize, screenSize: croppedScreenSize, - webcamSize: settings.webcamLayoutPreset === "no-webcam" ? null : WEBCAM_SOURCE_SIZE, + webcamSize: preset === "no-webcam" ? null : WEBCAM_SOURCE_SIZE, layoutPreset: preset, webcamSizePreset: settings.webcamSizePreset, // ponytail: PiP webcam is grabbable. Pass through the user's @@ -254,6 +267,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { frameSize, screenNativeSize, cropRegion, + activeClipHasCamera, settings.webcamLayoutPreset, settings.webcamMaskShape, settings.webcamSizePreset, @@ -293,17 +307,9 @@ export function PreviewCanvas(props: PreviewCanvasProps) { () => buildWebcamStyle(effectiveLayout, settings, frameSize), [effectiveLayout, settings, frameSize], ); - // P4 — the layout math above only knows the user's chosen preset - // (PiP/dual/stack), not whether the clip under the playhead actually has a - // camera. Without this, an empty (but styled — shadow, background) webcam - // slot stays visible for clips with no camera attached. - const activeCameraTrack = useMemo( - () => resolveActiveCameraTrack(assets, props.clips, props.currentTimeSec), - [assets, props.clips, props.currentTimeSec], - ); - const showWebcamSlot = Boolean( - layout?.webcamRect && activeCameraTrack?.visible && activeCameraTrack.sourcePath, - ); + // `layout` already resolves to "no-webcam" (hence `webcamRect: null`) for a + // camera-less clip, so this is belt-and-braces rather than the only guard. + const showWebcamSlot = Boolean(layout?.webcamRect && activeClipHasCamera); const [isPlaying, setIsPlaying] = useState(false); const handleVideoElement = useMemo(() => props.onVideoElement, [props.onVideoElement]); // L'élément `