From 5d4bfc08466064a5c1b4e400944f6840df23283d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:57:08 +0300 Subject: [PATCH] Media: keep playback with the document and the clip Shape ids are local to one presentation, and decoded audio was cached by the address of a buffer the cache did not keep. Switching decks, closing the last one, or replacing a clip could keep the previous sound. Playback now stops when the document changes or closes, a new source replaces the voice's audio, and the decode and duration caches hold the bytes they are keyed by. Co-authored-by: Cursor --- ROADMAP.md | 2 + crates/media/src/player.rs | 159 ++++++++++++++++----- crates/ui-egui/src/canvas.rs | 9 +- crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/media.rs | 267 ++++++++++++++++++++++++++++++----- crates/ui-egui/src/show.rs | 18 +-- 6 files changed, 380 insertions(+), 84 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..d25b5fd3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by | | 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: media playback follows the open document and clip (#415) | | 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/player.rs b/crates/media/src/player.rs index 9ac4a804..1e069544 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -6,9 +6,11 @@ //! heard. Without an output device (web, CI, no sound card) [`Player::tick`] advances the clocks on //! wall time instead, so playback, looping and video still work silently. //! -//! Audio is decoded once per media item (on a worker thread where there are threads) and cached; -//! a voice whose audio is still decoding holds at its start (`Loading`), one whose audio can't be -//! decoded runs silently. +//! Audio is decoded once per media item (on a worker thread where there are threads) and cached. +//! The cache keeps those source bytes: its key is the allocation's address, which would otherwise +//! be free to reuse for different media. A voice whose audio is still decoding holds at its start +//! (`Loading`); one whose audio can't be decoded runs silently. Playing a voice again with a +//! different source replaces its audio instead of keeping the previous clip. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -66,8 +68,17 @@ pub struct VoiceStatus { type Slot = Arc, String>>>; +/// One decoded item. `source` keeps the allocation `media_key` names alive for as long as the +/// entry does, so a later buffer cannot be given that address and read this PCM. +struct CachedAudio { + slot: Slot, + source: Bytes, +} + struct Voice { key: u64, + /// Identity of the bytes this voice is playing. A different key replaces `audio`. + media_key: u64, audio: Option, spec: VoiceSpec, t: f64, @@ -192,7 +203,7 @@ pub struct Player { mixer: Arc>, last_tick: Option, idle_since: Option, - cache: HashMap, + cache: HashMap, } impl Player { @@ -213,49 +224,61 @@ impl Player { /// The decoded audio of `bytes`, cached by `media_key` (decoding starts now). fn slot(&mut self, media_key: u64, bytes: &Bytes) -> Slot { if self.cache.len() > 16 { - // Forget audio no voice uses. - let used: Vec = self.cache.iter().filter(|(_, s)| Arc::strong_count(s) > 1).map(|(k, _)| *k).collect(); + // Forget audio no voice uses. Dropping the entry drops its source bytes too. + let used: Vec = self.cache.iter().filter(|(_, c)| Arc::strong_count(&c.slot) > 1).map(|(k, _)| *k).collect(); self.cache.retain(|k, _| used.contains(k)); } - self.cache - .entry(media_key) - .or_insert_with(|| { - let slot: Slot = Arc::new(OnceLock::new()); - let (s, b) = (slot.clone(), bytes.clone()); - let job = move || { - let _ = s.set(crate::audio::decode(&b).map(Arc::new).map_err(|e| e.to_string())); - }; - if crate::THREADS { - if std::thread::Builder::new().name("deckcraft-audio-decode".into()).spawn(job).is_err() { - let _ = slot.set(Err("couldn't start the decoder".into())); - } - } else { - job(); - } - slot - }) - .clone() + if let Some(hit) = self.cache.get(&media_key) + && Arc::as_ptr(&hit.source) as u64 == media_key + { + return hit.slot.clone(); + } + // A key whose bytes are gone is not this clip. Decode again. + self.cache.remove(&media_key); + let slot: Slot = Arc::new(OnceLock::new()); + let (s, b) = (slot.clone(), bytes.clone()); + let job = move || { + let _ = s.set(crate::audio::decode(&b).map(Arc::new).map_err(|e| e.to_string())); + }; + if crate::THREADS { + if std::thread::Builder::new().name("deckcraft-audio-decode".into()).spawn(job).is_err() { + let _ = slot.set(Err("couldn't start the decoder".into())); + } + } else { + job(); + } + let cached = CachedAudio { slot: slot.clone(), source: bytes.clone() }; + self.cache.insert(media_key, cached); + slot } /// Play voice `key` (a shape) from `from` seconds (default: the trimmed start). Resumes a - /// paused voice when `from` is `None`. + /// paused voice when `from` is `None` and `media_key` is unchanged. A different `media_key` + /// replaces the decoded audio and starts the new clip (at `from`, or at its start). pub fn play(&mut self, key: u64, media_key: u64, bytes: &Bytes, spec: VoiceSpec, from: Option) { let audio = spec.audio.then(|| self.slot(media_key, bytes)); self.with(|m| { if let Some(v) = m.voices.iter_mut().find(|v| v.key == key) { + let same = v.media_key == media_key; + v.media_key = media_key; v.spec = spec; - if v.audio.is_none() { + if same { + if v.audio.is_none() { + v.audio = audio; + } + if let Some(t) = from { + v.t = t; + } else if v.state == PlayState::Ended { + v.t = spec.clip.range(v.duration()).0; + } + } else { v.audio = audio; - } - if let Some(t) = from { - v.t = t; - } else if v.state == PlayState::Ended { - v.t = spec.clip.range(v.duration()).0; + v.t = from.unwrap_or_else(|| spec.clip.range(spec.duration).0); } v.state = PlayState::Playing; } else { - let t = from.unwrap_or(spec.clip.range(spec.duration).0); - m.voices.push(Voice { key, audio, spec, t, state: PlayState::Playing }); + let t = from.unwrap_or_else(|| spec.clip.range(spec.duration).0); + m.voices.push(Voice { key, media_key, audio, spec, t, state: PlayState::Playing }); } }); self.ensure_stream(); @@ -459,6 +482,7 @@ mod tests { let mut m = Mixer { voices: vec![Voice { key: 1, + media_key: 1, audio: Some(slot), spec: VoiceSpec { clip, looping: false, duration: 1.0, audio: true }, t: 0.0, @@ -502,4 +526,73 @@ mod tests { let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); assert!((pos - 0.1).abs() < 1e-6, "{pos}"); } + + /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. + fn wav_level(secs: f64, amp: f32) -> Bytes { + let rate = 8_000u32; + let n = (rate as f64 * secs).round() as usize; + let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + Arc::new(b) + } + + fn audible(duration: f64) -> VoiceSpec { + VoiceSpec { clip: ClipParams::default(), looping: false, duration, audio: true } + } + + /// First mixed sample of voice `key`, after its audio has decoded. + fn first_sample(p: &Player, key: u64) -> f32 { + assert!(p.wait_loaded(key, std::time::Duration::from_secs(5)), "decode"); + let mut buf = vec![0.0f32; 4]; + p.render(&mut buf, 1, 8_000); + buf[0] + } + + #[test] + fn replacing_the_source_plays_the_new_audio_and_keeps_its_bytes() { + let mut p = Player::new(None); + let loud = wav_level(0.05, 0.5); + let quiet = wav_level(0.10, 0.25); + let loud_key = Arc::as_ptr(&loud) as u64; + let quiet_key = Arc::as_ptr(&quiet) as u64; + assert_ne!(loud_key, quiet_key); + + // Duration 0: the clock takes its length from the decoded audio, so a stale slot would + // report the previous clip. + p.play(1, loud_key, &loud, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.5).abs() < 0.02, "loud sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); + assert!(p.cache.get(&loud_key).is_some_and(|c| Arc::ptr_eq(&c.source, &loud))); + + // The caller's buffer can go away; the cache still owns the allocation its key names. + drop(loud); + assert_eq!(Arc::as_ptr(&p.cache.get(&loud_key).expect("cached").source) as u64, loud_key); + + p.play(1, quiet_key, &quiet, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.25).abs() < 0.02, "quiet sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.10).abs() < 0.01, "quiet duration {dur}"); + assert!(p.cache.get(&quiet_key).is_some_and(|c| Arc::ptr_eq(&c.source, &quiet))); + // Resuming the same source does not jump back to the start. + let pos = p.status(1).map(|s| s.position).unwrap_or(-1.0); + p.play(1, quiet_key, &quiet, audible(0.0), None); + let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); + assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..41a10cf7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -106,7 +106,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); let gifs = match app.session.active() { - Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused), + Some(d) if d.selection.target == Target::Slides => app.media.gifs(d.uid, &d.doc, d.selection.slide, now, &app.session.gif_paused), _ => vec![], }; crate::media::gif_repaint(ui.ctx(), &gifs); @@ -276,6 +276,7 @@ fn media_frames(app: &SlideApp, painter: &egui::Painter, xf: Xf) { /// selection or hover). fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { let Some(d) = app.session.active() else { return }; + let uid = d.uid; if d.selection.target != Target::Slides || d.selection.text.is_some() || app.session.tool.dragging() { return; } @@ -294,11 +295,11 @@ fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { let bar = Rect::from_min_size(pos2(r.center().x - w / 2.0, r.max.y + 6.0), vec2(w, 26.0)); let status = app.media.status(id); match crate::media::control_bar(ui, bar, status.as_ref(), &clip) { - Some(crate::media::BarAction::Toggle) => app.media.toggle(&doc, id, &clip, crate::media::Owner::Editor), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(&doc, id, &clip, t, crate::media::Owner::Editor), + Some(crate::media::BarAction::Toggle) => app.media.toggle(uid, &doc, id, &clip, crate::media::Owner::Editor), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(uid, &doc, id, &clip, t, crate::media::Owner::Editor), Some(crate::media::BarAction::Nudge(dt)) => { let pos = status.map(|s| s.position).unwrap_or(0.0); - app.media.seek(&doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); + app.media.seek(uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); } None => {} } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..81de1fcf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -668,8 +668,8 @@ impl SlideApp { } UiRequest::Media { action, shape, ms } => { let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; - if let Some(doc) = self.session.active().map(|d| d.doc.clone()) { - self.media.request(&doc, &action, shape, ms, owner); + if let Some((uid, doc)) = self.session.active().map(|d| (d.uid, d.doc.clone())) { + self.media.request(uid, &doc, &action, shape, ms, owner); } } } @@ -989,8 +989,11 @@ impl SlideApp { self.perf.frame_ms = now_ms() - t0; } - /// Media clocks and video textures; the editor's media stops when its slide is left. + /// Media clocks and video textures. Voices belong to the open presentation: closing the last + /// one, or switching decks, stops them. The editor's media also stops when its slide is left. fn tick_media(&mut self, ctx: &egui::Context) { + let uid = self.session.active().map(|d| d.uid); + self.media.set_document(uid); if !self.media.any_active() { self.session.media_status.clear(); return; diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index f887c2b2..85b65ea1 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -25,13 +25,31 @@ struct Video { tex: Option, } +/// A voice started for one shape in one presentation. +struct Owned { + owner: Owner, + clip: MediaClip, + /// Address of the source bytes this voice was started with. + source: usize, +} + +/// A probed duration. `source` keeps that allocation alive so a later buffer cannot reuse the +/// address and inherit this length. +struct Probed { + source: Arc>, + duration: f64, +} + pub struct MediaHost { player: Player, - owners: HashMap, + /// Presentation the voices and GIF clocks belong to. `None` before the first document is + /// bound, and after the last one closes. + doc: Option, + owners: HashMap, videos: HashMap, - /// Probed durations by media bytes (for media imported without a probe). - durations: HashMap, - /// Animated GIF clocks: when each started and, while paused, the time it holds. + /// Probed durations keyed by the source allocation (media imported without a length). + durations: HashMap, + /// Animated GIF clocks for `doc`: when each started and, while paused, the time it holds. gifs: HashMap)>, } @@ -57,12 +75,68 @@ fn key(id: ShapeId) -> u64 { impl MediaHost { pub fn new(out: Option>) -> MediaHost { - MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new(), gifs: HashMap::new() } + MediaHost { + player: Player::new(out), + doc: None, + owners: HashMap::new(), + videos: HashMap::new(), + durations: HashMap::new(), + gifs: HashMap::new(), + } + } + + /// Bind playback to the open presentation. `None` (nothing open) stops every voice. Switching + /// to a different document stops the previous one's voices and GIF clocks, so a shape id that + /// exists in both decks cannot keep the old clip. + pub fn set_document(&mut self, uid: Option) { + if self.doc == uid { + return; + } + if uid.is_none() || self.doc.is_some() { + self.stop_voices(); + self.gifs.clear(); + } + self.doc = uid; + } + + fn ensure_doc(&mut self, uid: u64) { + self.set_document(Some(uid)); + } + + fn stop_voices(&mut self) { + self.player.stop_all(); + self.owners.clear(); + self.videos.clear(); + } + + /// Length of `bytes` in seconds, remembered for this allocation. The map keeps the bytes, and + /// stays within 16 entries. + fn duration_of(&mut self, bytes: &Arc>) -> f64 { + let media_key = Arc::as_ptr(bytes) as usize; + if let Some(hit) = self.durations.get(&media_key) { + return hit.duration; + } + if self.durations.len() >= 16 { + let drop_key = self + .durations + .iter() + .find(|(_, p)| Arc::strong_count(&p.source) <= 1) + .map(|(k, _)| *k) + .or_else(|| self.durations.keys().next().copied()); + if let Some(k) = drop_key { + self.durations.remove(&k); + } + } + let duration = deckcraft_media::probe(bytes).map(|i| i.duration()).unwrap_or(0.0); + self.durations.insert(media_key, Probed { source: Arc::clone(bytes), duration }); + duration } /// The animated GIF pictures directly on slide `index` at `now`: each plays from when it was - /// first shown; a paused one holds its time and resumes from it. - pub fn gifs(&mut self, doc: &Presentation, index: usize, now: f64, paused: &HashSet) -> Vec { + /// first shown; a paused one holds its time and resumes from it. Clocks belong to `doc_uid` + /// and are dropped when the open presentation changes. + pub fn gifs(&mut self, doc_uid: u64, doc: &Presentation, index: usize, now: f64, paused: &HashSet) -> Vec { + self.ensure_doc(doc_uid); let Some(slide) = doc.slides.get(index) else { return vec![] }; let mut out = vec![]; for sh in &slide.shapes { @@ -85,9 +159,7 @@ impl MediaHost { /// Replace the audio output (the host injects cpal after start-up). pub fn set_output(&mut self, out: Option>) { - self.player.stop_all(); - self.owners.clear(); - self.videos.clear(); + self.stop_voices(); self.player = Player::new(out); } @@ -101,7 +173,7 @@ impl MediaHost { } pub fn owner(&self, id: ShapeId) -> Option { - self.owners.get(&id).map(|o| o.0) + self.owners.get(&id).map(|o| o.owner) } /// Shapes with a voice (playing, paused or ended). @@ -113,23 +185,21 @@ impl MediaHost { !self.owners.is_empty() } - /// Start (or resume) shape `id` from `from` seconds. - pub fn play(&mut self, doc: &Presentation, id: ShapeId, clip: &MediaClip, from: Option, owner: Owner) { + /// Start (or resume) shape `id` of presentation `doc_uid` from `from` seconds. + pub fn play(&mut self, doc_uid: u64, doc: &Presentation, id: ShapeId, clip: &MediaClip, from: Option, owner: Owner) { + self.ensure_doc(doc_uid); let Some(item) = doc.media(clip.media) else { return }; let bytes = item.data.clone(); let media_key = Arc::as_ptr(&bytes) as usize; - let duration = if clip.duration_ms > 0 { - clip.duration_ms as f64 / 1000.0 - } else { - *self.durations.entry(media_key).or_insert_with(|| deckcraft_media::probe(&bytes).map(|i| i.duration()).unwrap_or(0.0)) - }; + let duration = if clip.duration_ms > 0 { clip.duration_ms as f64 / 1000.0 } else { self.duration_of(&bytes) }; let spec = VoiceSpec { clip: ClipParams::from_ms(clip.trim_start_ms, clip.trim_end_ms, clip.fade_in_ms, clip.fade_out_ms, clip.volume), looping: clip.loop_play, duration, audio: clip.volume > 0.0, }; - if clip.video && !self.videos.contains_key(&id) { + let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key); + if clip.video && (source_changed || !self.videos.contains_key(&id)) { let feed = match VideoFeed::new(bytes.clone()) { Ok(f) => Some(f), Err(e) => { @@ -138,20 +208,27 @@ impl MediaHost { } }; self.videos.insert(id, Video { feed, frame: None, tex: None }); + } else if !clip.video { + self.videos.remove(&id); } self.player.play(key(id), media_key as u64, &bytes, spec, from); - self.owners.insert(id, (owner, clip.clone())); + self.owners.insert(id, Owned { owner, clip: clip.clone(), source: media_key }); } pub fn pause(&mut self, id: ShapeId) { self.player.pause(key(id)); } - pub fn toggle(&mut self, doc: &Presentation, id: ShapeId, clip: &MediaClip, owner: Owner) { - if self.is_playing(id) { + fn same_source(&self, doc: &Presentation, id: ShapeId, clip: &MediaClip) -> bool { + doc.media(clip.media).is_some_and(|item| self.owners.get(&id).is_some_and(|o| o.source == Arc::as_ptr(&item.data) as usize)) + } + + pub fn toggle(&mut self, doc_uid: u64, doc: &Presentation, id: ShapeId, clip: &MediaClip, owner: Owner) { + self.ensure_doc(doc_uid); + if self.is_playing(id) && self.same_source(doc, id, clip) { self.pause(id); } else { - self.play(doc, id, clip, None, owner); + self.play(doc_uid, doc, id, clip, None, owner); } } @@ -162,16 +239,17 @@ impl MediaHost { } pub fn stop_owned(&mut self, owner: Owner) { - let ids: Vec = self.owners.iter().filter(|(_, o)| o.0 == owner).map(|(id, _)| *id).collect(); + let ids: Vec = self.owners.iter().filter(|(_, o)| o.owner == owner).map(|(id, _)| *id).collect(); for id in ids { self.stop(id); } } - /// Move to `t` seconds; a shape that isn't playing is cued there, paused. - pub fn seek(&mut self, doc: &Presentation, id: ShapeId, clip: &MediaClip, t: f64, owner: Owner) { - if self.status(id).is_none() { - self.play(doc, id, clip, Some(t), owner); + /// Move to `t` seconds; a shape that isn't playing, or whose clip changed, is cued there, paused. + pub fn seek(&mut self, doc_uid: u64, doc: &Presentation, id: ShapeId, clip: &MediaClip, t: f64, owner: Owner) { + self.ensure_doc(doc_uid); + if self.status(id).is_none() || !self.same_source(doc, id, clip) { + self.play(doc_uid, doc, id, clip, Some(t), owner); self.pause(id); } self.player.seek(key(id), t); @@ -188,10 +266,10 @@ impl MediaHost { let now = ctx.input(|i| i.time); self.player.tick(now); let mut ended = vec![]; - for (id, (_, clip)) in &self.owners { + for (id, owned) in &self.owners { if let Some(st) = self.player.status(key(*id)) && st.state == PlayState::Ended - && clip.rewind + && owned.clip.rewind { ended.push(*id); } @@ -239,16 +317,17 @@ impl MediaHost { playing } - /// Carry out a `media.*` request from the engine. - pub fn request(&mut self, doc: &Presentation, action: &str, id: ShapeId, ms: Option, owner: Owner) { + /// Carry out a `media.*` request from the engine for presentation `doc_uid`. + pub fn request(&mut self, doc_uid: u64, doc: &Presentation, action: &str, id: ShapeId, ms: Option, owner: Owner) { + self.ensure_doc(doc_uid); let Some(clip) = find_clip(doc, id) else { return }; let t = ms.map(|v| v as f64 / 1000.0); match action { - "play" => self.play(doc, id, &clip, t, owner), + "play" => self.play(doc_uid, doc, id, &clip, t, owner), "pause" => self.pause(id), - "toggle" => self.toggle(doc, id, &clip, owner), + "toggle" => self.toggle(doc_uid, doc, id, &clip, owner), "stop" => self.stop(id), - "seek" => self.seek(doc, id, &clip, t.unwrap_or(0.0), owner), + "seek" => self.seek(doc_uid, doc, id, &clip, t.unwrap_or(0.0), owner), _ => {} } } @@ -421,3 +500,121 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: } action } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Arc; + + use deckcraft_geom::Xfrm; + use deckcraft_model::style::PictureFill; + use deckcraft_model::{MediaClip, Presentation, Shape, ShapeId, ShapeKind}; + + use super::*; + + /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. + fn wav_level(secs: f64, amp: f32) -> Vec { + let rate = 8_000u32; + let n = (rate as f64 * secs).round() as usize; + let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b + } + + fn deck(shape: u32, bytes: Vec) -> (Presentation, ShapeId, MediaClip) { + let mut doc = Presentation::default(); + let media = doc.add_media("clip.wav", "audio/wav", bytes); + let clip = MediaClip { media, volume: 1.0, ..Default::default() }; + let id = ShapeId(shape); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(Shape { id, xfrm: Some(Xfrm::new(0.0, 0.0, 120.0, 40.0)), kind: ShapeKind::Media(clip.clone()), ..Default::default() }); + (doc, id, clip) + } + + /// First mixed sample of shape `id`, after its audio has decoded. + fn level(host: &MediaHost, id: ShapeId) -> f32 { + assert!(host.player.wait_loaded(key(id), std::time::Duration::from_secs(5)), "decode"); + let mut buf = vec![0.0f32; 4]; + host.player.render(&mut buf, 1, 8_000); + buf[0] + } + + #[test] + fn playback_follows_the_document_and_the_clip() { + let (loud_doc, id, loud) = deck(4000, wav_level(0.05, 0.5)); + let (quiet_doc, _, quiet) = deck(4000, wav_level(0.10, 0.25)); + let mut host = MediaHost::new(None); + + host.play(1, &loud_doc, id, &loud, None, Owner::Editor); + let sample = level(&host, id); + assert!((sample - 0.5).abs() < 0.02, "loud sample {sample}"); + let dur = host.status(id).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); + let loud_bytes = &loud_doc.media(loud.media).expect("media").data; + let ptr = Arc::as_ptr(loud_bytes) as usize; + assert!(host.durations.get(&ptr).is_some_and(|p| Arc::ptr_eq(&p.source, loud_bytes))); + + // Replacing the clip on the same shape plays the new bytes, not the voice already running. + host.play(1, &quiet_doc, id, &quiet, None, Owner::Editor); + let sample = level(&host, id); + assert!((sample - 0.25).abs() < 0.02, "replaced sample {sample}"); + let dur = host.status(id).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.10).abs() < 0.01, "replaced duration {dur}"); + assert_eq!(host.player.keys(), vec![key(id)]); + + // A second deck reuses the shape id. Leaving the first deck stops its voice. + host.set_document(Some(2)); + assert!(host.status(id).is_none(), "the previous document's voice is gone"); + host.play(2, &loud_doc, id, &loud, None, Owner::Editor); + let sample = level(&host, id); + assert!((sample - 0.5).abs() < 0.02, "second document sample {sample}"); + let dur = host.status(id).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "second document duration {dur}"); + + host.set_document(None); + assert!(host.ids().is_empty(), "closing the last document stops playback"); + assert!(host.status(id).is_none()); + } + + #[test] + fn gif_clocks_do_not_carry_across_documents() { + let gif = |shape: u32| { + let mut doc = Presentation::default(); + let media = doc.add_media("a.gif", "image/gif", b"GIF89a-not-animated".to_vec()); + let id = ShapeId(shape); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(Shape { + id, + xfrm: Some(Xfrm::new(0.0, 0.0, 80.0, 80.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + ..Default::default() + }); + (doc, id) + }; + let (a, id) = gif(7); + let (b, _) = gif(7); + let mut host = MediaHost::new(None); + let none = HashSet::new(); + host.gifs(1, &a, 0, 10.0, &none); + assert_eq!(host.gifs.get(&id).map(|c| c.0), Some(10.0)); + host.gifs(1, &a, 0, 12.0, &none); + assert_eq!(host.gifs.get(&id).map(|c| c.0), Some(10.0), "the same document keeps its clock"); + host.gifs(2, &b, 0, 50.0, &none); + assert_eq!(host.gifs.get(&id).map(|c| c.0), Some(50.0), "a new document starts its own clock"); + host.set_document(None); + assert!(host.gifs.is_empty()); + } +} diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..c8db8015 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -245,7 +245,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { app.show = None; return; }; @@ -315,13 +315,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx); - let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; + let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None }; if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click { - app.media.toggle(&doc, id, &clip, crate::media::Owner::Show); + app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); } else if clicked && let Some(l) = &link { let last = show.last_viewed; match show.state.follow(&doc, &l.action, last) { @@ -375,7 +375,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { keep = false; } } - show_media(app, &mut show, &doc); + show_media(app, &mut show, doc_uid, &doc); // Draw. let full = ui.max_rect(); let painter = ui.painter_at(full); @@ -405,7 +405,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { }) .map(|m| m.0) .collect(); - let gifs = app.media.gifs(&doc, idx, now, &app.session.gif_paused); + let gifs = app.media.gifs(doc_uid, &doc, idx, now, &app.session.gif_paused); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); crate::media::gif_repaint(&ctx, &gifs); let gif = crate::media::gif_key(&gifs); @@ -688,7 +688,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { /// Start the media of a newly shown slide: media from the previous slide stops unless it plays /// across slides; media set to start automatically plays. Everything stops at the end. -fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { +fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { let cur = if show.ended { None } else { Some(show.state.slide) }; if show.media_slide == cur { return; @@ -706,7 +706,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { if let Some(i) = cur { for (id, _, clip) in crate::media::slide_media(doc, i) { if clip.autoplay && !keep.contains(&id) { - app.media.play(doc, id, &clip, None, crate::media::Owner::Show); + app.media.play(doc_uid, doc, id, &clip, None, crate::media::Owner::Show); } } } @@ -748,13 +748,13 @@ fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { } /// The animated GIF whose play/pause button is under the pointer on the shown slide. -fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation, app: &mut SlideApp) -> Option { +fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, doc: &Presentation, app: &mut SlideApp) -> Option { let p = ctx.input(|i| i.pointer.interact_pos())?; let full = ui.max_rect(); let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; let srect = fit(avail, doc); let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - let gifs = app.media.gifs(doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); + let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id) }