diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 5a7a6d737..8224118e3 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -155,7 +155,7 @@ unsafe fn open_and_seek_clip( let source_time_sec = source_time_sec.max(0.0); 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(source_time_sec)?; + 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)?; @@ -195,7 +195,7 @@ unsafe fn seek_pair( source_time_sec: f64, webcam_offset_sec: f64, ) -> Result { - let sf = sdec.seek_to(source_time_sec)?; + let sf = sdec.seek_to_or_last(source_time_sec)?; if sf.is_null() { return Ok(false); } @@ -601,10 +601,12 @@ impl Player { /// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de /// `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(target_sec)?; + 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(webcam_seek_time(target_sec, self.webcam_offset_sec))?; + .seek_to_or_last(webcam_seek_time(target_sec, self.webcam_offset_sec))?; if sf.is_null() || wf.is_null() { self.has_current_frame = false; return Ok(false); diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index b118befdd..1c47e0b0f 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -127,6 +127,12 @@ impl Decoder { self.decode_present(idx) } + /// Contrat de `pipeline_windows::Decoder::seek_to_or_last`, que `seek_to` remplit déjà ici : + /// au-delà de la fin, `pump_to_target` tient la dernière image décodée. + pub unsafe fn seek_to_or_last(&mut self, seconds: f64) -> Result<*mut AVFrame> { + self.seek_to(seconds) + } + /// Decode la frame SEQUENTIELLE suivante — pompage `next_frame`, PAS de seek. /// La frame rendue appartient au decodeur (valide jusqu'au prochain appel), /// donc elle ne se libere pas ici, contrairement au chemin `decode_at`. diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs index 93827b00c..ad43084a0 100644 --- a/crates/compositor/src/pipeline_macos.rs +++ b/crates/compositor/src/pipeline_macos.rs @@ -410,6 +410,25 @@ impl Decoder { } } + /// `seek_to`, mais une cible au-delà de la dernière image se pose sur cette dernière image au + /// lieu de ne rien rendre. L'audio d'un enregistrement dure souvent un peu plus que sa vidéo + /// (de 12 ms à près d'une demi-seconde mesurés) : son clip finit alors après la dernière + /// image, et y entrer par la fin, en scrubant de droite à gauche, demandait une image qui + /// n'existe pas. Le changement de clip échouait sans bruit, et la vue gardait la scène du + /// clip qu'on quittait. + pub unsafe fn seek_to_or_last(&mut self, seconds: f64) -> Result<*mut crate::ffi::AVFrame> { + let frame = self.seek_to(seconds)?; + if !frame.is_null() { + return Ok(frame); + } + // Le seek a décodé jusqu'à l'EOF : `cur_pts` est celui de la dernière image. L'EOF a vidé + // la frame courante, donc il faut le seek complet : `take` écarte le chemin rapide. + match self.cur_pts.take() { + Some(last) => self.seek_to(last as f64 * self.tb_sec()), + None => Ok(frame), + } + } + /// Déroule le décodeur en avant jusqu'à la première frame à `seconds` ou après, SANS /// jeter son état. Symétrique de `pipeline_windows::Decoder::decode_forward_to`. unsafe fn decode_forward_to(&mut self, seconds: f64, tb_sec: f64) -> Result<*mut crate::ffi::AVFrame> { diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index fabe31207..a232f06b7 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -968,6 +968,25 @@ impl Decoder { } } + /// `seek_to`, mais une cible au-delà de la dernière image se pose sur cette dernière image au + /// lieu de ne rien rendre. L'audio d'un enregistrement dure souvent un peu plus que sa vidéo + /// (de 12 ms à près d'une demi-seconde mesurés) : son clip finit alors après la dernière + /// image, et y entrer par la fin, en scrubant de droite à gauche, demandait une image qui + /// n'existe pas. Le changement de clip échouait sans bruit, et la vue gardait la scène du + /// clip qu'on quittait. + pub(crate) unsafe fn seek_to_or_last(&mut self, seconds: f64) -> Result<*mut AVFrame> { + let frame = self.seek_to(seconds)?; + if !frame.is_null() { + return Ok(frame); + } + // Le seek a décodé jusqu'à l'EOF : `cur_pts` est celui de la dernière image. L'EOF a vidé + // la frame courante, donc il faut le seek complet : `take` écarte le chemin rapide. + match self.cur_pts.take() { + Some(last) => self.seek_to(last as f64 * self.tb_sec()), + None => Ok(frame), + } + } + /// Déroule le décodeur en avant jusqu'à la première frame à `seconds` ou après, SANS /// jeter son état. Critère d'arrêt identique à celui du seek complet — c'est ce qui /// garantit que les deux chemins rendent exactement la même frame. @@ -2836,6 +2855,29 @@ mod tests { ); } + #[test] + fn a_seek_past_the_last_frame_can_land_on_it() { + let Some(gpu) = strict_hardware_gpu("a_seek_past_the_last_frame_can_land_on_it") else { + return; + }; + let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], "last-frame.mp4"); + let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) } + .unwrap_or_else(|e| panic!("H.264 Decoder::open: {e:#}")); + + let frame = unsafe { dec.seek_to_or_last(10.0) }.expect("seek past the end"); + assert!(!frame.is_null(), "a target past the end must land on the last frame"); + assert!(!dec.cur_frame().is_null(), "the last frame must be presentable"); + assert!( + unsafe { dec.next() }.expect("decode after the last frame").is_null(), + "the frame it landed on must be the last one" + ); + assert!( + unsafe { dec.seek_to(10.0) }.expect("plain seek past the end").is_null(), + "seek_to itself still reports no frame past the end" + ); + println!("CORE_ASSERTIONS_COMPLETED:a_seek_past_the_last_frame_can_land_on_it"); + } + /// 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. diff --git a/src/components/ai-edition/NativeCompositorOverlay.test.tsx b/src/components/ai-edition/NativeCompositorOverlay.test.tsx index 619bd84b3..ff3522665 100644 --- a/src/components/ai-edition/NativeCompositorOverlay.test.tsx +++ b/src/components/ai-edition/NativeCompositorOverlay.test.tsx @@ -193,6 +193,42 @@ describe("NativeCompositorOverlay while playing", () => { expect(native.setActiveClip).not.toHaveBeenCalled(); }); + // Two copies of one take: only the clip index tells them apart, and the scrub enters the left + // copy by its end. The view must be sent there, or it keeps the right copy's crop and zoom. + it("sends the left copy back when scrubbing into it while paused", async () => { + const left: AxcutClip = { + ...FIRST, + id: "clip_left", + sourceStartSec: 0, + sourceEndSec: 14.5, + timelineStartSec: 0, + timelineEndSec: 14.5, + }; + const right: AxcutClip = { + ...left, + id: "clip_right", + timelineStartSec: 14.5, + timelineEndSec: 29, + }; + const document = makeDocument(); + useProjectStore.setState({ + document: { ...document, timeline: { ...document.timeline, clips: [left, right] } }, + sourceDurationSec: 14.5, + currentTimeSec: 20, + playing: false, + }); + await mountAtFirstClip(); + publishNativePosition({ clipIndex: 1, sourceTimeSec: 5.5 }, now); + + for (const time of [16, 15, 14.6, 14.4, 12, 9]) { + now += 16; + setPlayhead(time, false); + } + + expect(native.setActiveClip).toHaveBeenCalledWith(7, "/take.mp4", "", 0, 0, 14.4); + expect(native.setActiveClip).toHaveBeenLastCalledWith(7, "/take.mp4", "", 0, 0, 14.4); + }); + it("still sends the clip on a change while paused", async () => { useProjectStore.setState({ playing: false }); await mountAtFirstClip();