Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions crates/compositor/examples/live_free_run_bench_macos.rs
Original file line number Diff line number Diff line change
@@ -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 -- <fichier> [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 <fichier> [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");
}
73 changes: 58 additions & 15 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ struct PrefetchedClip {
/// `should_draw_webcam`).
///
/// ponytail: on garde le remplaçant plutôt que de passer `wdec` en `Option<Decoder>`, 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,
Expand Down Expand Up @@ -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}\")");
Expand Down Expand Up @@ -192,13 +195,18 @@ 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<bool> {
let sf = sdec.seek_to_or_last(source_time_sec)?;
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)?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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).
///
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<bool> {
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);
Expand Down
109 changes: 109 additions & 0 deletions crates/compositor/tests/no_camera_stand_in.rs
Original file line number Diff line number Diff line change
@@ -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é"
);
}
}
Loading
Loading