diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 02f2de756..16c75bd9e 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -490,6 +490,25 @@ impl Player { self.programme.as_ref().map(|c| c.at(t) as f32) } + /// La lecture libre est-elle au bout de la source écran ? Oui quand la frame courante, + /// composée, atteint `end_sec` (la fin de la fenêtre du clip), quand la suivante, due d'ici + /// `target_source_time`, tomberait hors de la fenêtre (`step` composerait une frame coupée), + /// ou qu'aucune autre ne la suit dans le fichier : `step` y reboucle au début. + unsafe fn reached_end(&mut self, end_sec: f64, target_source_time: f64) -> Result { + // Une frame repositionnée (seek, bascule de clip) attend encore d'être composée. + if self.use_current_on_next_step { + return Ok(false); + } + if self.sdec.cur_time_sec() >= end_sec { + return Ok(true); + } + Ok(match self.sdec.peek_next_time_sec()? { + NextFrameTime::At(t) => t >= end_sec && t <= target_source_time, + NextFrameTime::Eof => true, + NextFrameTime::Unknown => false, + }) + } + /// Compose la PROCHAINE frame due (→ `comp.rt`), au plus une, si `target_source_time` /// (temps écran) est atteint. Sémantique de "hold" : `false` sans rien composer quand la /// frame suivante n'est pas encore due — l'appelant garde alors l'image déjà affichée, @@ -1265,12 +1284,20 @@ fn should_settle(now: Instant, settle_until: Option, last_settle: Insta && now.duration_since(last_settle) >= SETTLE_STEP } +/// Le clip que la lecture libre enchaîne après `active`. Aucun après le dernier : le programme +/// s'arrête là, comme la tête de lecture de l'app. Reboucler sur le premier emmenait la vue +/// où l'app ne la suivait pas, et ses seeks suivants, qui ne nomment pas de clip, cherchaient +/// alors dans le fichier du premier (#997). +fn next_clip_index(scene: &Scene, active: usize) -> Option { + (active + 1 < scene.clips.len()).then_some(active + 1) +} + /// Démarre le préchargement du clip suivant sur un thread dédié dès qu'on entre dans la /// fenêtre `PREFETCH_LEAD_SEC` avant la fin du clip actif — pour que la bascule à la /// frontière (`advance_to_next_scene_clip`) trouve les décodeurs déjà ouverts et positionnés /// au lieu de payer l'E/S + le parsing FFmpeg sur le thread de rendu pile au moment de la /// transition (la pause perceptible observée en usage réel). No-op si un préchargement est -/// déjà en cours, ou pour une scène à 1 clip (voir `advance_to_next_scene_clip`). +/// déjà en cours, ou sur le dernier clip (voir `next_clip_index`). unsafe fn maybe_start_prefetch( scene: &Scene, active_clip_index: usize, @@ -1278,9 +1305,12 @@ unsafe fn maybe_start_prefetch( gpu: &Gpu, prefetch: &mut Option, ) { - if scene.clips.len() <= 1 || prefetch.is_some() { + if prefetch.is_some() { return; } + let Some(next_index) = next_clip_index(scene, active_clip_index) else { + return; + }; let Some(clip) = scene.clips.get(active_clip_index) else { return; }; @@ -1288,11 +1318,6 @@ unsafe fn maybe_start_prefetch( if !(0.0..PREFETCH_LEAD_SEC).contains(&remaining) { return; } - let next_index = if active_clip_index + 1 < scene.clips.len() { - active_clip_index + 1 - } else { - 0 - }; let next_clip = scene.clips[next_index].clone(); // Copie légère (COM refcount, pas de nouveau device) — même motif que `Player::open`. let gpu_clone = Gpu { @@ -1320,9 +1345,8 @@ unsafe fn maybe_start_prefetch( *prefetch = Some((next_index, rx)); } -/// Bascule le `Player` + le compositeur sur le clip suivant de `scene` (reboucle sur le -/// premier après le dernier). No-op pour une scène à 1 clip (le bouclage léger existant de -/// `Player::step` suffit et coûte moins cher qu'un `set_active_clip` — reopen des décodeurs). +/// Bascule le `Player` + le compositeur sur le clip suivant de `scene`. No-op sur le dernier +/// clip, que rien ne suit (`next_clip_index`) : la boucle de rendu y tient la dernière image. /// /// Partagée entre le déclenchement PROACTIF (seuil `source_end_sec` franchi) et le filet de /// sécurité RÉACTIF de `render_thread` (le temps du décodeur a reculé — `Player::step` a @@ -1350,13 +1374,8 @@ unsafe fn advance_to_next_scene_clip( loaded_cursor_path: &mut String, last_smoothing: &mut f32, ) { - if scene.clips.len() <= 1 { + let Some(next_index) = next_clip_index(scene, *active_clip_index) else { return; - } - let next_index = if *active_clip_index + 1 < scene.clips.len() { - *active_clip_index + 1 - } else { - 0 }; let next_clip = &scene.clips[next_index]; @@ -1809,7 +1828,7 @@ unsafe fn render_thread( loop { // Timeline = niveau d'abstraction AU-DESSUS des clips : dès que le décodeur // écran atteint la fin de fenêtre du clip actif, on enchaîne nous-mêmes sur - // le clip suivant (ou on reboucle sur le premier après le dernier) — sans + // le clip suivant (après le dernier, on tient sa dernière image) — sans // dépendre d'un `active_clip_request` poussé par le JS en réaction au // franchissement. Ce round-trip arrivait toujours trop tard : le décodeur // avait déjà dépassé la fin de la fenêtre, voire atteint l'EOF brut du @@ -1827,6 +1846,14 @@ unsafe fn render_thread( &mut prefetch, ); if let Some(clip) = scene.clips.get(active_clip_index) { + // Fin du programme : la vue s'y arrête comme la tête de lecture de l'app, + // au lieu de reboucler sur un clip où l'app ne la suit pas (#997). + if next_clip_index(scene, active_clip_index).is_none() + && player.reached_end(clip.source_end_sec, player.screen_time_sec() + acc)? + { + acc = 0.0; + break; + } if player.screen_time_sec() >= clip.source_end_sec { advance_to_next_scene_clip( &mut player, diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index 62755b8f0..5d3bda1de 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -2116,13 +2116,22 @@ mod tests { codec_args: &[&str], filename: &str, duration_sec: &str, + ) -> std::path::PathBuf { + encode_named_color(codec_args, filename, "red", duration_sec) + } + + fn encode_named_color( + codec_args: &[&str], + filename: &str, + color: &str, + duration_sec: &str, ) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("openscreen-554-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("temp dir"); let out = dir.join(filename); let ff = ffmpeg_exe(); let mut cmd = std::process::Command::new(&ff); - let input = format!("color=c=red:s=64x64:d={duration_sec}"); + let input = format!("color=c={color}:s=64x64:d={duration_sec}"); cmd.args(["-y", "-f", "lavfi", "-i", input.as_str()]); cmd.args(codec_args); cmd.arg(&out); @@ -2923,6 +2932,112 @@ mod tests { println!("CORE_ASSERTIONS_COMPLETED:{name}"); } + /// #997: playback that ran off the end of the programme left the view on the FIRST clip, + /// while the app's playhead stayed on the last. The app's next seeks carry a source time and + /// no clip, so they searched the first clip's file. Drives the real render loop, on clips laid + /// out as in the report: a first clip from its own (red) file, the last two from one (blue). + #[test] + fn playback_off_the_end_of_the_programme_stays_on_the_last_clip() { + let name = "playback_off_the_end_of_the_programme_stays_on_the_last_clip"; + if strict_hardware_gpu(name).is_none() { + return; + } + let h264 = ["-c:v", "libopenh264", "-b:v", "200k"]; + // Long enough that a view which wrapped onto it is still there when the test looks. + let first = encode_named_color(&h264, "997-first.mp4", "red", "3.0"); + let last = encode_named_color(&h264, "997-last.mp4", "blue", "0.6"); + // The paths travel in JSON, and the view matches them against its requests as strings. + let first = first.to_str().expect("utf8").replace('\\', "/"); + let last = last.to_str().expect("utf8").replace('\\', "/"); + let clip = |path: &str, end_sec: f64| { + format!(r#"{{"screenPath":"{path}","webcamPath":"","sourceStartSec":0,"sourceEndSec":{end_sec},"webcamOffsetSec":0,"hasAudio":false}}"#) + }; + + // The last clip ends on its file's last frame (0.56 s, short of the declared 0.6 s) as in + // the report, then on a cut. + for last_end_sec in [0.6, 0.4] { + let scene = format!( + r##"{{"clips":[{},{},{}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}}, + "background":{{"kind":"color","color":"#000000"}}, + "zoomRegions":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip":[null,null,null], + "output":{{"width":64,"height":64,"fps":25}}}}"##, + clip(&first, 3.0), + clip(&last, 0.6), + clip(&last, last_end_sec), + ); + let view = crate::live::LiveView::create(64, 64, &first, "", "").expect("view"); + view.set_playing(false); + view.set_scene(&scene); + view.set_active_clip(&last, "", 0.0, 2, 0.1); + let first_frame = wait_for_frame(&view, 0, |p| p.clip_index == 2); + let mut gen = first_frame.0; + let mut held = first_frame.5; + + // Play to the end of the last clip, then about as long again. + view.set_playing(true); + std::thread::sleep(std::time::Duration::from_millis(1000)); + view.set_playing(false); + std::thread::sleep(std::time::Duration::from_millis(150)); + while let Some(frame) = view.latest_frame_since(gen) { + gen = frame.0; + held = frame.5; + } + // The frame held at the end is the clip's own last frame, not the first one its cut + // removed (the 25 fps file has a frame at exactly 0.4 s). + assert!( + held.source_time_sec < last_end_sec, + "end {last_end_sec}: held a frame past the clip's end, at {:.3} s", + held.source_time_sec + ); + // And it did play up to there: a view stalled at its start would pass the bound above. + assert!( + held.source_time_sec > last_end_sec - 0.1, + "end {last_end_sec}: playback did not reach the clip's end, held at {:.3} s", + held.source_time_sec + ); + + // A seek inside the last clip, as the app sends it while paused. + view.set_time(0.2); + let (_, w, h, rgba, _, position) = + wait_for_frame(&view, gen, |p| (p.source_time_sec - 0.2).abs() < 0.03); + let center = (((h / 2) * w + w / 2) * 4) as usize; + let rgb = &rgba[center..center + 3]; + assert!( + i32::from(rgb[2]) - i32::from(rgb[0]) > 100, + "end {last_end_sec}: expected the last clip's blue, got rgb {rgb:?}" + ); + assert_eq!(position.clip_index, 2, "end {last_end_sec}: the view left the last clip"); + } + println!("CORE_ASSERTIONS_COMPLETED:{name}"); + } + + /// The first frame the view publishes after generation `since` whose position is `wanted`. + fn wait_for_frame( + view: &crate::live::LiveView, + mut since: u64, + wanted: impl Fn(&crate::live::FramePosition) -> bool, + ) -> crate::live::LatestFrame { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(frame) = view.latest_frame_since(since) { + if wanted(&frame.5) { + return frame; + } + since = frame.0; + } + assert!( + std::time::Instant::now() < deadline, + "no such frame within 5 s (last gen {since}, fatal: {:?})", + view.fatal_error() + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + /// Playhead crossing clips is `Decoder::open` of the next source on the /// same `Gpu` (#554). H.264 must stay on D3D11VA after an AV1 software /// decoder has been opened and dropped.