From 216aed223b3b26ebf3c0756c5f9026fbf411accb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:32:52 +0000 Subject: [PATCH 01/24] chore(release): bump to 2.1.0-rc.1 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index f744ecf21..a83443ec7 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-qSDxX9ReTPNfFPdLIwr3vl3RiQjj2QR2hScAwperOWE="; + npmDepsHash = "sha256-lJfmphRy/yatz+vgiXOizpoobUr7HH/286ULsAUhPrY="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index a6bbff51f..9aec0acce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.0.0", + "version": "2.1.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.0.0", + "version": "2.1.0-rc.1", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index 2f6da283a..8108f204c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.0.0", + "version": "2.1.0-rc.1", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From c5bc8be47c2f2d58eb6f2e7a5eaf621b72fda993 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:39:38 +0000 Subject: [PATCH 02/24] chore(release): bump to 2.1.0-rc.2 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index a83443ec7..40097283f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-lJfmphRy/yatz+vgiXOizpoobUr7HH/286ULsAUhPrY="; + npmDepsHash = "sha256-KweMgyDlb1VaKSX9g2FOTL/jLxeVBMerJ5dWvbf6Ca4="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 9aec0acce..8b6f50831 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0-rc.1", + "version": "2.1.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0-rc.1", + "version": "2.1.0-rc.2", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index 8108f204c..5260afc5a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0-rc.1", + "version": "2.1.0-rc.2", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From 9b3730dd3273097c03a8313e31dd7d92bf6b99e1 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 08:56:50 +0200 Subject: [PATCH 03/24] fix(preview): advance the camera while a screen frame is held A held screen frame was recomposed at the playback clock, but the camera only moved when the next screen frame arrived, so it decoded the whole hold at once: a 50 to 90 ms stall after every long hold of a recording with a camera. The camera catch-up moves out of step() into advance_webcam_to(), which the held recompose now calls at the clock. --- crates/compositor/src/live.rs | 94 ++++++++++++--------- crates/compositor/tests/held_frame_clock.rs | 44 ++++++++++ 2 files changed, 99 insertions(+), 39 deletions(-) diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 6024645ed..d5b8bb5f9 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -578,50 +578,13 @@ impl Player { return Ok(false); } - let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0); let wf = if !self.webcam_decoder_is_real { // Pas de caméra : le remplaçant n'est pas décodé (cf. `open_webcam_or_stand_in`). sf } else if use_current { self.wdec.cur_frame() } else { - let cur = self.wdec.cur_frame(); - if cur.is_null() { - // Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé. - self.wdec.seek_to(target_webcam_t)? - } else { - // Rattrape la webcam vers `target_webcam_t` par pts réel, jamais au-delà — - // même sémantique de hold que l'écran ci-dessus (et que `advance_decoder_to`) : - // adopter une frame webcam dont le pts dépasse `target_webcam_t` l'afficherait - // en avance sur son heure. Le garde-fou ne joue que contre un cas pathologique. - // - // BUG corrigé : à son EOF la webcam était reseekée à 0 alors que - // `target_webcam_t` continue de croître avec le temps écran. Au tick - // suivant, le rattrapage repartait donc de 0 et réavalait le fichier - // entier vers une cible toujours aussi lointaine — 1000 frames par tick - // (le plafond du garde-fou), en boucle, pour l'éternité. Un décodage - // permanent à fond, pour afficher une webcam qui n'a plus rien à montrer. - // Une fois l'EOF traité comme un hold, la décision webcam est EXACTEMENT - // celle de l'écran à l'export : `frame_step` couvre les quatre cas sans - // rien de spécifique, et ses tests couvrent donc aussi ce chemin. - let mut wf = cur; - let mut guard = 0u32; - loop { - match frame_step(self.wdec.peek_next_time_sec()?, 0.0, target_webcam_t) { - FrameStep::Commit => wf = self.wdec.commit_peek()?, - FrameStep::CommitAndStop => { - wf = self.wdec.commit_peek()?; - break; - } - FrameStep::Hold => break, - } - guard += 1; - if guard > 1000 { - break; - } - } - wf - } + self.advance_webcam_to(self.sdec.cur_time_sec())? }; if wf.is_null() { self.has_current_frame = false; @@ -635,6 +598,59 @@ impl Player { Ok(true) } + /// Avance la vraie caméra jusqu'au temps écran `screen_source_time_sec` (décalage compris) + /// et rend sa frame courante. + unsafe fn advance_webcam_to(&mut self, screen_source_time_sec: f64) -> Result<*mut crate::ffi::AVFrame> { + let target_webcam_t = webcam_seek_time(screen_source_time_sec, self.webcam_offset_sec); + let cur = self.wdec.cur_frame(); + if cur.is_null() { + // Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé. + return self.wdec.seek_to(target_webcam_t); + } + // Rattrape la webcam vers `target_webcam_t` par pts réel, jamais au-delà — + // même sémantique de hold que l'écran dans `step` (et que `advance_decoder_to`) : + // adopter une frame webcam dont le pts dépasse `target_webcam_t` l'afficherait + // en avance sur son heure. Le garde-fou ne joue que contre un cas pathologique. + // + // BUG corrigé : à son EOF la webcam était reseekée à 0 alors que + // `target_webcam_t` continue de croître avec le temps écran. Au tick + // suivant, le rattrapage repartait donc de 0 et réavalait le fichier + // entier vers une cible toujours aussi lointaine — 1000 frames par tick + // (le plafond du garde-fou), en boucle, pour l'éternité. Un décodage + // permanent à fond, pour afficher une webcam qui n'a plus rien à montrer. + // Une fois l'EOF traité comme un hold, la décision webcam est EXACTEMENT + // celle de l'écran à l'export : `frame_step` couvre les quatre cas sans + // rien de spécifique, et ses tests couvrent donc aussi ce chemin. + let mut wf = cur; + let mut guard = 0u32; + loop { + match frame_step(self.wdec.peek_next_time_sec()?, 0.0, target_webcam_t) { + FrameStep::Commit => wf = self.wdec.commit_peek()?, + FrameStep::CommitAndStop => { + wf = self.wdec.commit_peek()?; + break; + } + FrameStep::Hold => break, + } + guard += 1; + if guard > 1000 { + break; + } + } + Ok(wf) + } + + /// `recompose_at` for a held screen frame during playback: the camera is advanced to + /// `clock_sec` first. Otherwise it stood still through the hold and decoded the whole + /// stretch at once on the next screen frame, a 50–90 ms stall after every long hold. + pub unsafe fn hold_at(&mut self, comp: &Compositor, cfg: &Cfg, clock_sec: f64) -> Result { + // A frame placed by a seek or a clip switch is composed as is by the next `step`. + if self.has_current_frame && self.webcam_decoder_is_real && !self.use_current_on_next_step { + self.advance_webcam_to(clock_sec)?; + } + self.recompose_at(comp, cfg, clock_sec) + } + /// Positionne `comp` sur le temps source RÉEL (pts) de la frame écran courante, pour que le /// curseur ET les zoom/full-camera regions du clip actif restent exacts quelle /// que soit la cadence réelle de l'enregistrement — BUG corrigé : tout dérivait auparavant @@ -1980,7 +1996,7 @@ unsafe fn render_thread( let clock_sec = hold_clock_sec(player.screen_time_sec(), acc, clip_end_sec); if should_recompose_hold(now, last_play_compose, clock_sec, player.composed_time_sec()) { - if let Ok(true) = player.recompose_at(&comp, &cfg, clock_sec) { + if let Ok(true) = player.hold_at(&comp, &cfg, clock_sec) { stepped = true; } } diff --git a/crates/compositor/tests/held_frame_clock.rs b/crates/compositor/tests/held_frame_clock.rs index 106656300..5b7161dc2 100644 --- a/crates/compositor/tests/held_frame_clock.rs +++ b/crates/compositor/tests/held_frame_clock.rs @@ -3,6 +3,9 @@ //! ```sh //! OPENSCREEN_LIVE_SOURCE=/path/source.mp4 cargo test -p openscreen-compositor --test held_frame_clock -- --nocapture //! ``` +//! +//! The camera test also needs `OPENSCREEN_LIVE_WEBCAM`, a camera file with a frame between 1 s and +//! 1.25 s (any constant-frame-rate recording). The source itself does not count as a camera. #![cfg(any(windows, target_os = "macos"))] @@ -69,3 +72,44 @@ fn a_held_frame_follows_the_playback_clock() { assert_eq!(player.composed_time_sec(), held); } } + +#[test] +fn a_held_frame_advances_the_camera_to_the_clock() { + let (Ok(source), Ok(camera)) = + (std::env::var("OPENSCREEN_LIVE_SOURCE"), std::env::var("OPENSCREEN_LIVE_WEBCAM")) + else { + println!("SKIP: set OPENSCREEN_LIVE_SOURCE and OPENSCREEN_LIVE_WEBCAM (file header)."); + return; + }; + let gpu = Gpu::create(false).expect("device"); + let mut cfg = config::all().pop().expect("at least one config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(&gpu, 480, 270).expect("compositor"); + let scene = Scene::from_json(&scene_json(&source)).expect("valid scene"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene.clone())); + comp.clear_cursor(); + + unsafe { + let mut player = Player::open(&source, &camera, &gpu).expect("open the source"); + assert!(player.webcam_decoder_is_real(), "the camera file must open"); + player.set_programme_clock(Some(&scene), 0); + assert!(player.present_frame(&comp, &cfg, 1.0).expect("seek"), "paused seek"); + let held = player.screen_time_sec(); + let camera_before = player.webcam_time_sec(); + + let clock = held + 0.25; + assert!(player.hold_at(&comp, &cfg, clock).expect("hold_at")); + assert_eq!(player.screen_time_sec(), held, "the screen frame must stay the held one"); + let camera_after = player.webcam_time_sec(); + assert!( + camera_after > camera_before, + "the camera stood still at {camera_before:.3} s through the hold" + ); + assert!( + camera_after <= clock + 1e-6, + "camera at {camera_after:.3} s, ahead of the clock {clock:.3} s" + ); + } +} From 3038507afaf4470f920bfae818ac54ca9400a8af Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 15 Oct 2026 10:34:07 +0200 Subject: [PATCH 04/24] fix(timeline): copy a whole pill, not the fragment clicked A zoom, annotation, speed or Full Camera region cut by a clip boundary is several fragments drawn as one pill. Copying it took the fragment clicked, so the paste came out shorter than the region on screen. wholePillOf gives the pill as one region over its whole span; the shader effect copy, which had its own version of this, uses it too. --- src/components/ai-edition/NewEditorShell.tsx | 26 +++++++++---------- .../ai-edition/timeline/timelineMap.test.ts | 13 ++++++++++ src/lib/ai-edition/timeline/timelineMap.ts | 18 +++++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 60a209b6f..13e5c1c47 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1203,22 +1203,17 @@ export function NewEditorShell() { return; } - // An effect region goes with its definition (`RegionSnapshot`), and with the span of the - // whole pill: one cut by a clip boundary is several fragments. + // The pill on screen, not the fragment clicked: a region cut by a clip boundary is several. + const { wholePillOf } = await import("@/lib/ai-edition/timeline/timelineMap"); + + // An effect region goes with its definition (`RegionSnapshot`). if (sel.kind === "shaderEffect") { - const { coalesceRegionsForRuler } = await import("@/lib/ai-edition/timeline/timelineMap"); - const pill = coalesceRegionsForRuler(tl.shaderEffectRanges).find((p) => - p.ids.includes(sel.id), - ); - const definition = pill && tl.shaderEffects.find((e) => e.id === pill.member.effectId); - if (!pill || !definition) return; + const region = wholePillOf(tl.shaderEffectRanges, sel.id); + const definition = region && tl.shaderEffects.find((e) => e.id === region.effectId); + if (!region || !definition) return; copyRegion({ kind: "shaderEffect", - region: { - ...pill.member, - startMs: Math.round(pill.start * 1000), - endMs: Math.round(pill.end * 1000), - } as unknown as Record, + region: region as unknown as Record, definition, }); setCopiedClipId(null); @@ -1233,7 +1228,10 @@ export function NewEditorShell() { : sel.kind === "speed" ? tl.speedRegions : tl.cameraFullscreenRegions; - const region = (source as Array<{ id: string }>).find((r) => r.id === sel.id); + const region = wholePillOf( + source as Array<{ id: string; startMs: number; endMs: number }>, + sel.id, + ); if (!region) return; copyRegion({ kind: sel.kind, region: region as unknown as Record }); // One clipboard wins at a time: a copied pill retires the copied clip. diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index 11aa30c50..d613614bb 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -18,6 +18,7 @@ import { resolveNativePosition, resolvePillIds, resolveRegionIds, + wholePillOf, } from "./timelineMap"; function clip(overrides: Partial & Pick): AxcutClip { @@ -787,6 +788,18 @@ describe("pills wired to the universal rules", () => { expect(pills[0].ids).toEqual(["a", "b"]); }); + it("wholePillOf gives the pill a fragment belongs to, over its whole span", () => { + // One zoom cut by the clip boundary at 25.557 s: two fragments, one pill. + const regions = [ + { id: "a", startMs: 20000, endMs: 25557, depth: 3 }, + { id: "b", startMs: 25557, endMs: 30000, depth: 3 }, + { id: "c", startMs: 30000, endMs: 31000, depth: 5 }, + ]; + expect(wholePillOf(regions, "b")).toEqual({ id: "a", startMs: 20000, endMs: 30000, depth: 3 }); + expect(wholePillOf(regions, "c")).toEqual(regions[2]); + expect(wholePillOf(regions, "gone")).toBeNull(); + }); + it("keeps adjacent regions with different properties as separate pills", () => { const pills = coalesceRegionsForRuler([ { id: "a", startMs: 2000, endMs: 5000, speed: 3 }, diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index 451987ce9..80f77ed4d 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -304,6 +304,24 @@ export function coalesceRegionsForRuler( + regions: T[], + id: string, +): T | null { + const pill = coalesceRegionsForRuler(regions).find((p) => p.ids.includes(id)); + if (!pill) return null; + return { + ...pill.member, + startMs: Math.round(pill.start * 1000), + endMs: Math.round(pill.end * 1000), + }; +} + /** * The regions that render as the SAME pill as `id`. Recomputed from the merge rule * rather than stored: nothing records which regions belong together, because equal From a1ccced32caae8c75dab8cef1b59a977bbf61ad0 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 15 Oct 2026 15:00:39 +0200 Subject: [PATCH 05/24] fix(compositor): read the macOS preview back through Managed textures on Intel Macs The live preview reads the composed frame back through a CPU mirror texture created with MTLStorageModeShared. Apple only guarantees Shared textures on its own GPUs; on an Intel Mac a texture read by the CPU has to be Managed and synchronized after the GPU writes it. The preview stayed blank, then froze on its first frame, while the export (which renders into the encoder's CVPixelBuffers and never reads the RT back) came out fine. Pick the storage mode per device (Shared on Apple GPUs, Managed otherwise) for every texture the CPU reads or writes, and synchronize Managed mirrors in the blit that fills them. Apple Silicon keeps the exact same Shared path. Refs #1238 (keep open until confirmed on an Intel Mac) --- crates/compositor/src/compositor_macos.rs | 215 +++++++++++++++------- crates/compositor/src/text_macos.rs | 2 +- 2 files changed, 148 insertions(+), 69 deletions(-) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 822d1d417..a6d168c9a 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -19,10 +19,13 @@ //! Metal n'a pas d'équivalent de `ID3D11DeviceContext::Map` sur une ressource //! `Private`. Les cibles de rendu (`rt`, `nv12_y`, `nv12_uv`) sont donc en //! `StorageMode::Private`, et chaque passe se termine par un `MTLBlitCommandEncoder` -//! vers un miroir `Shared` (`rt_read`, `nv12_read_y`, `nv12_read_uv`) sur lequel -//! `getBytes` est légal. Le `waitUntilCompleted` qui suit est ce qui rend +//! vers un miroir lisible par le CPU (`rt_read`, `nv12_read_y`, `nv12_read_uv`) sur +//! lequel `getBytes` est légal. Le `waitUntilCompleted` qui suit est ce qui rend //! `readback_direct` synchrone, comme son homologue Windows : sans lui, la preview //! lirait le contenu de la frame précédente (ou du noir au premier tour). +//! +//! Ce miroir est `Shared` sur un GPU Apple et `Managed` sur un Mac Intel, où `Shared` +//! n'existe pas pour les textures : cf. `cpu_storage` et `copy_for_cpu`. use crate::config::Cfg; use crate::d3d::Gpu; @@ -219,14 +222,13 @@ const SEGMENTATION_HZ: u32 = 30; /// Cible RGBA + miroir de lecture pour extraire la frame webcam à la résolution du modèle. /// /// Deux textures, pas une : `rt` est `Private` parce que c'est une cible de rendu, et -/// `get_bytes` n'est légal que sur du `Shared`. C'est exactement le couple -/// `nv12_y`/`nv12_read_y` du chemin d'encodage, en RGBA et à 256x144 — cf. l'en-tête du -/// module. `Managed` n'a pas sa place ici : rien dans ce fichier n'en utilise, et c'est le -/// seul mode de stockage qui exigerait un `synchronizeResource` avant la lecture. +/// `get_bytes` n'est légal que sur une texture lisible par le CPU (`cpu_storage`). C'est +/// exactement le couple `nv12_y`/`nv12_read_y` du chemin d'encodage, en RGBA et à 256x144 +/// (cf. l'en-tête du module). struct SegCapture { /// Cible de la passe de capture. `Private` : écrite par le GPU, jamais lue par le CPU. rt: metal::Texture, - /// Miroir `Shared` de `rt`, rempli par blit dans le même command buffer. + /// Miroir CPU de `rt` (`cpu_storage`), rempli par blit dans le même command buffer. read: metal::Texture, width: u32, height: u32, @@ -294,13 +296,13 @@ pub struct Compositor { /// Render target principal RGBA8. Cible de `compose_frame`. `Private` : c'est une /// cible de rendu pure, jamais lue par le CPU (c'est `rt_read` qui l'est). rt: metal::Texture, - /// Miroir `Shared` de `rt`, rempli par blit à la fin de `compose_frame` — la seule + /// Miroir CPU de `rt` (`cpu_storage`), rempli par blit dans `readback_direct`, la seule /// façon d'atteindre `getBytes` depuis une cible `Private`. rt_read: metal::Texture, /// NV12 interne : plan Y `R8Unorm`, plan UV `RG8Unorm` (demi-résolution). nv12_y: metal::Texture, nv12_uv: metal::Texture, - /// Miroirs `Shared` des deux plans, pour `read_nv12_scaled`. + /// Miroirs CPU des deux plans, pour `read_nv12_scaled`. nv12_read_y: metal::Texture, nv12_read_uv: metal::Texture, @@ -418,6 +420,56 @@ fn make_texture( device.new_texture(&desc) } +/// Mode de stockage d'une texture que le CPU écrit (`replace_region`) ou lit (`get_bytes`). +/// +/// Apple ne garantit `Shared` aux TEXTURES que sur ses propres GPU. Sur un Mac Intel (UHD +/// 630 intégré comme Radeon Pro discret), le mode par défaut d'une texture est `Managed`, +/// le seul que ces GPU tiennent pour une texture lue par le CPU. Le RT relu par la preview +/// y était quand même créé `Shared` : preview vide, ou figée sur sa première image, pendant que l'export, +/// qui rend dans les `CVPixelBuffer` de l'encodeur sans jamais relire le RT, sortait juste +/// (#1238). +/// +/// `Managed` garde une copie CPU et une copie GPU : un `replace_region` met la seconde à +/// jour tout seul, mais une écriture GPU n'atteint la première qu'après un +/// `synchronizeResource`, et c'est ce que fait `copy_for_cpu`. +pub(crate) fn cpu_storage(device: &metal::DeviceRef) -> metal::MTLStorageMode { + cpu_storage_for(device.supports_family(metal::MTLGPUFamily::Apple1)) +} + +fn cpu_storage_for(apple_gpu: bool) -> metal::MTLStorageMode { + if apple_gpu { + metal::MTLStorageMode::Shared + } else { + metal::MTLStorageMode::Managed + } +} + +/// Blit de `src` (`Private`) vers son miroir CPU `dst`, `w`x`h` depuis l'origine. Un miroir +/// `Managed` (cf. `cpu_storage`) est synchronisé dans le même encodeur : sans cela, +/// `get_bytes` y lirait une copie CPU que le blit n'a pas touchée. +fn copy_for_cpu( + blit: &metal::BlitCommandEncoderRef, + src: &metal::TextureRef, + dst: &metal::TextureRef, + w: u32, + h: u32, +) { + blit.copy_from_texture( + src, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { width: w as u64, height: h as u64, depth: 1 }, + dst, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + if dst.storage_mode() == metal::MTLStorageMode::Managed { + blit.synchronize_resource(dst); + } +} + /// Tout ce qui a la taille du rendu, et rien d'autre : ce que `new_sized` alloue et que /// `resized` réalloue. Cf. `compositor_windows::Targets`. struct Targets { @@ -451,7 +503,7 @@ fn make_targets(device: &metal::Device, rw: u32, rh: u32) -> Targets { metal::MTLPixelFormat::RGBA8Unorm, rw, rh, - metal::MTLStorageMode::Shared, + cpu_storage(device), metal::MTLTextureUsage::ShaderRead, ); let nv12_y = make_texture( @@ -479,7 +531,7 @@ fn make_targets(device: &metal::Device, rw: u32, rh: u32) -> Targets { metal::MTLPixelFormat::R8Unorm, rw, rh, - metal::MTLStorageMode::Shared, + cpu_storage(device), metal::MTLTextureUsage::ShaderRead, ); let nv12_read_uv = make_texture( @@ -487,7 +539,7 @@ fn make_targets(device: &metal::Device, rw: u32, rh: u32) -> Targets { metal::MTLPixelFormat::RG8Unorm, rw / 2, rh / 2, - metal::MTLStorageMode::Shared, + cpu_storage(device), metal::MTLTextureUsage::ShaderRead, ); let accum = make_texture( @@ -649,7 +701,7 @@ impl Compositor { /// Comme `new`, mais avec une taille de rendu explicite. Câble le moteur Metal : /// - `CVMetalTextureCache` (zero-copy CVPixelBuffer → MTLTexture), - /// - render targets (RT RGBA, RT NV12 Y/UV, miroirs `Shared`), + /// - render targets (RT RGBA, RT NV12 Y/UV, miroirs CPU), /// - compilation MSL (`shaders.metal` → `MTLLibrary`), /// - pipeline states (principal + passes fullscreen). pub fn new_sized(gpu: &Gpu, w: u32, h: u32) -> Result { @@ -1227,7 +1279,7 @@ impl Compositor { metal::MTLPixelFormat::RGBA8Unorm, w, h, - metal::MTLStorageMode::Shared, + cpu_storage(&self.gpu.device), metal::MTLTextureUsage::ShaderRead, ); tex.replace_region( @@ -1256,7 +1308,7 @@ impl Compositor { metal::MTLPixelFormat::R16Float, sdf.width, sdf.height, - metal::MTLStorageMode::Shared, + cpu_storage(&self.gpu.device), metal::MTLTextureUsage::ShaderRead, ); tex.replace_region( @@ -1958,8 +2010,8 @@ impl Compositor { /// /// Trois étapes, la forme prescrite par l'en-tête du module et déjà tenue par /// `render_nv12` + `read_nv12_scaled` : rendu dans une cible `Private`, blit vers un - /// miroir `Shared`, `get_bytes`. Pas de `Managed`, donc pas de `synchronizeResource` — - /// c'est le seul mode de stockage qui l'exigerait, et rien dans ce fichier n'en utilise. + /// miroir CPU (`copy_for_cpu`, qui synchronise le miroir `Managed` d'un Mac Intel), + /// `get_bytes`. /// /// Le buffer `out` est réutilisé d'un appel à l'autre : il est dimensionné au RGBA lu /// puis compacté sur place en RGB, ce qui laisse sa capacité au maximum des deux et ne @@ -1995,7 +2047,7 @@ impl Compositor { metal::MTLPixelFormat::RGBA8Unorm, width, height, - metal::MTLStorageMode::Shared, + cpu_storage(&self.gpu.device), metal::MTLTextureUsage::ShaderRead, ), width, @@ -2038,17 +2090,7 @@ impl Compositor { enc.end_encoding(); } let blit = cmd_buf.new_blit_command_encoder(); - blit.copy_from_texture( - &cap.rt, - 0, - 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - metal::MTLSize { width: width as u64, height: height as u64, depth: 1 }, - &cap.read, - 0, - 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - ); + copy_for_cpu(blit, &cap.rt, &cap.read, width, height); blit.end_encoding(); cmd_buf.commit(); cmd_buf.wait_until_completed(); @@ -2079,8 +2121,9 @@ impl Compositor { /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`, 0 = fond). /// - /// La texture est `Shared` et réécrite en place par `replace_region` ; elle n'est - /// recréée que si la résolution du modèle change, ce qui n'arrive pas en régime établi. + /// La texture est écrivable par le CPU (`cpu_storage`) et réécrite en place par + /// `replace_region` ; elle n'est recréée que si la résolution du modèle change, ce qui + /// n'arrive pas en régime établi. /// /// Réécrire une texture que le GPU pourrait encore lire serait une course — ici il ne /// le peut pas : les trois chemins de frame macOS drainent la file avant de rendre la @@ -2107,7 +2150,7 @@ impl Compositor { metal::MTLPixelFormat::R8Unorm, width, height, - metal::MTLStorageMode::Shared, + cpu_storage(&self.gpu.device), metal::MTLTextureUsage::ShaderRead, ), width, @@ -2443,7 +2486,7 @@ impl Compositor { } /// Compose la frame : fond, ombre écran, écran, ombre caméra, caméra — puis miroir - /// `Shared` pour la lecture CPU. + /// CPU pour la lecture (`readback_direct`). /// /// La géométrie vient de `frame_geometry::plan_frame`, la MÊME fonction que le moteur /// D3D11 appelle. Ce qui reste ici n'est donc que l'émission des draws ; c'est aussi @@ -3050,24 +3093,10 @@ impl Compositor { Ok(()) } - /// Copie `rt` (`Private`) vers `rt_read` (`Shared`) dans le command buffer donné. + /// Copie `rt` (`Private`) vers son miroir CPU `rt_read` dans le command buffer donné. fn mirror_rt(&self, cmd_buf: &metal::CommandBufferRef) { let blit = cmd_buf.new_blit_command_encoder(); - blit.copy_from_texture( - &self.rt, - 0, - 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - metal::MTLSize { - width: self.render_w as u64, - height: self.render_h as u64, - depth: 1, - }, - &self.rt_read, - 0, - 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - ); + copy_for_cpu(blit, &self.rt, &self.rt_read, self.render_w, self.render_h); blit.end_encoding(); } @@ -3143,7 +3172,7 @@ impl Compositor { /// Convertit le RT RGBA → `nv12_y` (R8) et `nv12_uv` (RG8) via deux passes /// fullscreen (`ps_y` puis `ps_uv` sur `vs_fs`), puis recopie vers les miroirs - /// `Shared` que `read_nv12_scaled` lit. Miroir Metal de + /// CPU que `read_nv12_scaled` lit. Miroir Metal de /// `compositor_windows::render_nv12` — même conversion BT.709 limited. pub unsafe fn render_nv12(&self) -> Result<()> { let cmd_buf = self.gpu.context.new_command_buffer(); @@ -3179,21 +3208,7 @@ impl Compositor { self.render_h / 2, ), ] { - blit.copy_from_texture( - src, - 0, - 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - metal::MTLSize { - width: w as u64, - height: h as u64, - depth: 1, - }, - dst, - 0, - 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - ); + copy_for_cpu(blit, src, dst, w, h); } blit.end_encoding(); @@ -3203,7 +3218,7 @@ impl Compositor { /// Lit le RT RGBA vers un `Vec` CPU (preview live). Renvoie `(w, h, RGBA8)`. pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { - // Le miroir `Shared` se fait ICI plutôt qu'à chaque composition : seul ce chemin le + // Le miroir CPU se fait ICI plutôt qu'à chaque composition : seul ce chemin le // lit, et il n'est emprunté que par la preview. let cmd_buf = self.gpu.context.new_command_buffer(); self.mirror_rt(cmd_buf); @@ -3350,7 +3365,7 @@ mod tests { metal::MTLPixelFormat::R8Unorm, w, h, - metal::MTLStorageMode::Shared, + cpu_storage(device), metal::MTLTextureUsage::ShaderRead, ); let mut plane = vec![0u8; (w * h) as usize]; @@ -3367,7 +3382,7 @@ mod tests { metal::MTLPixelFormat::RG8Unorm, uw, uh, - metal::MTLStorageMode::Shared, + cpu_storage(device), metal::MTLTextureUsage::ShaderRead, ); let chroma = vec![UV_NEUTRAL; (uw * uh * 2) as usize]; @@ -3448,6 +3463,70 @@ mod tests { assert_eq!(px(&out, 3 * w / 4, h / 2), right); } + #[test] + fn only_an_apple_gpu_gets_shared_textures() { + assert_eq!(cpu_storage_for(true), metal::MTLStorageMode::Shared); + assert_eq!(cpu_storage_for(false), metal::MTLStorageMode::Managed); + } + + /// Le chemin d'un Mac Intel (#1238), joué sur le GPU qu'on a : un GPU Apple accepte aussi + /// les textures `Managed`. Deux couleurs à la suite, parce que le défaut rapporté est une + /// preview vide puis figée : une seule lecture ne verrait pas la seconde moitié. + #[test] + fn a_managed_mirror_reads_back_each_frame_the_gpu_drew() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let (w, h) = (64u32, 32u32); + let rt = make_texture( + &gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + w, + h, + metal::MTLStorageMode::Private, + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + let read = make_texture( + &gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + w, + h, + metal::MTLStorageMode::Managed, + metal::MTLTextureUsage::ShaderRead, + ); + + for (color, expected) in [ + (metal::MTLClearColor::new(1.0, 0.0, 0.0, 1.0), [255u8, 0, 0, 255]), + (metal::MTLClearColor::new(0.0, 0.0, 1.0, 1.0), [0u8, 0, 255, 255]), + ] { + let cmd = gpu.context.new_command_buffer(); + let pass = metal::RenderPassDescriptor::new(); + let ca = pass.color_attachments().object_at(0).expect("color attachment 0"); + ca.set_texture(Some(&rt)); + ca.set_load_action(metal::MTLLoadAction::Clear); + ca.set_clear_color(color); + ca.set_store_action(metal::MTLStoreAction::Store); + cmd.new_render_command_encoder(pass).end_encoding(); + let blit = cmd.new_blit_command_encoder(); + copy_for_cpu(blit, &rt, &read, w, h); + blit.end_encoding(); + cmd.commit(); + cmd.wait_until_completed(); + + let mut data = vec![0u8; (w * h * 4) as usize]; + read.get_bytes( + data.as_mut_ptr() as *mut std::ffi::c_void, + (w * 4) as u64, + region(w, h), + 0, + ); + let last = data.len() - 4; + assert_eq!(data[..4], expected, "premier pixel"); + assert_eq!(data[last..], expected, "dernier pixel"); + } + } + #[test] fn a_capture_of_zero_size_is_refused_rather_than_rendered() { let Ok(gpu) = crate::d3d::Gpu::create(false) else { diff --git a/crates/compositor/src/text_macos.rs b/crates/compositor/src/text_macos.rs index 0cfd49af5..219b4ef01 100644 --- a/crates/compositor/src/text_macos.rs +++ b/crates/compositor/src/text_macos.rs @@ -571,7 +571,7 @@ impl TextRasterizer { desc.set_width(w as u64); desc.set_height(h as u64); desc.set_usage(metal::MTLTextureUsage::ShaderRead); - desc.set_storage_mode(metal::MTLStorageMode::Shared); + desc.set_storage_mode(crate::compositor_macos::cpu_storage(&gpu.device)); let texture = gpu.device.new_texture(&desc); texture.replace_region( From 3b988edce6db5c13350abc25e69081765ce7d75e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 10:20:20 +0200 Subject: [PATCH 06/24] fix(preview): give a shared frame's slot back 40 ms after Chromium lets go Chromium reports a shared texture released as soon as the renderer drops its references, before the GPU has copied it into the canvas. The ring then wrote the slot again, the canvas showed a later frame and then the one in between, and the picture and the cursor stepped back a frame: tens of times in 18 s at 60 Hz, a few at 280 Hz, in 2.0.0 as in main. Holding the slot 40 ms took the steps back to none at both rates. --- .../services/compositorViewService.test.ts | 20 +++++++++++++----- .../services/compositorViewService.ts | 21 +++++++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 40987fe62..a7791c58f 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -10,6 +10,7 @@ import { CompositorViewService, ffmpegSharedBinCandidates, resolveSceneAssetPaths, + SHARED_SLOT_RELEASE_DELAY_MS, } from "./compositorViewService"; describe("native GIF cancellation capability", () => { @@ -185,13 +186,22 @@ describe("CompositorViewService frames handed over as shared GPU textures", () = expect(addon.releaseSharedFrame).not.toHaveBeenCalled(); }); - it("hands the slot back once Chromium has let go of the texture everywhere", async () => { - const { addon, id, releaseEverywhere, service } = setup(); - await service.readFrame(id, 0, target); + it("hands the slot back a while after Chromium has let go of the texture everywhere", async () => { + vi.useFakeTimers(); + try { + const { addon, id, releaseEverywhere, service } = setup(); + await service.readFrame(id, 0, target); - releaseEverywhere(); + releaseEverywhere(); + // Not at once: the GPU copy into the canvas may still be queued behind the release. + vi.advanceTimersByTime(SHARED_SLOT_RELEASE_DELAY_MS - 1); + expect(addon.releaseSharedFrame).not.toHaveBeenCalled(); - expect(addon.releaseSharedFrame).toHaveBeenCalledWith(id, 1, 3); + vi.advanceTimersByTime(1); + expect(addon.releaseSharedFrame).toHaveBeenCalledWith(id, 1, 3); + } finally { + vi.useRealTimers(); + } }); it("goes back to read-back when a delivery fails", async () => { diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 34f4004d8..078f53877 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -233,6 +233,17 @@ export interface CompositorViewServiceOptions { gpuCompositing?: () => boolean; } +/** + * How long a shared frame's ring slot stays out of the render thread's reach after Chromium has + * let go of it. Chromium lets go when the renderer drops its references, which is as soon as it + * has issued the draw into the canvas, not once the GPU has run it: the copy runs when the frame + * carrying it is drawn, up to a vsync later. A slot written again before that copy put a later + * frame on the canvas, then the one in between, so the picture and the cursor stepped back a frame + * (tens of times in 18 s at 60 Hz, a few at 280 Hz). 40 ms is more than two vsyncs at 60 Hz; it + * took the steps back to none at both rates, and the four slots still keep up with 60 fps. + */ +export const SHARED_SLOT_RELEASE_DELAY_MS = 40; + /** The two calls a shared preview frame needs from Electron's `sharedTexture` module. */ export type SharedTextureApi = Pick< Electron.SharedTexture, @@ -746,8 +757,14 @@ export class CompositorViewService { codedSize: { width: frame.width, height: frame.height }, handle: { ntHandle: frame.handle }, }, - // Chromium is done with the texture in every process: the slot may be written again. - allReferencesReleased: () => addon.releaseSharedFrame?.(id, frame.slot, frame.gen), + // Chromium is done with the texture in every process: after a grace period, the slot + // may be written again (SHARED_SLOT_RELEASE_DELAY_MS says why it waits). + allReferencesReleased: () => { + setTimeout( + () => addon.releaseSharedFrame?.(id, frame.slot, frame.gen), + SHARED_SLOT_RELEASE_DELAY_MS, + ); + }, }); await api.sendSharedTexture({ frame: target, importedSharedTexture: imported }, meta); return { ...meta, shared: true }; From e3dc4214c9ce243d0e6e429a7bd18c7f62d8e690 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 10:26:23 +0200 Subject: [PATCH 07/24] fix(preview): eight ring slots, and a slow display's two frames as the hold floor The 40 ms hold kept three to four of the four slots busy at 60 fps, so the render thread waited for a free one and skipped frames. Eight slots leave headroom to about 150 fps. On a display slower than 50 Hz the copy into the canvas can come later than 40 ms: hold two of its frames there (67 ms at 30 Hz). --- crates/compositor/src/shared_frames.rs | 10 ++- .../services/compositorViewService.test.ts | 62 ++++++++++++++++++- .../services/compositorViewService.ts | 39 +++++++++--- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/crates/compositor/src/shared_frames.rs b/crates/compositor/src/shared_frames.rs index a6b4f8415..cbc6eda8a 100644 --- a/crates/compositor/src/shared_frames.rs +++ b/crates/compositor/src/shared_frames.rs @@ -16,9 +16,13 @@ use crate::frame_geometry::FootageQuad; use crate::live::FramePosition; use std::time::{Duration, Instant}; -/// Cases de l'anneau : une prête, une en transit vers le renderer (tenue jusqu'à ce que -/// Chromium la relâche), une en écriture, et une de marge pour la latence de la libération. -pub const RING_SLOTS: u32 = 4; +/// Cases de l'anneau : une prête, une en écriture, et celles que Chromium tient encore. Une case +/// relâchée reste tenue 40 ms de plus côté Electron (`SHARED_SLOT_RELEASE_DELAY_MS`), le temps +/// que le GPU ait copié la frame dans le canvas : à 60 images/s, trois à quatre cases sont alors +/// tenues en même temps. Avec quatre, le rendu attendait une case libre et sautait des frames ; +/// huit laissent de la marge jusqu'à ~150 images/s, pour 8 × (largeur × hauteur × 4) octets de +/// VRAM (66 Mo en 1080p). +pub const RING_SLOTS: u32 = 8; /// Une case tenue plus longtemps que ça a perdu sa libération (renderer rechargé, import /// échoué en route) : Chromium relâche les autres dans la milliseconde. Même ordre que le diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index a7791c58f..36e4048c3 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -11,6 +11,7 @@ import { ffmpegSharedBinCandidates, resolveSceneAssetPaths, SHARED_SLOT_RELEASE_DELAY_MS, + sharedSlotHoldMs, } from "./compositorViewService"; describe("native GIF cancellation capability", () => { @@ -113,7 +114,13 @@ describe("CompositorViewService frames handed over as shared GPU textures", () = footageProjective: false, }; - function setup(options: { addon?: Partial; gpuCompositing?: boolean } = {}) { + function setup( + options: { + addon?: Partial; + gpuCompositing?: boolean; + displayHz?: number | null; + } = {}, + ) { const addon = { createView: vi.fn(() => 7), setSharedFrames: vi.fn(() => true), @@ -136,6 +143,7 @@ describe("CompositorViewService frames handed over as shared GPU textures", () = addon: addon as unknown as CompositorViewAddon, sharedTexture, gpuCompositing: () => options.gpuCompositing ?? true, + displayHz: () => options.displayHz ?? 60, }); const id = service.createView({ x: 0, y: 0, width: 4, height: 2 }); return { @@ -204,6 +212,58 @@ describe("CompositorViewService frames handed over as shared GPU textures", () = } }); + it("holds the slot two frames of a slow display, never less than the floor", async () => { + expect(sharedSlotHoldMs(60)).toBe(SHARED_SLOT_RELEASE_DELAY_MS); + expect(sharedSlotHoldMs(144)).toBe(SHARED_SLOT_RELEASE_DELAY_MS); + expect(sharedSlotHoldMs(30)).toBe(67); + expect(sharedSlotHoldMs(null)).toBe(SHARED_SLOT_RELEASE_DELAY_MS); + expect(sharedSlotHoldMs(0)).toBe(SHARED_SLOT_RELEASE_DELAY_MS); + + vi.useFakeTimers(); + try { + const { addon, id, releaseEverywhere, service } = setup({ displayHz: 30 }); + await service.readFrame(id, 0, target); + releaseEverywhere(); + vi.advanceTimersByTime(66); + expect(addon.releaseSharedFrame).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(addon.releaseSharedFrame).toHaveBeenCalledWith(id, 1, 3); + } finally { + vi.useRealTimers(); + } + }); + + it("holds every frame in flight on its own clock, so none of them loses its slot", async () => { + vi.useFakeTimers(); + try { + const frames = [ + { ...sharedFrame, gen: 3, slot: 1 }, + { ...sharedFrame, gen: 4, slot: 2 }, + ]; + const { addon, id, service, sharedTexture } = setup({ + addon: { readSharedFrame: vi.fn(() => frames.shift() ?? null) }, + }); + const released: Array<() => void> = []; + sharedTexture.importSharedTexture.mockImplementation((options) => { + released.push(options.allReferencesReleased as () => void); + return { release: vi.fn() } as unknown as Electron.SharedTextureImported; + }); + await service.readFrame(id, 0, target); + await service.readFrame(id, 3, target); + + released[0](); + vi.advanceTimersByTime(20); + released[1](); + vi.advanceTimersByTime(SHARED_SLOT_RELEASE_DELAY_MS - 20); + expect(addon.releaseSharedFrame).toHaveBeenCalledTimes(1); + expect(addon.releaseSharedFrame).toHaveBeenLastCalledWith(id, 1, 3); + vi.advanceTimersByTime(20); + expect(addon.releaseSharedFrame).toHaveBeenLastCalledWith(id, 2, 4); + } finally { + vi.useRealTimers(); + } + }); + it("goes back to read-back when a delivery fails", async () => { const { addon, id, service, sharedTexture } = setup(); sharedTexture.sendSharedTexture.mockRejectedValueOnce(new Error("timed out after 1000ms")); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 078f53877..8bb4e888a 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -2,7 +2,14 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { app, sharedTexture, type WebFrameMain } from "electron"; +import { + app, + BrowserWindow, + screen, + sharedTexture, + type WebFrameMain, + webContents, +} from "electron"; import type { ShaderEffectCheck } from "../../../src/lib/ai-edition/shaderEffects"; import { type CursorKind, @@ -231,6 +238,9 @@ export interface CompositorViewServiceOptions { /** Whether Chromium composites on the GPU, where a shared texture is imported. Injectable * for tests; defaults to `app.getGPUFeatureStatus()`. */ gpuCompositing?: () => boolean; + /** Refresh rate of the display the frame's window is on, `null` when unknown. Injectable for + * tests; defaults to Electron's `screen`. */ + displayHz?: (target: WebFrameMain) => number | null; } /** @@ -239,11 +249,28 @@ export interface CompositorViewServiceOptions { * has issued the draw into the canvas, not once the GPU has run it: the copy runs when the frame * carrying it is drawn, up to a vsync later. A slot written again before that copy put a later * frame on the canvas, then the one in between, so the picture and the cursor stepped back a frame - * (tens of times in 18 s at 60 Hz, a few at 280 Hz). 40 ms is more than two vsyncs at 60 Hz; it - * took the steps back to none at both rates, and the four slots still keep up with 60 fps. + * (tens of times in 18 s at 60 Hz, a few at 280 Hz). 40 ms took the steps back to none at both + * rates. On a slower display the copy can come later still: see `sharedSlotHoldMs`. */ export const SHARED_SLOT_RELEASE_DELAY_MS = 40; +/** How long to hold a released slot for a window on a `hz` display: two of its frames, never less + * than `SHARED_SLOT_RELEASE_DELAY_MS` (67 ms at 30 Hz, 40 ms from 50 Hz up). */ +export function sharedSlotHoldMs(hz: number | null): number { + if (!hz || !Number.isFinite(hz) || hz <= 0) return SHARED_SLOT_RELEASE_DELAY_MS; + return Math.max(SHARED_SLOT_RELEASE_DELAY_MS, Math.ceil(2000 / hz)); +} + +function defaultDisplayHz(target: WebFrameMain): number | null { + try { + const contents = webContents.fromFrame(target); + const win = contents && BrowserWindow.fromWebContents(contents); + return win ? screen.getDisplayMatching(win.getBounds()).displayFrequency : null; + } catch { + return null; + } +} + /** The two calls a shared preview frame needs from Electron's `sharedTexture` module. */ export type SharedTextureApi = Pick< Electron.SharedTexture, @@ -746,6 +773,7 @@ export class CompositorViewService { sourceTimeSec: frame.sourceTimeSec, }; const api = this.sharedTextureApi(); + const holdMs = sharedSlotHoldMs((this.options.displayHz ?? defaultDisplayHz)(target)); let imported: Electron.SharedTextureImported | undefined; try { if (!api) { @@ -760,10 +788,7 @@ export class CompositorViewService { // Chromium is done with the texture in every process: after a grace period, the slot // may be written again (SHARED_SLOT_RELEASE_DELAY_MS says why it waits). allReferencesReleased: () => { - setTimeout( - () => addon.releaseSharedFrame?.(id, frame.slot, frame.gen), - SHARED_SLOT_RELEASE_DELAY_MS, - ); + setTimeout(() => addon.releaseSharedFrame?.(id, frame.slot, frame.gen), holdMs); }, }); await api.sendSharedTexture({ frame: target, importedSharedTexture: imported }, meta); From e90a13d629f56eb2c6c26c1863a1eeb316c75047 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 10:50:28 +0200 Subject: [PATCH 08/24] test(compositor): the stale-ready test holds every slot but the last, whatever the ring size --- crates/compositor/src/shared_frames.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/compositor/src/shared_frames.rs b/crates/compositor/src/shared_frames.rs index cbc6eda8a..cee094618 100644 --- a/crates/compositor/src/shared_frames.rs +++ b/crates/compositor/src/shared_frames.rs @@ -298,8 +298,9 @@ mod tests { book.publish(frame(u64::from(slot) + 1, slot)); book.take(u64::from(slot), now); } - book.publish(frame(9, 3)); - assert_eq!(book.claim(RING_SLOTS), Some(3)); + let last = RING_SLOTS - 1; + book.publish(frame(u64::from(RING_SLOTS) + 1, last)); + assert_eq!(book.claim(RING_SLOTS), Some(last)); // Retirée : la livrer maintenant, pendant qu'on la réécrit, donnerait une image déchirée. assert_eq!(book.take(0, now), None); } From 523a3e4c47cf7566cc5c759ad5275f7edcd3382e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:12:37 +0000 Subject: [PATCH 09/24] chore(release): bump to 2.1.0-rc.3 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 40097283f..186407467 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-KweMgyDlb1VaKSX9g2FOTL/jLxeVBMerJ5dWvbf6Ca4="; + npmDepsHash = "sha256-kICXzPZl+zVaYLjA2sebzyFMo51lhupp2wXrySht5EU="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 8b6f50831..3c55b0617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0-rc.2", + "version": "2.1.0-rc.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0-rc.2", + "version": "2.1.0-rc.3", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index 5260afc5a..aa45b4109 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0-rc.2", + "version": "2.1.0-rc.3", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From 336366faabdd236d22b25951ec29f68e76feec7d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 16:42:30 +0200 Subject: [PATCH 10/24] fix(ai): rewind a chat message from the view it was sent in The chat panel shows a sent message before the main process stores it, under a local `local__u` id, and kept that id for good: the main process never said which id it stored the message under. A rewind names the message by id, so rewinding the message just sent failed with "Message not found in this session" until the conversation was reloaded. runChat now reports the stored id, also when the model answers nothing (the message is stored all the same), and the panel gives the message that id and its real checkpoint. A text-only turn records no checkpoint, so its rewind button no longer shows a rewind that could only fail. --- .../ai-edition/chat-service.toolloop.test.ts | 41 +++++++++++++++++++ electron/ai-edition/chat-service.ts | 4 ++ src/components/ai-edition/LeftPanel.tsx | 2 + .../ai-edition/chatTranscript.test.ts | 36 ++++++++++++++++ src/components/ai-edition/chatTranscript.ts | 21 ++++++++++ src/native/contracts.ts | 3 ++ 6 files changed, 107 insertions(+) create mode 100644 src/components/ai-edition/chatTranscript.test.ts create mode 100644 src/components/ai-edition/chatTranscript.ts diff --git a/electron/ai-edition/chat-service.toolloop.test.ts b/electron/ai-edition/chat-service.toolloop.test.ts index d140cf468..11719179d 100644 --- a/electron/ai-edition/chat-service.toolloop.test.ts +++ b/electron/ai-edition/chat-service.toolloop.test.ts @@ -200,6 +200,47 @@ describe("runChat tool loop", () => { expect(undo.prompt).toBe("cut the silence"); }); + it("reports the id it stored the user's message under, which a rewind accepts", async () => { + // The panel shows the message before the turn answers, under a local id the session does + // not know. Without this id it could only rewind after reloading the conversation. + const session = createSession("proj_rewind_sent"); + const result = await runChat( + "proj_rewind_sent", + session.id, + "cut the silence", + stubConfig(), + fixtureDocument(), + ); + const { selectSession } = await import("./chat-service"); + const stored = selectSession("proj_rewind_sent", session.id)?.messages[0]; + expect(result.userMessageId).toBe(stored?.id); + expect(result.userMessageCheckpointId).toBe(stored?.id); + + const rewound = rewindToMessage("proj_rewind_sent", session.id, result.userMessageId ?? ""); + expect(rewound.success).toBe(true); + }); + + it("reports the stored user message id when the model answers nothing", async () => { + invokeMock.mockImplementationOnce(async (args) => ({ + text: "", + document: args.document, + mutated: false, + })); + const session = createSession("proj_empty_reply"); + const result = await runChat( + "proj_empty_reply", + session.id, + "hi", + stubConfig(), + fixtureDocument(), + ); + const { selectSession } = await import("./chat-service"); + expect(result.success).toBe(false); + expect(result.userMessageId).toBe( + selectSession("proj_empty_reply", session.id)?.messages[0].id, + ); + }); + it("rewindToMessage refuses when the target has no checkpoint", async () => { const { createSession, selectSession, runChat } = await import("./chat-service"); const session = createSession("proj_rewind_no_cp"); diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index ab57b273b..e056f04b1 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -484,6 +484,9 @@ export async function runChat( return { success: false, error: result.reason ?? "Empty response from model.", + // The message is stored all the same, and can be rewound to retry it. + userMessageId: userMessage.id, + userMessageCheckpointId: userMessage.checkpointId ?? undefined, }; } @@ -508,6 +511,7 @@ export async function runChat( document: result.mutated && editsAllowed ? result.document : undefined, toolCalls: appliedToolCalls.length ? appliedToolCalls : undefined, userMessageCheckpointId: userMessage.checkpointId ?? undefined, + userMessageId: userMessage.id, }; } diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 84c5227b7..4b534ed6d 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -26,6 +26,7 @@ import { } from "../../../electron/ai-edition/provider-registry"; import { ChatWelcome } from "./ChatWelcome"; import { canSendChat } from "./chatAvailability"; +import { adoptStoredUserMessage } from "./chatTranscript"; import { ChatHistoryModal } from "./Modals"; import styles from "./NewEditorShell.module.css"; import { useChatBudget } from "./useChatBudget"; @@ -634,6 +635,7 @@ export function ChatStripPanel() { const { result, applyDocument } = await runAgentTurn((documentSnapshot) => nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot), ); + setMessages((prev) => adoptStoredUserMessage(prev, optimisticUserId, result)); const assistant = result.assistantMessage; if (result.success && assistant) { if (result.document) { diff --git a/src/components/ai-edition/chatTranscript.test.ts b/src/components/ai-edition/chatTranscript.test.ts new file mode 100644 index 000000000..6e8b19249 --- /dev/null +++ b/src/components/ai-edition/chatTranscript.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { adoptStoredUserMessage } from "./chatTranscript"; + +const local = { + id: "local_1_u", + role: "user", + content: "cut the silence", + checkpointId: "local_1_u", +}; +const earlier = { id: "m0", role: "assistant", content: "hi", checkpointId: null }; + +describe("adoptStoredUserMessage", () => { + it("gives the message just sent the id the session stored it under, so it can be rewound", () => { + const next = adoptStoredUserMessage([earlier, local], "local_1_u", { + userMessageId: "stored-1", + userMessageCheckpointId: "stored-1", + }); + expect(next[1]).toMatchObject({ + id: "stored-1", + checkpointId: "stored-1", + content: "cut the silence", + }); + expect(next[0]).toBe(earlier); + }); + + it("hides the rewind of a message stored without a checkpoint", () => { + // A text-only turn (no document open) records no snapshot: there is nothing to rewind to. + const next = adoptStoredUserMessage([local], "local_1_u", { userMessageId: "stored-1" }); + expect(next[0]).toMatchObject({ id: "stored-1", checkpointId: null }); + }); + + it("leaves the transcript as it is when the turn never stored the message", () => { + const messages = [earlier, local]; + expect(adoptStoredUserMessage(messages, "local_1_u", {})).toBe(messages); + }); +}); diff --git a/src/components/ai-edition/chatTranscript.ts b/src/components/ai-edition/chatTranscript.ts new file mode 100644 index 000000000..2d70460c1 --- /dev/null +++ b/src/components/ai-edition/chatTranscript.ts @@ -0,0 +1,21 @@ +import type { AiEditionChatResult } from "@/native/contracts"; + +/** + * The panel shows a sent message before the main process has stored it, under a `local_…` id + * the session does not know. Once the turn answers with the stored id, the message takes it, + * with its real checkpoint: a rewind names the message by that id, and with the local one it + * failed with "Message not found in this session" until the conversation was reloaded. + */ +export function adoptStoredUserMessage( + messages: M[], + localId: string, + result: Pick, +): M[] { + const storedId = result.userMessageId; + if (!storedId) return messages; + return messages.map((message) => + message.id === localId + ? { ...message, id: storedId, checkpointId: result.userMessageCheckpointId ?? null } + : message, + ); +} diff --git a/src/native/contracts.ts b/src/native/contracts.ts index c837b216e..521cc993e 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -407,6 +407,9 @@ export interface AiEditionChatResult { error?: string; /** Document checkpoint id recorded for the user message that triggered this turn (axcut parity). */ userMessageCheckpointId?: string; + /** Id the session stored the user's message under, once it did. The panel shows the message + * before that under a local id, which a rewind cannot name. */ + userMessageId?: string; } export interface AiEditionChatRewindResult { From 9c42819858c7286c423bff9d53a28193c19becf9 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 16:48:38 +0200 Subject: [PATCH 11/24] fix(ai): point an open rewind confirmation at the stored message id --- src/components/ai-edition/LeftPanel.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 4b534ed6d..144d0b4c7 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -636,6 +636,18 @@ export function ChatStripPanel() { nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot), ); setMessages((prev) => adoptStoredUserMessage(prev, optimisticUserId, result)); + // A rewind confirmation opened during the turn names the message too. Stored without a + // checkpoint, the message has nothing to rewind to and the confirmation closes. + const storedId = result.userMessageId; + if (storedId) { + setRewindFor((open) => + open?.messageId !== optimisticUserId + ? open + : result.userMessageCheckpointId + ? { ...open, messageId: storedId } + : null, + ); + } const assistant = result.assistantMessage; if (result.success && assistant) { if (result.document) { From 89cdff2abee5ecf8dc0c55650b9796b25ee600e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:35:16 +0000 Subject: [PATCH 12/24] chore(release): bump to 2.1.0-rc.4 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 186407467..3b43942ed 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-kICXzPZl+zVaYLjA2sebzyFMo51lhupp2wXrySht5EU="; + npmDepsHash = "sha256-0vAC9QgTWS9AVl9I03YPhZMXzxIvZWJ+iD1nWxtafJQ="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 3c55b0617..a5fbf6e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0-rc.3", + "version": "2.1.0-rc.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0-rc.3", + "version": "2.1.0-rc.4", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index aa45b4109..3819c52d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0-rc.3", + "version": "2.1.0-rc.4", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From de0cea215534bdf7a965b1375f8ee69fbe719f5f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 20:37:57 +0200 Subject: [PATCH 13/24] fix(compositor): no Full Camera trail on a clip's first frame when its start is inexact in f32 The previous frame's time is held at the clip's start (297dfeac), but the test compared the frame time, an f32, with the clip's start widened to f64. The first frame of the clip after a cut lands exactly on that start (SpeedSegment::source_time clamps it there, and set_timeline_time hands it over as f32), and a start f32 rounds down, 11.388 among them, read back as 11.38799953 < 11.388: the hold was skipped, the previous frame fell in the trim's hole with the camera in its PiP corner, while the Full Camera phase, compared in f32, already had it full frame. One ghosted frame at the cut in the export, and in getFrames (macOS pass of v2.1.0-rc.3, frame 600). A start that rounds up, like 15.788090425531916, was unaffected, which is why the same check looked clean on another cut. Compare in f32, as the region bounds are. Co-Authored-By: Claude Opus 5.5 --- crates/compositor/src/frame_geometry.rs | 39 ++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index be60b69c7..2988aabc2 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3058,9 +3058,11 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // clip, elle tombe dans le trou d'un trim ou dans le clip d'avant. Les régions y valent // ce qu'elles valent hors du clip (Full Camera coupée par un trim : 0), et le flou de // mouvement traînait la caméra plein cadre jusqu'à son coin PiP sur la frame de la coupe. + // Comparé en f32, comme les bornes des régions : la première frame d'un clip tombe pile + // sur son début, et un début inexact en f32 (11.388) relu en f64 passait sous la borne. let source_t_prev = clock.source_at(clock.at(source_t) - 1.0 / FPS); let source_t_prev = match scene.and_then(|s| s.clips.get(s.active_clip_index)) { - Some(clip) if f64::from(source_t) >= clip.source_start_sec => { + Some(clip) if source_t >= clip.source_start_sec as f32 => { source_t_prev.max(clip.source_start_sec as f32) } _ => source_t_prev, @@ -4849,6 +4851,41 @@ mod tests { assert_eq!(g.w_dst_prev, g.w_dst, "pas de traînée depuis le trou du trim"); } + /// La même coupe, mais la première frame du clip tombe PILE sur son début, comme à l'export + /// (`walk_composited_timeline` pose `target_source_time as f32`), et ce début n'est pas exact + /// en f32 : `11.388f32` relu en f64 vaut 11.38799953…, sous le début du clip. La borne de la + /// frame précédente ne s'appliquait donc pas, alors que la phase Full Camera, comparée en + /// f32, voyait déjà la caméra plein cadre : frame 600 traînée vers le coin PiP à l'export et + /// dans `getFrames` (passe e2e macOS de v2.1.0-rc.3). + #[test] + fn the_first_frame_of_a_clip_starting_inside_full_camera_has_no_trail() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let scene = Scene::from_json( + r##"{ + "clips":[{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":10.001,"webcamOffsetSec":0,"hasAudio":true}, + {"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":11.388,"sourceEndSec":27.12,"webcamOffsetSec":0,"hasAudio":true}], + "layout":{"preset":"picture-in-picture","webcamSize":0.44,"webcamShape":"circle","webcamMirror":false, + "webcamPosition":{"cx":0.8577,"cy":0.8159},"webcamReactiveZoom":false}, + "effects":{"padding":0.51,"blur":false,"shadow":0.35,"roundnessFrac":0.0255,"motionBlur":0.2}, + "background":{"kind":"color","color":"#1e1e2e"}, + "zoomRegions":[], + "cameraFullscreenRegions":[{"clipIndex":0,"startSec":8.006,"endSec":10.001,"continuesAfter":true}, + {"clipIndex":1,"startSec":11.388,"endSec":14.061,"continuesBefore":true}], + "cursor":{"show":true,"size":7.76,"smoothing":0,"motionBlur":0.35,"clickBounce":1,"clipToBounds":false,"theme":"default"}, + "output":{"width":1080,"height":1920,"fps":60} + }"##, + ) + .expect("scène coupée") + .for_clip_window(1, 11.388, 27.12); + assert!(f64::from(11.388f32) < 11.388, "le cas suppose un début inexact en f32"); + let mut input = golden_input(&scene, &cfg); + input.timeline_t_override = Some(11.388f64 as f32); + let g = plan_frame(&input); + + assert_eq!(g.w_dst, [0.0, 0.0, 1.0, 1.0], "la caméra reste plein cadre à travers le trim"); + assert_eq!(g.w_dst_prev, g.w_dst, "pas de traînée depuis le trou du trim"); + } + /// La caméra en orbite, en focus auto : celle qui bouge avec le pointeur. Sans piste, elle /// rend comme l'orbite manuelle posée au centre. const ORBIT: &str = r#""orbit","focusMode":"auto""#; From 57686f5011838bb18bb5138e8df27f92ad58082d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 16 Oct 2026 22:23:11 +0200 Subject: [PATCH 14/24] fix(preview): restart the playback clock at the requested time on a held frame On Linux the live player's seek lands on the frame held at the target, which in a still stretch of a PipeWire take can be seconds earlier. The playback clock restarted at that frame's pts, so the view ran seconds behind the app's playhead and nativeSync's drift watch re-anchored it every 0.5 s, each time back to the same frame, until the playhead left the stretch. The clock now restarts at the requested time after a seek, an app re-anchor and an automatic clip switch, and the frame composed there reports it. The seek is unchanged; on Windows and macOS it lands at or after the target, so the anchor is zero there. --- crates/compositor/src/live.rs | 62 +++++++++++++++++---- crates/compositor/tests/held_frame_clock.rs | 1 + 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index d5b8bb5f9..f7becd6bd 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -57,6 +57,12 @@ fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 (screen_source_time_sec - webcam_offset_sec).max(0.0) } +/// The playback accumulator that puts the clock at `t` on a frame just positioned there. The +/// clock is the frame's pts plus `acc`, and a held frame's pts can be seconds before `t`. +fn anchor_acc(t: f64, pts: f64) -> f64 { + (t - pts).max(0.0) +} + /// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat /// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch` /// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus @@ -592,7 +598,13 @@ impl Player { } self.has_current_frame = true; - self.sync_time(comp); + if use_current { + // A frame just positioned by a seek or a clip switch can be a held one, seconds + // before the playhead: compose it at the clock, which the caller anchored there. + self.sync_time_at(comp, target_source_time.max(self.sdec.cur_time_sec())); + } else { + self.sync_time(comp); + } comp.compose_frame(sf, wf, self.idx as f32, cfg)?; self.idx = self.idx.wrapping_add(1); Ok(true) @@ -715,7 +727,9 @@ impl Player { } self.has_current_frame = true; self.use_current_on_next_step = false; - self.sync_time(comp); + // At `target_sec`, not at the held frame's pts: the cursor and zooms show where the + // playhead is, and the published position matches the app's. + self.sync_time_at(comp, target_sec.max(self.sdec.cur_time_sec())); // "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé // du temps réel pour rester cohérent si jamais consulté. self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32; @@ -1418,6 +1432,9 @@ unsafe fn maybe_start_prefetch( /// bascule est instantanée (juste un échange de champs, `Player::apply_prefetched`) ; sinon /// on retombe sur l'ouverture synchrone habituelle (`Player::set_active_clip`) — correct dans /// tous les cas, juste plus lent quand le préchargement n'a pas eu le temps de finir. +/// +/// Returns the source time the next clip starts at when the switch happened, for the caller to +/// anchor its clock there (`anchor_acc`): the clip can start inside a still stretch. #[allow(clippy::too_many_arguments)] unsafe fn advance_to_next_scene_clip( player: &mut Player, @@ -1431,9 +1448,9 @@ unsafe fn advance_to_next_scene_clip( raw_cursor: &mut Option, loaded_cursor_path: &mut String, last_smoothing: &mut f32, -) { +) -> Option { let Some(next_index) = next_clip_index(scene, *active_clip_index) else { - return; + return None; }; let next_clip = &scene.clips[next_index]; @@ -1505,8 +1522,12 @@ unsafe fn advance_to_next_scene_clip( None => comp.clear_cursor(), } *last_smoothing = -1.0; + Some(next_clip.source_start_sec) + } + Err(e) => { + eprintln!("[live] auto-advance clip: {e:#}"); + None } - Err(e) => eprintln!("[live] auto-advance clip: {e:#}"), } } @@ -1666,6 +1687,8 @@ unsafe fn render_thread( }; match switch_result { Ok(()) => { + // The app's re-anchor names the playhead: the clock restarts there. + acc = anchor_acc(request.source_time_sec, player.screen_time_sec()); // Condition sur `repositioned`, pas sur `same_media` : un repositionnement // qui a échoué est retombé sur l'ouverture complète, donc des décodeurs // ONT été fermés et le cache doit être vidé malgré des médias identiques. @@ -1861,7 +1884,9 @@ unsafe fn render_thread( // affiché jusqu'à ce qu'une autre action provoque un compose. (settle_until, last_settle) = open_settle_window(now); } - acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek + // Resynchronise l'accumulateur de lecture libre après un seek : l'horloge repart du + // temps demandé, pas du pts de la frame tenue (`anchor_acc`). + acc = anchor_acc(target, player.screen_time_sec()); } else if shared.playing.load(Ordering::Relaxed) { // BUG corrigé : la lecture libre décodait auparavant exactement 1 frame RÉELLE par // tranche de 1/60s de temps réel écoulé, quelle que soit la densité effective de @@ -1928,7 +1953,7 @@ unsafe fn render_thread( break; } if player.screen_time_sec() >= clip.source_end_sec { - advance_to_next_scene_clip( + if let Some(start) = advance_to_next_scene_clip( &mut player, &comp, scene, @@ -1940,7 +1965,9 @@ unsafe fn render_thread( &mut raw_cursor, &mut loaded_cursor_path, &mut last_smoothing, - ); + ) { + acc = anchor_acc(start, player.screen_time_sec()); + } } } } @@ -1954,9 +1981,10 @@ unsafe fn render_thread( // finit alors par boucler tout seul sur l'EOF réel (temps qui recule // brutalement). On détecte ce recul et on corrige immédiatement en enchaînant // sur le clip suivant, plutôt que de rester bloqué sur le 1er clip. + let mut crossed_anchor = None; if let (Some(scene), Some(t_before)) = (&full_scene, screen_time_before_step) { if player.screen_time_sec() < t_before { - advance_to_next_scene_clip( + crossed_anchor = advance_to_next_scene_clip( &mut player, &comp, scene, @@ -1968,9 +1996,13 @@ unsafe fn render_thread( &mut raw_cursor, &mut loaded_cursor_path, &mut last_smoothing, - ); + ) + .map(|start| anchor_acc(start, player.screen_time_sec())); } } + if let Some(anchored) = crossed_anchor { + acc = anchored; + } if !committed { // Rien n'est dû pour l'instant (hold) : `acc` reste tel quel — il continue // de s'accumuler aux ticks suivants jusqu'à ce qu'une vraie frame arrive. @@ -1978,7 +2010,8 @@ unsafe fn render_thread( } stepped = true; let after = player.screen_time_sec(); - acc = consume_acc(acc, before, after); + // A switch just anchored the clock on the new clip: nothing of the old one to consume. + acc = crossed_anchor.unwrap_or_else(|| consume_acc(acc, before, after)); n += 1; if n >= max_steps { // Rattrapage plafonné : le contenu dû est plus dense que ce qu'on peut décoder @@ -2408,6 +2441,13 @@ mod tests { use super::*; + #[test] + fn the_clock_restarts_at_the_requested_time_on_a_held_frame() { + // Before: the clock restarted at the held frame's pts, 0.9 s behind the playhead. + assert_eq!(hold_clock_sec(0.1, anchor_acc(1.0, 0.1), Some(5.0)), 1.0); + assert_eq!(anchor_acc(1.0, 1.02), 0.0, "never behind the frame's own pts"); + } + fn multiclip_scene() -> Scene { Scene::from_json(r##"{ "clips": [ diff --git a/crates/compositor/tests/held_frame_clock.rs b/crates/compositor/tests/held_frame_clock.rs index 5b7161dc2..4e6b45989 100644 --- a/crates/compositor/tests/held_frame_clock.rs +++ b/crates/compositor/tests/held_frame_clock.rs @@ -55,6 +55,7 @@ fn a_held_frame_follows_the_playback_clock() { assert!(player.present_frame(&comp, &cfg, 1.0).expect("seek"), "paused seek"); let held = player.screen_time_sec(); let at_pts = player.programme_time().expect("programme clock"); + assert_eq!(player.composed_time_sec(), 1.0f64.max(held), "a seek reports the playhead"); comp.set_programme_time(None); assert!(player.recompose_at(&comp, &cfg, held + 0.25).expect("recompose_at")); From 1180671ec993e3f18a1806ac905f5646a34c8fb6 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 00:41:42 +0200 Subject: [PATCH 15/24] fix(preview): recompose a paused frame at the time it was shown at A paused recompose (a settle pass, a param, scene or clip change) used the frame's pts. On Linux a paused seek into a still stretch shows the held frame at the requested time, so the next settle pass sent the cursor and the published position back to the held frame's pts; a frame held when playback paused had the same problem. `recompose` now uses the time the frame was last composed at. Repositioning without composing (`seek_active`, a clip swap) resets that time, and an app re-anchor sets it to the requested playhead through `anchor_at`. --- crates/compositor/src/live.rs | 23 ++++++++++++++++++--- crates/compositor/tests/held_frame_clock.rs | 13 ++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index f7becd6bd..2165d1d35 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -397,6 +397,8 @@ impl Player { self.idx = (source_time_sec * self.sdec.fps()).round().max(0.0) as u32; self.has_current_frame = true; self.use_current_on_next_step = true; + // Not composed yet: a `recompose` before the next frame must not reuse the old time. + self.composed_time_sec.set(self.sdec.cur_time_sec()); Ok(true) } @@ -430,6 +432,8 @@ impl Player { self.idx = incoming.idx; self.has_current_frame = true; self.use_current_on_next_step = true; + // Not composed yet: a `recompose` before the next frame must not reuse the old clip's time. + self.composed_time_sec.set(self.sdec.cur_time_sec()); outgoing } @@ -682,8 +686,20 @@ impl Player { } /// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param. + /// At the time it was last composed at, not at its pts: a held frame placed by a seek on + /// Linux, or held when playback paused, is shown at the playhead, and a settle pass or a + /// param change sent the cursor and the published position back to the pts. pub unsafe fn recompose(&self, comp: &Compositor, cfg: &Cfg) -> Result { - self.recompose_at(comp, cfg, self.sdec.cur_time_sec()) + self.recompose_at(comp, cfg, self.composed_time_sec.get()) + } + + /// Anchors the playhead at `t` on a frame positioned but not composed yet (a re-anchor or + /// a clip change from the app): a `recompose` composes it there, and the returned + /// accumulator puts the playback clock there (`anchor_acc`). + pub unsafe fn anchor_at(&self, t: f64) -> f64 { + let pts = self.sdec.cur_time_sec(); + self.composed_time_sec.set(t.max(pts)); + anchor_acc(t, pts) } /// Recomposes the current frame with the cursor, zooms and programme time at `clock_sec` @@ -1687,8 +1703,9 @@ unsafe fn render_thread( }; match switch_result { Ok(()) => { - // The app's re-anchor names the playhead: the clock restarts there. - acc = anchor_acc(request.source_time_sec, player.screen_time_sec()); + // The app's re-anchor names the playhead: the clock restarts there, and a + // paused recompose (the `clip_changed` branch below) composes there. + acc = player.anchor_at(request.source_time_sec); // Condition sur `repositioned`, pas sur `same_media` : un repositionnement // qui a échoué est retombé sur l'ouverture complète, donc des décodeurs // ONT été fermés et le cache doit être vidé malgré des médias identiques. diff --git a/crates/compositor/tests/held_frame_clock.rs b/crates/compositor/tests/held_frame_clock.rs index 4e6b45989..c5dad9c9e 100644 --- a/crates/compositor/tests/held_frame_clock.rs +++ b/crates/compositor/tests/held_frame_clock.rs @@ -67,10 +67,19 @@ fn a_held_frame_follows_the_playback_clock() { "programme time {advanced} instead of {at_pts} + 0.25 s" ); + // Paused there, a param change or a settle pass recomposes: the frame keeps the time it + // was shown at. At the pts, the cursor and the published position went back. comp.set_programme_time(None); assert!(player.recompose(&comp, &cfg).expect("recompose")); - assert_eq!(comp.programme_time(), Some(at_pts), "recompose stays at the pts"); - assert_eq!(player.composed_time_sec(), held); + assert_eq!(comp.programme_time(), Some(advanced), "recompose keeps the time it was composed at"); + assert_eq!(player.composed_time_sec(), held + 0.25); + + // A re-anchor positions the decoders without composing: the next recompose is at the + // anchored playhead, never at the time the frame before it was composed at. + assert!(player.seek_active(0.5).expect("seek_active"), "re-anchor"); + player.anchor_at(0.5); + assert!(player.recompose(&comp, &cfg).expect("recompose")); + assert_eq!(player.composed_time_sec(), 0.5f64.max(player.screen_time_sec())); } } From 3db4c7ca67f8e101b79711f55744e49e16d7e984 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:43:48 +0000 Subject: [PATCH 16/24] chore(release): bump to 2.1.0-rc.5 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 3b43942ed..fed298abd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-0vAC9QgTWS9AVl9I03YPhZMXzxIvZWJ+iD1nWxtafJQ="; + npmDepsHash = "sha256-6pyE2xVYXsJPwtFtnqdp1tFdoOJTa3BLtXbh9VRJ7+Y="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index a5fbf6e6e..4e95c97c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0-rc.4", + "version": "2.1.0-rc.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0-rc.4", + "version": "2.1.0-rc.5", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index 3819c52d7..c1df05238 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0-rc.4", + "version": "2.1.0-rc.5", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From 421802f3ff4b044d050d06e8b194f2572d18740d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 07:58:34 +0200 Subject: [PATCH 17/24] fix(export): no Full Camera trail at a cut whose chunk start rounds up projectRegionsToSource rounded each piece's source bounds to the ms, while the clip keeps its exact start. A piece cut by a trim is gated in f32 by the compositor (continues_before: t < start), and the clip's first frame lands on the clip's own start. Where that start rounded up (10.292957 -> 10.293, 46.34986635 -> 46.350) the first frame fell before the piece: camera back in its PiP corner, motion blur trailing it there. A start that rounded down was clean, and the same held mirrored for a piece's end before a cut. Leave a bound at a cut unrounded, so it sits exactly on the segment's bound. A region's own bounds keep their rounding: unrounding those too moved a caption and two Full Camera ramps by a frame in the same export. Every consumer divides the bounds by 1000 into an f64 startSec, so nothing downstream needs an integer. Refs #1300 (cherry picked from commit a90ae5fb30ce5cb572f4419f283e85c6f3a8bf6a) --- .../ai-edition/timeline/timelineMap.test.ts | 34 +++++++++++++++++++ src/lib/ai-edition/timeline/timelineMap.ts | 7 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index d613614bb..26e20b498 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -344,6 +344,40 @@ describe("projectRegionsToSource", () => { const out = projectRegionsToSource([partial], [c], [c], () => "x"); expect(out).toEqual([{ ...partial, startMs: 3000, endMs: 5000, clipIndex: 0 }]); }); + + // A piece at a cut is gated in f32 by the compositor (`continues_before` → `t < start`, + // `continues_after` → `t > end`), and a clip's first and last frames land on the clip's own + // bounds. A bound rounded to the ms past them left that frame out of the Full Camera piece: + // camera back in its PiP corner, motion blur trailing it there (issue #1300, cuts of the + // v2.1.0-rc.5 passes). The cuts that rounded the other way were clean: the controls. + const pieceAcross = (trimStartSec: number, trimEndSec: number) => { + const c = clip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 60, + timelineEndSec: 60, + }); + const anchored = { ...region("r", 0, 60), clipId: "c1", sourceStartSec: 0, sourceEndSec: 60 }; + const segments = resolvePlaybackSegments([c], [trim("a", trimStartSec, trimEndSec)]); + return projectRegionsToSource([anchored], segments, [c], () => "r2"); + }; + + it.each([ + 10.292957, 18.361809, 46.34986635, 6.284394, + ])("keeps the first frame after a cut at %s in the Full Camera piece", (cut) => { + const [, after] = pieceAcross(cut - 2, cut); + expect(after.continuesBefore).toBe(true); + expect(Math.fround(cut)).toBeGreaterThanOrEqual(Math.fround(after.startMs / 1000)); + }); + + it.each([ + 6.284394, 14.340314, 10.292957, + ])("keeps the last frame before a cut at %s in the Full Camera piece", (cut) => { + const [before] = pieceAcross(cut, cut + 2); + expect(before.continuesAfter).toBe(true); + expect(Math.fround(cut)).toBeLessThanOrEqual(Math.fround(before.endMs / 1000)); + }); }); describe("resolveNativePosition", () => { diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index 80f77ed4d..15c9907ba 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -689,8 +689,11 @@ export function projectRegionsToSource< out.push({ ...region, id: emitted === 0 ? region.id : makeId(), - startMs: Math.round(srcStartSec * 1000), - endMs: Math.round(srcEndSec * 1000), + // A bound at a cut is not rounded to the ms: it must sit exactly on the segment's + // bound, where native puts the clip's first or last frame. Rounded past it, that + // frame fell out of the piece and Full Camera trailed to its PiP corner (#1300). + startMs: flags.continuesBefore ? srcStartSec * 1000 : Math.round(srcStartSec * 1000), + endMs: flags.continuesAfter ? srcEndSec * 1000 : Math.round(srcEndSec * 1000), clipIndex, // Omitted rather than sent as `false`: every payload without a trim under a // modifier stays byte-for-byte what it was. From cffb06f48ca7c10f1d70dde9e501aaefb97aa1ff Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 08:18:48 +0200 Subject: [PATCH 18/24] test(timeline): assert a cut-edge piece bound equals the cut in f32 (cherry picked from commit abe64d566fa864f1bf8aa03f6efd5b6a5083c012) --- src/lib/ai-edition/timeline/timelineMap.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index 26e20b498..dbaebf9db 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -368,7 +368,7 @@ describe("projectRegionsToSource", () => { ])("keeps the first frame after a cut at %s in the Full Camera piece", (cut) => { const [, after] = pieceAcross(cut - 2, cut); expect(after.continuesBefore).toBe(true); - expect(Math.fround(cut)).toBeGreaterThanOrEqual(Math.fround(after.startMs / 1000)); + expect(Math.fround(after.startMs / 1000)).toBe(Math.fround(cut)); }); it.each([ @@ -376,7 +376,7 @@ describe("projectRegionsToSource", () => { ])("keeps the last frame before a cut at %s in the Full Camera piece", (cut) => { const [before] = pieceAcross(cut, cut + 2); expect(before.continuesAfter).toBe(true); - expect(Math.fround(cut)).toBeLessThanOrEqual(Math.fround(before.endMs / 1000)); + expect(Math.fround(before.endMs / 1000)).toBe(Math.fround(cut)); }); }); From 36b3814f0874af9a42014753c49f8dc2e5ca4953 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:38:00 +0000 Subject: [PATCH 19/24] chore(release): bump to 2.1.0-rc.6 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index fed298abd..45dc380e8 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-6pyE2xVYXsJPwtFtnqdp1tFdoOJTa3BLtXbh9VRJ7+Y="; + npmDepsHash = "sha256-cnZxifzDWfmIx+9aBKQmniFG5wbPv6Xyy9lb4KIt/Y4="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 4e95c97c8..e9c3cd23b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0-rc.5", + "version": "2.1.0-rc.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0-rc.5", + "version": "2.1.0-rc.6", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index c1df05238..0815395c5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0-rc.5", + "version": "2.1.0-rc.6", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From 514105057d7822bf0609606f5a9c93b65f93914f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:14:46 +0000 Subject: [PATCH 20/24] chore(release): bump to 2.1.0 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 45dc380e8..fd305bcf2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-cnZxifzDWfmIx+9aBKQmniFG5wbPv6Xyy9lb4KIt/Y4="; + npmDepsHash = "sha256-ZwmB2QPWLweVfkDOkjEX6ydsOMq5XsojJE4l95DUvXo="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index e9c3cd23b..a7f2b59d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0-rc.6", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0-rc.6", + "version": "2.1.0", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index 0815395c5..7ef069e46 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0-rc.6", + "version": "2.1.0", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From 0913998537d7128bc251f0c12bf28e5b532d1a8f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 16:16:22 +0200 Subject: [PATCH 21/24] fix(compositor): tilt the whole card under a filled format Under Format > Follow cursor, a tilted zoom (orbit camera, iso, left, right) cut the card's corners flat. Format fill reused the block-layout slot mask, whose rule is "the container stays flat, the footage tilts inside": the plane of the whole recording tilted and the invisible, axis-aligned slot rect clipped it. The orbit camera also framed that whole-recording plane rather than the card. Under a filled format with a tilt, the box is now the slot, the drawn cut is the zoomed window a flat fill zoom shows, and nothing masks it. The camera films the card at zoom 1, contained in its slot over the whole orbit, and its aim frames the zoom inside the card. Flat fill zooms and side-by-side / stacked layouts are unchanged. Introduced with format fill (51fc97404, v2.0.0). (cherry picked from commit 58fdafe138977c84e49221f42c5f4c8335e972e1) --- crates/compositor/src/frame_geometry.rs | 139 ++++++++++++++--- .../tests/format_fill_tilt_render.rs | 142 ++++++++++++++++++ 2 files changed, 261 insertions(+), 20 deletions(-) create mode 100644 crates/compositor/tests/format_fill_tilt_render.rs diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 2988aabc2..45909be89 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3315,17 +3315,33 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // ce correctif ; la coupe DESSINÉE ne porte plus que le crop. `remap_box` reporte la // seconde à travers le mapping de la première, ce qui conserve le cadrage exact. // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. + // + // Sous un plan incliné (angle fixe ou caméra réelle), le remplissage penche la CARTE : la + // boîte est le slot, la coupe la fenêtre zoomée qu'il montre, et rien ne la rogne. Le slot + // n'y est pas un conteneur qu'on voit, comme dans un bloc : rogner à son rect droit le plan + // de l'enregistrement entier y coupait des coins à plat. Sous la caméra réelle, c'est sa + // visée qui cadre le zoom (`regions::resolve_focus` laisse la boîte zoomer sur son centre). + let fill_tilt = + follow && (zoom_camera > 0.0 || !crate::regions::is_identity_rotation(zoom_rotation)); + let focus = if fill_tilt && zoom_camera > 0.0 { zoom_aim } else { p.focus }; let cut_ref = - cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus), follow_at(source_t)); + cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, focus), follow_at(source_t)); let cut_ref_prev = cover( - screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus), + screen_source_rect(u_max, v_max, active_crop, pp.zoom, focus), follow_at(source_t_prev), ); // Sous le remplissage, la coupe dessinée est le crop ENTIER : la fenêtre bouge, et une // coupe dessinée qui ne la contiendrait plus laisserait un trou dans le slot. Le masque du // slot (`screen_mask`) rogne le reste. let crop_cut = screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus); - let cut = if follow { crop_cut } else { cover(crop_cut, None) }; + // Le remplissage incliné dessine la fenêtre zoomée elle-même : sa boîte est le slot. + let cut = if fill_tilt { + cut_ref + } else if follow { + crop_cut + } else { + cover(crop_cut, None) + }; // Ce que l'écran montre au repos : la fenêtre couverte, suivie ou non. Le curseur, les coins // et l'ombre se mesurent sur elle, pas sur la coupe entière que le remplissage dessine. let window = cover(crop_cut, follow_at(source_t)); @@ -3356,24 +3372,38 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // même report dans la coupe (un cover la rogne). La mise au point suit le pointeur lissé // (l'orbite), pas le point visé : celui-ci reste au centre au zoom 1 et bute sur sa portée // au zoom, là où le spectateur regarde le pointeur. - let camera = (zoom_camera > 0.0).then(|| crate::camera::CameraPose { - weight: zoom_camera, - aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim, cut), - orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), - zoom: p.zoom, - press, - }); + // Sous le remplissage incliné, la coupe est déjà la fenêtre zoomée : la caméra filme la + // carte au zoom 1, entière et contenue dans son slot depuis toute l'orbite + // (`camera::View::new`), et l'œil se place sur le pointeur dans l'enregistrement entier, + // où vit le poids de l'orbite (rapportée à la fenêtre, elle sauterait à l'entrée du zoom). + let pose = |weight: f32, aim: [f32; 2], orbit: [f32; 2], zoom: f32| { + if fill_tilt { + crate::camera::CameraPose { + weight, + aim: [0.5; 2], + orbit: focus_in_cut(u_max, v_max, active_crop, orbit, crop_cut), + zoom: 1.0, + press, + } + } else { + crate::camera::CameraPose { + weight, + aim: focus_in_cut(u_max, v_max, active_crop, aim, cut), + orbit: focus_in_cut(u_max, v_max, active_crop, orbit, cut), + zoom, + press, + } + } + }; + let camera = (zoom_camera > 0.0).then(|| pose(zoom_camera, zoom_aim, zoom_orbit, p.zoom)); // La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle // ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None` // quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant. - let camera_prev = (zoom_camera_prev > 0.0).then(|| crate::camera::CameraPose { - weight: zoom_camera_prev, - aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim_prev, cut), - orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit_prev, cut), - zoom: pp.zoom, - press, - }); + let camera_prev = (zoom_camera_prev > 0.0) + .then(|| pose(zoom_camera_prev, zoom_aim_prev, zoom_orbit_prev, pp.zoom)); let focus_plane = match camera { + // Le pointeur sur la carte, là où l'orbite du remplissage n'est pas dans la coupe. + Some(_) if fill_tilt => focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), Some(pose) => pose.orbit, None => focus_in_cut(u_max, v_max, active_crop, p.focus, cut), }; @@ -3490,7 +3520,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // l'app le lève pour ces deux layouts, sur un clip qui a une caméra, et pour le // remplissage du format, qui exclut les cadres (`formatFillAvailability`). Un cadre sous // un slot est donc toujours dans un bloc : il y devient le conteneur, au repos autour. - let screen_mask = scene.is_some_and(|s| s.layout.screen_cover).then(|| { + // Le remplissage incliné n'a pas de masque : sa boîte est le slot, et la carte penche entière. + let screen_mask = (scene.is_some_and(|s| s.layout.screen_cover) && !fill_tilt).then(|| { let slot_px = [s_base[2] * rw, s_base[3] * rh]; ScreenMask { rect: s_base, @@ -6348,6 +6379,11 @@ mod tests { /// Un clip 16:9 dans une sortie 9:16 remplie (`screenCover` + `screenFollow`) : la boîte écran /// est la zone paddée entière, `follow` décide si la fenêtre suit le pointeur. fn fill_plan(follow: bool, pointer: Option<(f32, f32)>) -> FrameGeometry { + fill_plan_with(follow, pointer, "[]") + } + + /// `fill_plan`, avec ces `zoomRegions`. + fn fill_plan_with(follow: bool, pointer: Option<(f32, f32)>, zoom_regions: &str) -> FrameGeometry { let scene = Scene::from_json(&format!( r##"{{ "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], @@ -6356,8 +6392,8 @@ mod tests { "screenCover":true,"screenFollow":{follow}}}, "effects":{{"padding":0.5,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0}}, "background":{{"kind":"color","color":"#1e1e2e"}}, - "zoomRegions":[], - "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, + "zoomRegions":{zoom_regions}, + "cursor":{{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, "cropByClip":[null], "output":{{"width":1080,"height":1920,"fps":60}} }}"## @@ -6408,6 +6444,69 @@ mod tests { } } + /// Le remplissage sous un plan incliné, angle fixe ou caméra en orbite : c'est la CARTE qui + /// penche, entière, ses quatre coins arrondis. Son slot n'est pas un conteneur qu'on voit, + /// comme celui d'un bloc : y rogner le plan de l'enregistrement entier coupait des coins à plat + /// (v2.0.0 à v2.1.0). + /// + /// * aucun masque ne rogne le plan, et sa boîte est le slot ; + /// * la carte montre ce qu'un zoom à plat y montre : la même fenêtre zoomée ; + /// * le plan tient dans la sortie, centré sur le slot ; + /// * à plat, rien ne change : le slot masque toujours la boîte zoomée. + #[test] + fn a_tilted_zoom_tilts_the_whole_filled_card() { + let render = [1080.0f32, 1920.0]; + let zoom = |rotation: &str| { + format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.3,"focusY":0.6,"rotation":{rotation}}}]"#) + }; + let pointer = Some((0.85, 0.8)); + // La fenêtre que la carte montre, en UV : le slot reporté par s_dst → cut. + let seen = |g: &FrameGeometry| -> [f32; 4] { + let (s, d, c) = (g.s_ann, g.s_dst, g.cut); + let x = |f: f32| c[0] + (f - d[0]) / d[2] * (c[2] - c[0]); + let y = |f: f32| c[1] + (f - d[1]) / d[3] * (c[3] - c[1]); + [x(s[0]), y(s[1]), x(s[0] + s[2]), y(s[1] + s[3])] + }; + for (rotation, flat_rotation) in [ + (r#""iso""#, "null"), + (r#""right""#, "null"), + (ORBIT, r#"null,"focusMode":"auto""#), + ] { + let flat = fill_plan_with(true, pointer, &zoom(flat_rotation)); + assert!(!flat.tilted(), "{rotation} : garde, le zoom à plat est droit"); + assert!(flat.screen_mask.is_some(), "{rotation} : à plat, le slot masque la boîte zoomée"); + assert!(!contains(flat.s_ann, flat.s_dst, 1e-3), "{rotation} : garde, la boîte à plat déborde"); + + let g = fill_plan_with(true, pointer, &zoom(rotation)); + assert!(g.tilted(), "{rotation} : garde, l'écran doit pencher"); + assert_eq!(g.screen_mask, None, "{rotation} : un rect droit rogne la carte inclinée"); + for k in 0..4 { + assert!((g.s_dst[k] - g.s_ann[k]).abs() < 1e-5, "{rotation} : boîte {:?}, slot {:?}", g.s_dst, g.s_ann); + } + let (w, wf) = (seen(&g), seen(&flat)); + for k in 0..4 { + assert!((w[k] - wf[k]).abs() < 1e-4, "{rotation} : fenêtre {w:?}, à plat {wf:?}"); + } + + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("incliné"); + let c = g.screen_center_px(render); + let slot_c = [(g.s_ann[0] + g.s_ann[2] * 0.5) * render[0], (g.s_ann[1] + g.s_ann[3] * 0.5) * render[1]]; + let mut mean = [0.0f32; 2]; + for &(x, y) in &quad.corners { + let (x, y) = (c[0] + x, c[1] + y); + assert!((0.0..=render[0]).contains(&x) && (0.0..=render[1]).contains(&y), "{rotation} : coin ({x}, {y}) hors de la sortie"); + mean = [mean[0] + x * 0.25, mean[1] + y * 0.25]; + } + let off = (mean[0] - slot_c[0]).hypot(mean[1] - slot_c[1]); + assert!(off < 0.1 * s_px[0].min(s_px[1]), "{rotation} : carte à {off} px du slot"); + + let cb = tilted_screen_cb(&quad, s_px, c, g.cut, g.focus_plane, g.s_radius, 0.0, false, render, g.screen_mask, None); + assert_eq!(cb.color[3], 0.0, "{rotation} : aucun slot ne rogne le plan"); + assert!(cb.radius_px > 1.0, "{rotation} : coins arrondis attendus, rayon {}", cb.radius_px); + } + } + /// `slot_scene` à 1,5 s (zoom tenu), pointeur garé en `pointer` (la caméra en orbite le suit). fn slot_plan(scene: &Scene, pointer: Option<(f32, f32)>) -> FrameGeometry { let cfg = crate::config::all().pop().expect("au moins une config"); diff --git a/crates/compositor/tests/format_fill_tilt_render.rs b/crates/compositor/tests/format_fill_tilt_render.rs new file mode 100644 index 000000000..2adb8b15d --- /dev/null +++ b/crates/compositor/tests/format_fill_tilt_render.rs @@ -0,0 +1,142 @@ +//! Le remplissage du format sous la caméra en orbite, rendu par le vrai compositeur D3D11. +//! +//! Un enregistrement 4:3 remplit une sortie 16:9 (`screenCover` + `screenFollow`), zoom ×2 en +//! orbite, pointeur garé en bas à droite : la caméra tourne à fond. La carte doit pencher ENTIÈRE, +//! ses quatre coins arrondis. De v2.0.0 à v2.1.0, le plan de l'enregistrement entier penchait +//! dans le rect droit du slot, qui le rognait : des coins coupés à plat, par une arête alignée sur +//! les axes, alors que la carte tenait dans la sortie. +//! +//! Frame NV12 synthétique et unie, fond noir, sans ombre : le métrage est clair, tout le reste +//! noir. Sans adaptateur matériel, le test se saute. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::frame_geometry::{live_params_from_scene, plan_frame, FrameGeometryInput}; +use openscreen_compositor::scene::Scene; + +mod common; +use common::{gpu, Nv12Frame}; + +const SRC: (u32, u32) = (640, 480); +const OUT: (u32, u32) = (1280, 720); +/// En plein palier de la région (0..10 s), le cadreur posé. +const T: f32 = 3.0; + +fn scene() -> Scene { + Scene::from_json( + r##"{"clips":[{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}], + "layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{"x":0.1,"y":0.1,"width":0.8,"height":0.8},"screenCover":true,"screenFollow":true}, + "effects":{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.04,"motionBlur":0,"depthOfField":false}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[{"clipIndex":0,"startSec":0,"endSec":10,"scale":2.0,"focusX":0.5,"focusY":0.5,"focusMode":"auto","rotation":"orbit"}], + "annotations":[], + "cursor":{"show":true,"size":0.05,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"none"}, + "cropByClip":[null], + "output":{"width":1280,"height":720,"fps":30}}"##, + ) + .expect("scène valide") +} + +/// Pointeur garé en (x, y), échantillonné à 60 Hz sur 10 s, écrit dans un sidecar temporaire. +fn parked(x: f32, y: f32) -> CursorTrack { + let body: Vec = + (0..=600).map(|i| format!(r#"{{"timeMs":{},"cx":{x},"cy":{y}}}"#, (i as f32 * 1000.0 / 60.0).round())).collect(); + let path = std::env::temp_dir().join(format!("os_fill_tilt_{}.json", std::process::id())); + std::fs::write(&path, format!(r#"{{"samples":[{}]}}"#, body.join(","))).expect("sidecar"); + let track = CursorTrack::load(path.to_str().unwrap(), 0.0, 10.0).expect("piste curseur"); + let _ = std::fs::remove_file(&path); + track +} + +fn cfg() -> Cfg { + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + cfg +} + +/// Hors de la sortie, rien n'est dessiné : noir, comme le fond. +fn luma(rgba: &[u8], x: f32, y: f32) -> f32 { + let (x, y) = (x.round() as i32, y.round() as i32); + if !(0..OUT.0 as i32).contains(&x) || !(0..OUT.1 as i32).contains(&y) { + return 0.0; + } + let i = ((y * OUT.0 as i32 + x) * 4) as usize; + 0.2126 * rgba[i] as f32 + 0.7152 * rgba[i + 1] as f32 + 0.0722 * rgba[i + 2] as f32 +} + +/// Chaque coin de la carte inclinée est arrondi : sur la bissectrice, à 0,15 rayon du sommet +/// projeté, le fond (l'arrondi l'a retiré) ; à 1,3 rayon, le métrage. Un coin coupé par le rect +/// du slot laissait du noir là où la carte devait être, ou la poussait hors de la sortie. +#[test] +fn every_corner_of_a_filled_card_stays_rounded_under_the_orbit() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = Nv12Frame::new(&gpu, SRC, |_, _| 220, |_, _| [128, 128]); + let scene = scene(); + let track = parked(0.9, 0.9); + + let mut live = live_params_from_scene(&scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(T)); + comp.set_timeline_time(Some(T)); + let cfg = cfg(); + let (w, h, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + assert_eq!((w, h), OUT); + if let Ok(dir) = std::env::var("OPENSCREEN_FILL_TILT_OUT") { + image::RgbaImage::from_raw(w, h, rgba.clone()).unwrap().save(format!("{dir}/fill-orbit.png")).unwrap(); + } + + let render_px = [w as f32, h as f32]; + let src = [SRC.0 as f32, SRC.1 as f32]; + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(&track), + timeline_t_override: Some(T), + programme_time: None, + }); + let camera = g.camera.expect("la caméra réelle doit être posée"); + assert!(camera.orbit[0] > 0.8, "garde : la caméra doit tourner à fond, orbite {:?}", camera.orbit); + let s_px = [g.s_dst[2] * render_px[0], g.s_dst[3] * render_px[1]]; + let quad = g.screen_tilt(s_px).expect("quad de la caméra"); + let center = [(g.s_dst[0] + g.s_dst[2] * 0.5) * render_px[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render_px[1]]; + let r = g.s_radius * quad.scale; + assert!(r > 20.0, "garde : rayon {r} px, trop petit pour se mesurer"); + + let corners = quad.corners.map(|(x, y)| [center[0] + x, center[1] + y]); + let mid = [corners.iter().map(|c| c[0]).sum::() / 4.0, corners.iter().map(|c| c[1]).sum::() / 4.0]; + for (name, c) in ["haut-gauche", "haut-droit", "bas-droit", "bas-gauche"].iter().zip(corners) { + let (dx, dy) = (mid[0] - c[0], mid[1] - c[1]); + let n = dx.hypot(dy); + let at = |k: f32| luma(&rgba, c[0] + dx / n * k * r, c[1] + dy / n * k * r); + let (rounded, inside) = (at(0.15), at(1.3)); + println!("coin {name} ({:.0}, {:.0}) : arrondi {rounded:.0}, dedans {inside:.0}", c[0], c[1]); + assert!(rounded < 40.0, "coin {name} : pas arrondi (luma {rounded:.0} au sommet)"); + assert!(inside > 150.0, "coin {name} : la carte est coupée (luma {inside:.0} à 1,3 rayon)"); + } +} From ce866df3dd99bd42d276905a51f1258c6a203d81 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 22:38:41 +0200 Subject: [PATCH 22/24] fix(compositor): keep blur masks and motion blur right on a tilted filled card A tilted filled card draws only the zoomed window (`cut`), while blur annotations, shader effects and the editor's gimbal give fractions of the whole recording. Map those fractions from the cut a flat frame shows (`content_cut`) into the drawn plane in `privacy_mask`, `footage_quad` and `footage_aspect`, and clip a mask to the card. Without it, a blur landed hundreds of pixels away from the secret it should hide. The card is fixed in its slot and only the footage moves inside it, so its motion blur stays per pixel (`screen_trail` returns false), as under a block layout's mask. The rigid mode-18 blur smeared the card's edges and corners along the footage's path. Both are identities outside a tilted filled card. (cherry picked from commit 124f622474f4d6c9e898bf7c141063c7ace7297e) --- crates/compositor/src/frame_geometry.rs | 152 +++++++++++++++++++++--- 1 file changed, 137 insertions(+), 15 deletions(-) diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 45909be89..f7affa9dc 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1932,6 +1932,13 @@ pub struct FrameGeometry { pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], + /// La coupe que montrerait le même écran à plat : le repère du métrage (annotations, effets, + /// gimbal de l'éditeur). Égale à `cut`, sauf sous le remplissage incliné, où `cut` n'est plus + /// que la fenêtre zoomée qu'en montre la carte (`content_to_plane`). + pub content_cut: [f32; 4], + /// Remplissage du format sous un plan incliné : la carte penche dans son slot, fixe, et seul + /// le métrage bouge dedans. Son flou de mouvement reste donc par pixel (`screen_trail`). + pub fill_tilt: bool, /// Point de focus du zoom (résolu : suivi curseur et rampe compris) en 0..1 DANS la coupe /// dessinée `cut`, c'est-à-dire dans le plan que le mode 8 incline. Borné au plan : un focus /// que le cover a rogné retombe sur le bord. C'est lui qui fixe `z_focus` (`depth_mb`). @@ -2177,10 +2184,11 @@ impl FrameGeometry { /// Incliné, le plan a bougé quand ses coins ont bougé : sans mouvement, ceux de `tilt_trail` /// sont les siens au bit près, et le rendu reste celui d'avant, à l'octet. /// - /// Pas sous un masque de bloc : la case ne bouge pas, seul le métrage bouge dedans, et le - /// flou par pixel (modes 0 et 8) reste le bon. + /// Pas sous un masque de bloc ni sous le remplissage incliné : la case ou la carte ne bouge + /// pas, seul le métrage bouge dedans, et le flou par pixel (modes 0 et 8) reste le bon. pub fn screen_trail(&self, render_px: [f32; 2]) -> bool { - if self.mb_taps < 2.0 || self.mb_amount <= 0.001 || self.screen_mask.is_some() { + if self.mb_taps < 2.0 || self.mb_amount <= 0.001 || self.screen_mask.is_some() || self.fill_tilt + { return false; } match self.screen_tilt_in(render_px) { @@ -2760,14 +2768,31 @@ impl FrameGeometry { (self.s_dst[0] + self.s_dst[2] * 0.5) * rw, (self.s_dst[1] + self.s_dst[3] * 0.5) * rh, ]; + // Sous le remplissage incliné, le plan ne montre que la fenêtre zoomée (`cut`) d'un + // métrage dont l'annotation donne les fractions (`content_cut`) : le grossissement et la + // boîte sont ceux du même écran à plat. Ailleurs, l'échelle vaut 1 au bit près. + let scale = self.content_scale(); + let zoom_k = zoom_k * scale[1]; + let content_px = [s_px[0] * scale[0], s_px[1] * scale[1]]; // La marge se prend en fraction du contenu, comme sur le chemin droit : `zoom_k` px de // la boîte zoomée, soit un pixel de la boîte au repos. La projection l'emmène ensuite // avec le contenu qu'elle borde. let (mx, my) = ( - PRIVACY_MASK_PAD_PX * zoom_k / s_px[0].max(1.0), - PRIVACY_MASK_PAD_PX * zoom_k / s_px[1].max(1.0), + PRIVACY_MASK_PAD_PX * zoom_k / content_px[0].max(1.0), + PRIVACY_MASK_PAD_PX * zoom_k / content_px[1].max(1.0), ); - let (x0, y0, x1, y1) = (x - mx, y - my, x + w + mx, y + h + my); + let [x0, y0] = self.content_to_plane([x - mx, y - my]); + let [x1, y1] = self.content_to_plane([x + w + mx, y + h + my]); + // Rogné à la carte du remplissage : ce qu'elle ne montre pas n'a rien à cacher, et un + // masque hors d'elle flouterait le fond. + let (x0, y0, x1, y1) = if self.fill_tilt { + (x0.max(0.0), y0.max(0.0), x1.min(1.0), y1.min(1.0)) + } else { + (x0, y0, x1, y1) + }; + if x1 <= x0 || y1 <= y0 { + return None; + } let at = |fx: f32, fy: f32| { let (px, py) = quad.point_px(fx, fy); [centre[0] + px, centre[1] + py] @@ -2845,7 +2870,10 @@ impl FrameGeometry { let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh]; if let Some(quad) = self.screen_tilt(s_px).filter(|_| self.tilted() && rw > 0.0 && rh > 0.0) { let centre = self.screen_center_px(render_px); + // Les coins du métrage entier (`content_cut`), au-delà de la carte sous le + // remplissage incliné : le warp prolongé est exact, bilinéaire comme projectif. let at = |fx: f32, fy: f32| { + let [fx, fy] = self.content_to_plane([fx, fy]); let (px, py) = quad.point_px(fx, fy); [(centre[0] + px) / rw, (centre[1] + py) / rh] }; @@ -2860,7 +2888,29 @@ impl FrameGeometry { /// Largeur sur hauteur du métrage dans son propre plan : celles du rect qu'incline le tilt. pub fn footage_aspect(&self, render_px: [f32; 2]) -> f32 { - self.s_dst[2] * render_px[0] / (self.s_dst[3] * render_px[1]).max(1e-6) + let scale = self.content_scale(); + self.s_dst[2] * render_px[0] * scale[0] / (self.s_dst[3] * render_px[1] * scale[1]).max(1e-6) + } + + /// Un point du métrage, en fractions de `content_cut` (le repère des annotations, des effets + /// et du gimbal), en fractions du plan dessiné (`cut`). L'identité, au bit près, hors du + /// remplissage incliné. + fn content_to_plane(&self, f: [f32; 2]) -> [f32; 2] { + if self.content_cut == self.cut { + return f; + } + let (c, k) = (self.content_cut, self.cut); + let map = |f: f32, a0: f32, a1: f32, b0: f32, b1: f32| (a0 + f * (a1 - a0) - b0) / (b1 - b0).max(1e-6); + [map(f[0], c[0], c[2], k[0], k[2]), map(f[1], c[1], c[3], k[1], k[3])] + } + + /// Combien de fois le métrage dépasse le plan dessiné, par axe : 1 hors du remplissage incliné. + fn content_scale(&self) -> [f32; 2] { + if self.content_cut == self.cut { + return [1.0; 2]; + } + let (c, k) = (self.content_cut, self.cut); + [(c[2] - c[0]) / (k[2] - k[0]).max(1e-6), (c[3] - c[1]) / (k[3] - k[1]).max(1e-6)] } } @@ -3334,14 +3384,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // coupe dessinée qui ne la contiendrait plus laisserait un trou dans le slot. Le masque du // slot (`screen_mask`) rogne le reste. let crop_cut = screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus); + let flat_cut = if follow { crop_cut } else { cover(crop_cut, None) }; // Le remplissage incliné dessine la fenêtre zoomée elle-même : sa boîte est le slot. - let cut = if fill_tilt { - cut_ref - } else if follow { - crop_cut - } else { - cover(crop_cut, None) - }; + let cut = if fill_tilt { cut_ref } else { flat_cut }; // Ce que l'écran montre au repos : la fenêtre couverte, suivie ou non. Le curseur, les coins // et l'ombre se mesurent sur elle, pas sur la coupe entière que le remplissage dessine. let window = cover(crop_cut, follow_at(source_t)); @@ -3591,6 +3636,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { camera_prev, padding_scale, cut, + content_cut: flat_cut, + fill_tilt, focus_plane, // Sans scène (bench fixture), aucune zoom region, donc aucun tilt : rien à défocaliser. depth_of_field: scene.is_some_and(|s| s.effects.depth_of_field), @@ -6384,6 +6431,15 @@ mod tests { /// `fill_plan`, avec ces `zoomRegions`. fn fill_plan_with(follow: bool, pointer: Option<(f32, f32)>, zoom_regions: &str) -> FrameGeometry { + fill_plan_track(follow, pointer.map(|(x, y)| parked_track(x, y)), zoom_regions) + } + + /// `fill_plan_with`, sur cette piste curseur. + fn fill_plan_track( + follow: bool, + track: Option<&'static crate::cursor::CursorTrack>, + zoom_regions: &str, + ) -> FrameGeometry { let scene = Scene::from_json(&format!( r##"{{ "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], @@ -6402,7 +6458,7 @@ mod tests { let cfg = crate::config::all().pop().expect("au moins une config"); let mut input = golden_input(&scene, &cfg); input.render_px = [1080.0, 1920.0]; - input.cursor = pointer.map(|(x, y)| parked_track(x, y)); + input.cursor = track; plan_frame(&input) } @@ -6507,6 +6563,70 @@ mod tests { } } + /// Sous le remplissage incliné, la carte ne montre que la fenêtre zoomée : un flou posé sur + /// le métrage (ses fractions sont celles de l'enregistrement entier) couvre toujours le + /// contenu qu'il cache, là où le curseur, passé par la coupe, le voit. Le gimbal de l'éditeur + /// (`footage_quad`) pose ce point au même endroit, et l'aspect est celui de l'enregistrement. + #[test] + fn a_privacy_mask_covers_its_content_on_a_tilted_filled_card() { + let render = [1080.0f32, 1920.0]; + let p = (0.85f32, 0.8f32); + let uv_max = [1.0, 1080.0 / 1088.0]; + for rotation in [r#""iso""#, ORBIT] { + let zoom = format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.85,"focusY":0.8,"rotation":{rotation}}}]"#); + let g = fill_plan_with(true, Some(p), &zoom); + assert!(g.fill_tilt, "{rotation} : garde, remplissage incliné"); + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("incliné"); + let c = g.screen_center_px(render); + // Où le contenu en `p` est dessiné, par la coupe comme le curseur. + let plane = cursor_plane_point(g.cut, uv_max, p).expect("garde : le point est sur la carte"); + let (sx, sy) = quad.point_px(plane[0], plane[1]); + let seen = [c[0] + sx, c[1] + sy]; + + let mut a = blur_annotation(""); + (a.w, a.h) = (0.04, 0.04); + (a.x, a.y) = (p.0 - 0.02, p.1 - 0.02); + let m = g.privacy_mask(&a, render).expect("masque"); + let pts = m.warp.expect("incliné").map(|[x, y]| [m.dst[0] * render[0] + x, m.dst[1] * render[1] + y]); + let cross = |a: [f32; 2], b: [f32; 2], q: [f32; 2]| (b[0] - a[0]) * (q[1] - a[1]) - (b[1] - a[1]) * (q[0] - a[0]); + let sides: Vec = (0..4).map(|i| cross(pts[i], pts[(i + 1) % 4], seen)).collect(); + assert!( + sides.iter().all(|&s| s >= 0.0) || sides.iter().all(|&s| s <= 0.0), + "{rotation} : le masque {pts:?} rate le contenu, dessiné en {seen:?}" + ); + // Le grain grandit avec le contenu, comme à plat. + assert!(m.strength > 1.5, "{rotation} : force {}", m.strength); + + // Le gimbal : le métrage entier, que la carte ne montre qu'en partie. + let f = g.footage_quad(render); + assert!(f.corners.iter().any(|q| q[0] < 0.0 || q[0] > 1.0 || q[1] < 0.0 || q[1] > 1.0), "{rotation} : gimbal réduit à la carte, {:?}", f.corners); + let ar = g.footage_aspect(render); + assert!((ar - 1920.0 / 1080.0).abs() < 1e-2, "{rotation} : aspect {ar}"); + } + } + + /// Le flou de mouvement de la carte inclinée du remplissage reste par pixel : la carte est + /// fixe dans son slot, seul le métrage bouge dedans. Le flou rigide (mode 18) l'étalait le + /// long du chemin du contenu, bords et coins compris. + #[test] + fn a_tilted_filled_card_keeps_a_per_pixel_motion_blur() { + let moving: &'static crate::cursor::CursorTrack = Box::leak(Box::new(crate::cursor::CursorTrack::new( + (0..=150).map(|i| (i as f32 / 30.0, 0.2 + 0.6 * i as f32 / 150.0, 0.5)).collect(), + vec![], + vec![], + ))); + let render = [1080.0f32, 1920.0]; + let zoom = format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":{ORBIT}}}]"#); + // Le pointeur en mouvement : la fenêtre suivie glisse d'une frame à l'autre. + let mut g = fill_plan_track(true, Some(moving), &zoom); + (g.mb_taps, g.mb_amount) = (8.0, 0.5); + assert!(g.fill_tilt, "garde : remplissage incliné"); + assert_ne!(g.s_dst_prev, g.s_dst, "garde : le métrage bouge dans la carte"); + assert!(!g.screen_trail(render), "flou rigide sur une carte fixe"); + assert!(g.tilt_pixel_trail(render).is_some(), "le flou par pixel suit le métrage"); + } + /// `slot_scene` à 1,5 s (zoom tenu), pointeur garé en `pointer` (la caméra en orbite le suit). fn slot_plan(scene: &Scene, pointer: Option<(f32, f32)>) -> FrameGeometry { let cfg = crate::config::all().pop().expect("au moins une config"); @@ -8033,6 +8153,8 @@ mod tests { camera_prev: None, padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], + content_cut: [0.0, 0.0, 1.0, 1.0], + fill_tilt: false, focus_plane: [0.5, 0.5], depth_of_field: false, s_dst: [0.0, 0.0, 1.0, 1.0], From 6ec38bfdefb259bafb35d23876a6d5f93d25cf63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:59:25 +0000 Subject: [PATCH 23/24] chore(release): bump to 2.1.1-rc.1 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index fd305bcf2..57c8c4e8b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-ZwmB2QPWLweVfkDOkjEX6ydsOMq5XsojJE4l95DUvXo="; + npmDepsHash = "sha256-E5d/tsxcUW7FJZSoGSWWY4CtzQK5F1pXznrig2aYSBE="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index a7f2b59d9..889d5f164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.0", + "version": "2.1.1-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.0", + "version": "2.1.1-rc.1", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index 7ef069e46..ba864bdd3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.0", + "version": "2.1.1-rc.1", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT", From 0bcd3d9b6dfd5265733cf23bc60750c29a3ad464 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:26:18 +0000 Subject: [PATCH 24/24] chore(release): bump to 2.1.1 [skip ci] --- nix/package.nix | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 57c8c4e8b..0cdcdbe80 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,7 +52,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-E5d/tsxcUW7FJZSoGSWWY4CtzQK5F1pXznrig2aYSBE="; + npmDepsHash = "sha256-gs1OEgX7mkqj9REMhVUUfSmPYsL6VLj/Au5AU6TfOi0="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 889d5f164..5e5e210eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "2.1.1-rc.1", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "2.1.1-rc.1", + "version": "2.1.1", "license": "MIT", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/package.json b/package.json index ba864bdd3..8ac987552 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "2.1.1-rc.1", + "version": "2.1.1", "description": "Record your screen and polish the demo", "homepage": "https://getopenscreen.com/", "license": "MIT",