From 641fa36a8eb2577a4df11326227f66ce96622cc9 Mon Sep 17 00:00:00 2001 From: Andreas Haraminac Date: Tue, 13 Oct 2026 21:24:26 +0000 Subject: [PATCH] Media.seek: preserve decoded frames and EOF across video seeks --- ROADMAP.md | 4 ++- crates/media/src/feed.rs | 58 ++++++++++++++++++++++++++++++++++-- crates/media/tests/decode.rs | 17 +++++++++++ docs/slideshow-parity.md | 5 ++-- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..6adab633 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (video-seek race fixed and regression-tested; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Video seeks and loops keep newly decoded frames and end-of-stream notifications; stale generations are filtered when consumed (upstream #416). Deterministic queue coverage and repeated seeks across H.264, HEVC, VP9 and AV1 | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/media/src/feed.rs b/crates/media/src/feed.rs index 721be858..f4b5fce4 100644 --- a/crates/media/src/feed.rs +++ b/crates/media/src/feed.rs @@ -66,8 +66,8 @@ impl VideoFeed { #[cfg(not(target_arch = "wasm32"))] { let _ = self.tx.send(Cmd::Seek(t, self.generation)); - // Unblock the worker if it's waiting to hand over a stale frame. - while self.rx.try_recv().is_ok() {} + // pull() discards stale generations and unblocks the worker. Draining here could + // also discard the new generation's frames or EOF if the worker handles seek fast. } #[cfg(target_arch = "wasm32")] self.dec.seek(t); @@ -178,3 +178,57 @@ fn worker(mut dec: VideoDecoder, cmds: std::sync::mpsc::Receiver, frames: s } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn seek_keeps_new_generation_frames_and_end_of_stream() { + let (tx, _commands) = std::sync::mpsc::channel(); + let (frames, rx) = std::sync::mpsc::sync_channel(AHEAD); + let frame = |time| Frame { time, width: 1, height: 1, rgba: vec![0, 0, 0, 255] }; + frames.send((0, Some(frame(0.3)))).expect("stale frame"); + frames.send((0, None)).expect("stale end of stream"); + // Model a fast worker's seek response already waiting when seek drains the queue. + frames.send((1, Some(frame(0.0)))).expect("new frame"); + frames.send((1, None)).expect("new end of stream"); + let mut feed = VideoFeed { size: (1, 1), current: None, next: None, generation: 0, ended: false, tx, rx }; + + feed.seek(0.05); + let shown = feed.frame_at(0.05).expect("frame from the new generation"); + assert_eq!(shown.time, 0.0); + assert!(feed.ended()); + + frames.send((2, Some(frame(0.3)))).expect("superseded seek frame"); + frames.send((2, None)).expect("superseded seek end of stream"); + frames.send((3, Some(frame(0.0)))).expect("latest seek frame"); + frames.send((3, None)).expect("latest seek end of stream"); + feed.seek(0.35); + feed.seek(0.05); + assert_eq!(feed.frame_at(0.05).expect("latest seek wins").time, 0.0); + assert!(feed.ended()); + } + + #[test] + fn seek_unblocks_worker_when_stale_queue_is_full() { + let (tx, commands) = std::sync::mpsc::channel(); + let (frames, rx) = std::sync::mpsc::sync_channel(AHEAD); + let frame = || Frame { time: 0.0, width: 1, height: 1, rgba: vec![0, 0, 0, 255] }; + for _ in 0..AHEAD { + frames.send((0, Some(frame()))).expect("fill stale queue"); + } + let worker = std::thread::spawn(move || { + let Cmd::Seek(_, generation) = commands.recv().expect("seek command"); + frames.send((generation, Some(frame()))).expect("seek frame"); + frames.send((generation, None)).expect("seek end of stream"); + }); + let mut feed = VideoFeed { size: (1, 1), current: None, next: None, generation: 0, ended: false, tx, rx }; + + feed.seek(0.05); + let shown = feed.frame_at_blocking(0.05, std::time::Duration::from_secs(5)).expect("worker unblocked"); + assert_eq!(shown.time, 0.0); + assert!(feed.ended()); + worker.join().expect("worker completed"); + } +} diff --git a/crates/media/tests/decode.rs b/crates/media/tests/decode.rs index b3179445..e4a57490 100644 --- a/crates/media/tests/decode.rs +++ b/crates/media/tests/decode.rs @@ -312,6 +312,23 @@ fn video_feed_follows_the_clock() { assert!(f.is_some_and(|v| (v - 0.5).abs() < 0.02), "{f:?}"); } +#[test] +fn video_feed_repeated_seeks_keep_the_requested_frame() { + let timeout = Duration::from_secs(5); + for name in ["clip-h264.mov", "clip-hevc.mp4", "clip-vp9.webm", "clip-av1.mkv"] { + let mut feed = deckcraft_media::VideoFeed::new(fixture(name)).expect("feed"); + for _ in 0..20 { + // A decoder can finish this small clip before the consumer next polls it. + // Rewinding must keep the new generation's first frame, even at end of stream. + feed.seek(0.05); + let frame = feed.frame_at_blocking(0.05, timeout).expect("frame after rewind"); + assert_eq!(frame.time, 0.0, "{name}"); + let frame = feed.frame_at_blocking(0.45, timeout).expect("frame after advancing"); + assert!((frame.time - 0.4).abs() < 0.02, "{name}: {}", frame.time); + } + } +} + #[test] fn player_plays_decoded_audio_through_the_mixer() { use deckcraft_media::{ClipParams, Player, VoiceSpec}; diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..904b1045 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (video seek frame-loss race fixed, upstream #416; previous: major, created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -20,7 +20,7 @@ ready** (estimated), **10–16 h** to parity. | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) | -| Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | +| Media playback with controls, play across slides, full-screen video | yes | yes | Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media`. WMA/WMV not decodable | | Animated GIFs play; pause from their corner button | yes | yes (#39) | | | See All Slides grid during the show | yes | no | | | Zoom into part of a slide during the show | yes | no | | @@ -73,4 +73,5 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Video seek handoff no longer discards freshly decoded frames or end-of-stream (upstream #416); deterministic queue and four-codec seek regression tests | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |