diff --git a/crates/compositor/examples/live_free_run_bench_macos.rs b/crates/compositor/examples/live_free_run_bench_macos.rs new file mode 100644 index 000000000..8b3ec5c3b --- /dev/null +++ b/crates/compositor/examples/live_free_run_bench_macos.rs @@ -0,0 +1,125 @@ +//! Bench de la lecture libre de la preview (macOS) : rejoue la boucle de `render_thread` sur +//! un seul clip, à une vitesse donnée, et mesure le retard que la vue prend sur l'horloge. +//! +//! Même arithmétique que `render_thread` : l'accumulateur avance de `dt × vitesse`, `step()` +//! compose chaque frame due jusqu'à `max_steps` par tick (au-delà le reliquat est abandonné), +//! puis relit le RT UNE fois par tick, comme la publication macOS (pas de textures partagées). +//! `dt` est borné à 0,1 s comme dans `render_thread` : un tick plus long perd de l'horloge. +//! +//! Utilisation : +//! `cargo run --release --example live_free_run_bench_macos -- [vitesse=4] [secondes=8] [largeur=1920] [hauteur=1080]` + +#[cfg(target_os = "macos")] +fn main() -> anyhow::Result<()> { + use openscreen_compositor::compositor::Compositor; + use openscreen_compositor::config; + use openscreen_compositor::d3d::Gpu; + use openscreen_compositor::frame_geometry::live_params_from_scene; + use openscreen_compositor::live::Player; + use openscreen_compositor::scene::Scene; + use std::time::{Duration, Instant}; + + let mut args = std::env::args().skip(1); + let path = args.next().unwrap_or_else(|| { + eprintln!("usage: live_free_run_bench_macos [vitesse=4] [secondes=8] [largeur=1920] [hauteur=1080]"); + std::process::exit(2); + }); + let speed: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(4.0); + let seconds: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(8.0); + let width: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1920); + let height: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1080); + + let source = path.replace('\\', "/"); + let scene_json = format!( + r##"{{ + "clips": [{{"screenPath":"{source}","webcamPath":"","sourceStartSec":0,"sourceEndSec":100000,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#303030"}}, + "zoomRegions": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{width},"height":{height},"fps":30}} + }}"## + ); + + let gpu = Gpu::create(false)?; + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(&gpu, width, height)?; + let scene = Scene::from_json(&scene_json)?; + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene.clone())); + comp.clear_cursor(); + + unsafe { + let mut player = Player::open(&path, "", &gpu)?; + player.set_programme_clock(Some(&scene), 0); + anyhow::ensure!(player.present_frame(&comp, &cfg, 0.0)?, "aucune frame à 0 s"); + // Temps source réellement joué, cumulé frame par frame : un clip plus court que la mesure + // reboucle à 0 (EOF), et la seule position finale compterait alors une seule passe. + let mut played = 0.0f64; + + let mut acc = 0.0f64; + let mut last = Instant::now(); + let t0 = last; + let (mut ticks, mut adopted, mut capped, mut published) = (0u64, 0u64, 0u64, 0u64); + // Ce que l'horloge de l'app a parcouru, en temps source : la cible que la vue devrait tenir. + let mut clock_source = 0.0f64; + while t0.elapsed().as_secs_f64() < seconds { + let now = Instant::now(); + let dt = (now - last).as_secs_f64().min(0.1); + last = now; + ticks += 1; + acc += dt * speed; + clock_source += (now - t0).as_secs_f64() * speed - clock_source; + let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64); + let mut n = 0; + let mut stepped = false; + loop { + let before = player.screen_time_sec(); + if !player.step(&comp, &cfg, before + acc)? { + break; + } + stepped = true; + adopted += 1; + let after = player.screen_time_sec(); + played += if after >= before { after - before } else { after }; + acc = if after >= before { (acc - (after - before)).max(0.0) } else { 0.0 }; + n += 1; + if n >= max_steps { + capped += 1; + acc = 0.0; + break; + } + } + if stepped { + comp.readback_direct()?; + published += 1; + } else { + std::thread::sleep(Duration::from_millis(4)); + } + } + let wall = t0.elapsed().as_secs_f64(); + println!( + "vitesse {speed}x, {width}x{height}, {wall:.2} s : horloge {:.2} s source, vue {played:.2} s source, retard {:.2} s source ({:.2} s de lecture)", + clock_source, + clock_source - played, + (clock_source - played) / speed + ); + println!( + "ticks {ticks} ({:.1}/s), frames adoptées {adopted} ({:.1}/s), plafond atteint {capped} fois, publiées {published} ({:.1}/s)", + ticks as f64 / wall, + adopted as f64 / wall, + published as f64 / wall + ); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("live_free_run_bench_macos : macOS uniquement"); +} diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 8224118e3..8f56f22a2 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -109,9 +109,13 @@ struct PrefetchedClip { /// `should_draw_webcam`). /// /// ponytail: on garde le remplaçant plutôt que de passer `wdec` en `Option`, ce qui -/// toucherait 22 sites dont le pool de décodeurs et la boucle de composition `unsafe`. À faire -/// si quelqu'un mesure que le décodeur inutile coûte (VRAM des pools D3D11VA, une ouverture -/// par clip) — l'avertissement ci-dessous dit enfin à quelle fréquence le cas visible arrive. +/// toucherait 22 sites dont le pool de décodeurs et la boucle de composition `unsafe`. Il est +/// ouvert mais n'est plus jamais avancé ni recherché : la composition reçoit la frame écran à +/// sa place (`Player::webcam_frame`), l'image même qu'il aurait décodée. Le décoder à côté de +/// l'écran coûtait la moitié du débit de décodage, sans caméra, c'est-à-dire le cas courant : +/// une source 4K lue à 2× plafonnait à ~46 frames/s pour 97 que le décodeur seul tient, et la +/// preview prenait des secondes de retard dans les régions de vitesse. Reste à faire si la +/// VRAM des pools D3D11VA ou l'ouverture par clip se mesurent. unsafe fn open_webcam_or_stand_in( screen_path: &str, webcam_path: &str, @@ -156,9 +160,8 @@ unsafe fn open_and_seek_clip( let mut sdec = Decoder::open(screen_path, gpu)?; let (mut wdec, webcam_decoder_is_real) = open_webcam_or_stand_in(screen_path, webcam_path, gpu)?; let sf = sdec.seek_to_or_last(source_time_sec)?; - let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; - if wf.is_null() { - wf = wdec.seek_to(0.0)?; + if webcam_decoder_is_real && wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?.is_null() { + wdec.seek_to(0.0)?; } if sf.is_null() { anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")"); @@ -192,6 +195,7 @@ struct PooledClip { unsafe fn seek_pair( sdec: &mut Decoder, wdec: &mut Decoder, + webcam_decoder_is_real: bool, source_time_sec: f64, webcam_offset_sec: f64, ) -> Result { @@ -199,6 +203,10 @@ unsafe fn seek_pair( if sf.is_null() { return Ok(false); } + // Le remplaçant n'est jamais lu (`Player::webcam_frame`) : rien à positionner. + if !webcam_decoder_is_real { + return Ok(true); + } let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; if wf.is_null() { wf = wdec.seek_to(0.0)?; @@ -237,7 +245,13 @@ unsafe fn swap_clip_pooled( let mut pooled = pool.remove(i); // Reseek les décodeurs poolés AVANT de les installer. Échec → on les jette et on // ouvre à neuf (chemin connu sûr), jamais une frame vide. - if seek_pair(&mut pooled.clip.sdec, &mut pooled.clip.wdec, t, request.webcam_offset_sec)? { + if seek_pair( + &mut pooled.clip.sdec, + &mut pooled.clip.wdec, + pooled.clip.webcam_decoder_is_real, + t, + request.webcam_offset_sec, + )? { pooled.clip.idx = (t * pooled.clip.sdec.fps()).round().max(0.0) as u32; hit = true; pooled.clip @@ -362,7 +376,13 @@ impl Player { // Les DEUX flux doivent avoir une frame : `compose_frame` les échantillonne tous les // deux sans condition, un seul manquant suffit à le faire échouer (d'où le `false` que // `seek_pair` peut rendre → l'appelant retombe sur l'ouverture complète). - if !seek_pair(&mut self.sdec, &mut self.wdec, source_time_sec, self.webcam_offset_sec)? { + if !seek_pair( + &mut self.sdec, + &mut self.wdec, + self.webcam_decoder_is_real, + source_time_sec, + self.webcam_offset_sec, + )? { return Ok(false); } self.idx = (source_time_sec * self.sdec.fps()).round().max(0.0) as u32; @@ -424,6 +444,17 @@ impl Player { self.webcam_decoder_is_real } + /// La frame webcam à composer avec la frame écran `screen` : celle de la caméra, ou sans + /// caméra la frame écran elle-même, que le remplaçant aurait décodée à l'identique et que + /// la composition ne dessine pas (`should_draw_webcam`). + unsafe fn webcam_frame(&self, screen: *mut crate::ffi::AVFrame) -> *mut crate::ffi::AVFrame { + if self.webcam_decoder_is_real { + self.wdec.cur_frame() + } else { + screen + } + } + /// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le /// franchissement de la fin de fenêtre du clip actif pendant la lecture libre, et pour /// calculer la cible de `step` en lecture libre. `pub` (pas `pub(crate)`) : le harnais @@ -432,6 +463,12 @@ impl Player { self.sdec.cur_time_sec() } + /// Temps source courant du décodeur webcam, le remplaçant compris : sans caméra il ne doit + /// jamais bouger (`tests/no_camera_stand_in.rs`). + pub unsafe fn webcam_time_sec(&self) -> f64 { + self.wdec.cur_time_sec() + } + /// Recalcule l'horloge programme pour le clip `clip_index` de `scene` (la scène COMPLÈTE, /// pas la fenêtre d'un clip : il faut la durée de sortie des clips précédents). /// @@ -512,7 +549,10 @@ impl Player { } let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0); - let wf = if use_current { + 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(); @@ -585,7 +625,7 @@ impl Player { return Ok(false); } let sf = self.sdec.cur_frame(); - let wf = self.wdec.cur_frame(); + let wf = self.webcam_frame(sf); if sf.is_null() || wf.is_null() { return Ok(false); } @@ -602,11 +642,14 @@ impl Player { /// `set_time`, incorrect au-delà de 6s sur un enregistrement réel). pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result { let sf = self.sdec.seek_to_or_last(target_sec)?; - // La webcam aussi : sans caméra, `wdec` rouvre le fichier écran, et une cible au-delà de - // sa dernière image laisserait la frame sans webcam, donc non composée. - let wf = self - .wdec - .seek_to_or_last(webcam_seek_time(target_sec, self.webcam_offset_sec))?; + // La caméra aussi, au-delà de sa dernière image comprise : sans frame webcam, la frame + // ne serait pas composée. Sans caméra, la frame écran tient sa place. + let wf = if self.webcam_decoder_is_real { + self.wdec + .seek_to_or_last(webcam_seek_time(target_sec, self.webcam_offset_sec))? + } else { + sf + }; if sf.is_null() || wf.is_null() { self.has_current_frame = false; return Ok(false); diff --git a/crates/compositor/tests/no_camera_stand_in.rs b/crates/compositor/tests/no_camera_stand_in.rs new file mode 100644 index 000000000..d741f12e9 --- /dev/null +++ b/crates/compositor/tests/no_camera_stand_in.rs @@ -0,0 +1,109 @@ +//! Sans caméra, la preview compose partout avec la frame écran à la place de la webcam : le +//! décodeur remplaçant n'est plus ni avancé ni recherché (`live::open_webcam_or_stand_in`). +//! +//! Couvre les chemins qui composent : le seek en pause (`present_frame`), y compris au-delà de +//! la dernière image, la lecture libre (`step`), et la recomposition à l'arrêt (`recompose`). +//! Une frame webcam nulle les ferait tous rendre `false`, preview figée. Vérifie aussi que le +//! remplaçant ne bouge pas, `seek_active` compris : composer suffit à ne rien prouver, puisqu'il +//! ouvre le même fichier valide que l'écran. +//! +//! La CI ne le joue pas : il faut un GPU et une source vidéo, sinon il se saute. N'importe quel +//! MP4 d'au moins 4 s convient : +//! +//! ```sh +//! OPENSCREEN_LIVE_SOURCE=/chemin/source.mp4 cargo test -p openscreen-compositor --test no_camera_stand_in -- --nocapture +//! ``` + +#![cfg(any(windows, target_os = "macos"))] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +fn scene_json(source: &str) -> String { + let s = source.replace('\\', "/"); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":100000,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.0,"roundnessFrac":0,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#303030"}}, + "zoomRegions": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":480,"height":270,"fps":30}} + }}"## + ) +} + +#[test] +fn every_compose_path_works_without_a_camera() { + let Ok(source) = std::env::var("OPENSCREEN_LIVE_SOURCE") else { + println!("SKIP: definir OPENSCREEN_LIVE_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let gpu = Gpu::create(false).expect("device"); + let mut cfg = config::all().pop().expect("au moins une 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("scene valide"); + 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, "", &gpu).expect("ouvrir la source"); + assert!(!player.webcam_decoder_is_real(), "pas de caméra déclarée : décodeur remplaçant"); + player.set_programme_clock(Some(&scene), 0); + + let stand_in_time = player.webcam_time_sec(); + + assert!(player.present_frame(&comp, &cfg, 1.0).expect("seek"), "seek en pause"); + let start = player.screen_time_sec(); + + // Lecture libre à 2× sur une seconde d'horloge, au pas de 1/60 s. À l'EOF, `step` + // reboucle à 0 : la cible ne serait alors plus jamais atteinte, d'où l'arrêt sur un + // temps qui recule. + let mut target = start; + let mut composed = 0; + for _ in 0..60 { + target += 2.0 / 60.0; + loop { + let before = player.screen_time_sec(); + if !player.step(&comp, &cfg, target).expect("step") { + break; + } + assert!( + player.screen_time_sec() >= before, + "la source a rebouclé avant {target:.2} s : il faut une source d'au moins 4 s" + ); + composed += 1; + } + } + assert!(composed > 0, "la lecture libre n'a composé aucune frame"); + assert!( + player.screen_time_sec() > start + 1.5, + "la lecture libre a avancé de {:.3} s au lieu de ~2 s", + player.screen_time_sec() - start + ); + + assert!(player.recompose(&comp, &cfg).expect("recompose"), "recomposition à l'arrêt"); + + // Au-delà de la dernière image : la dernière image, composée. + assert!(player.present_frame(&comp, &cfg, 100_000.0).expect("seek fin"), "seek au-delà de la fin"); + + assert!(player.seek_active(0.5).expect("seek_active"), "seek_active"); + assert!(player.step(&comp, &cfg, 0.5).expect("step après seek_active"), "step après seek_active"); + + assert_eq!( + player.webcam_time_sec().to_bits(), + stand_in_time.to_bits(), + "le décodeur remplaçant a bougé : il ne doit être ni avancé ni recherché" + ); + } +} diff --git a/technical-documentation/architecture/preview.md b/technical-documentation/architecture/preview.md index b328307e7..75be2fb7d 100644 --- a/technical-documentation/architecture/preview.md +++ b/technical-documentation/architecture/preview.md @@ -257,6 +257,16 @@ a new clip). The mapping sits in It replaced a guess from the wall clock at 1× speed, which inside a 2× speed region re-seeked the view ten times a second; `useNativePlaybackSync` still makes that guess for an addon that reports no position. +- **Free-run decodes every source frame.** Inside a speed region the render thread adopts + `speed ×` the source's frame rate, so the decoder sets the ceiling: 4K at 2× needs 60 + decoded frames a second, at 4× 120. A clip without a camera used to decode its screen + file a second time for a stand-in webcam decoder, halving that budget; the stand-in is + now never stepped or sought, and compose takes the screen frame in its place + (`Player::webcam_frame` in `crates/compositor/src/live.rs`). On an M1, 4K at 2× went from + 1.2 s of source behind on average in the editor to no lag the counter could measure, and + from 8 to 57 frames published a second in the bench. + `cargo run --release --example live_free_run_bench_macos -- ` replays the + loop and prints the lag. The overlay's rect is kept aligned with the DOM via the same primitives used elsewhere in the renderer: @@ -356,6 +366,11 @@ was thrown away. path and not for the live view. Editing playback is therefore silent against the exported file; users hear audio only when the export runs. There is no flag in this branch that re-routes live audio. +- **Speeds the decoder cannot follow fall behind.** VideoToolbox decodes 4K at about 97 + frames a second on an M1, short of the 120 a 30 fps source needs at 4×. The render thread + caps a tick at `max_steps` frames and counts at most 0.1 s of clock per tick, so the view + loses time and the drift watch re-anchors it every 500 ms; skipping ahead to a keyframe + instead is not implemented. - **Drift under 150 ms is left alone.** The view and the app's clock run independently inside the drift watch's tolerance, and a correction is a seek, not a change of pace: a view drifting slowly is re-anchored with a visible step rather than eased back.