From 460a2712e466ac0e922d712cfd7a3ea16826f15b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:07:06 +0300 Subject: [PATCH] Transitions: choose a transition sound and play it in the show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transition sounds were read from and written to .pptx but could not be set and never played. Transitions › Timing gets a Sound box ([No Sound] or Other Sound…), backed by a new transition.sound command that embeds an audio file on the selected slides (or removes it with none). In the slide show the sound plays as its slide comes in and, as in PowerPoint, keeps playing into the next slide unless that slide has a sound of its own. Built-in sounds, Stop Previous Sound and Loop Until Next Sound are still missing; the parity docs say so. Co-authored-by: Cursor --- crates/engine/src/cmd/transition.rs | 45 +++++++++++++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 45 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 7 +++++ crates/ui-egui/src/ribbon.rs | 22 ++++++++++++++ crates/ui-egui/src/show.rs | 17 ++++++++++- docs/animation-parity.md | 4 +-- docs/parity-checklist.md | 2 +- 7 files changed, 138 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 616cf002..28db7607 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -30,6 +30,15 @@ pub fn specs() -> Vec { has_slide, timing ), + cmd!( + "transition.sound", + "Sound", + ["Transitions", "Timing"], + None, + "{path? | data: base64, name?; none?: true (No Sound); index?} — the sound plays as the slide comes in", + has_slide, + sound + ), cmd!("transition.applyAll", "Apply To All", ["Transitions", "Timing"], None, "{}", has_slide, apply_all), cmd!(query "transition.list", "Transition Gallery", [], None, "{} → [{id, label, category, duration, options}]", always, list), ] @@ -108,6 +117,42 @@ fn timing(s: &mut Session, p: &Value) -> Result { ok() } +/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it. +fn sound(s: &mut Session, p: &Value) -> Result { + let list = slide_indices(s, p)?; + if bool_param(p, "none") == Some(true) { + s.edit(|doc, _| { + for i in list { + if let Some(sl) = doc.slides.get_mut(i) + && let Some(t) = Arc::make_mut(sl).transition.as_mut() + { + t.sound = None; + } + } + Ok(()) + })?; + return Ok(json!({"sound": null})); + } + let (name, bytes) = super::insert::media_bytes(p, "transition.sound")?; + let ct = super::insert::content_type(&name, &bytes); + let bytes = Arc::new(bytes); + let audible = deckcraft_media::probe(&bytes).is_ok_and(|i| i.audio.is_some()); + let bytes = Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); + if !audible && !ct.starts_with("audio/") { + return Err(bad("transition.sound", format!("{name} is not a sound file ({ct})"))); + } + let media = s.edit(|doc, _| { + let media = doc.add_media(&name, ct, bytes); + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).transition.get_or_insert_with(Transition::default).sound = Some(media); + } + } + Ok(media) + })?; + Ok(json!({"sound": name, "media": media.0, "playable": audible})) +} + fn apply_all(s: &mut Session, _p: &Value) -> Result { s.edit(|doc, sel| { let t = doc.slides.get(sel.slide).and_then(|x| x.transition.clone()); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..f9c8bc91 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,48 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +/// A tiny 8 kHz mono 16-bit WAV of `n` silent samples. +fn wav(n: u32) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + n * 2).to_le_bytes()); + v.extend_from_slice(b"WAVEfmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&8000u32.to_le_bytes()); + v.extend_from_slice(&16000u32.to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&16u16.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&(n * 2).to_le_bytes()); + v.resize(v.len() + n as usize * 2, 0); + v +} + +/// Transitions › Sound: a chosen sound is embedded, saved as `p:sndAc`, read back, and No Sound +/// removes it; a file that isn't audio is refused. +#[test] +fn transition_sound_is_set_saved_and_removed() { + use deckcraft_engine::Session; + use serde_json::json; + let b64 = deckcraft_engine::cmd::base64_encode(&wav(800)); + let mut s = Session::with_new(); + s.execute("transition.set", &json!({"kind": "fade"})).unwrap(); + let r = s.execute("transition.sound", &json!({"data": b64, "name": "chime.wav"})).unwrap(); + assert_eq!(r["playable"], true, "{r}"); + let doc = s.doc().unwrap().doc.clone(); + let m = doc.slides[0].transition.as_ref().and_then(|t| t.sound).expect("sound set"); + assert_eq!(doc.media(m).map(|i| i.name.as_str()), Some("chime.wav")); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let t = back.slides[0].transition.as_ref().expect("transition"); + assert_eq!(t.kind, "fade"); + let m = t.sound.expect("sound survives the save"); + assert_eq!(back.media(m).map(|i| i.data.len()), Some(wav(800).len())); + s.execute("transition.sound", &json!({"none": true})).unwrap(); + assert!(s.doc().unwrap().doc.slides[0].transition.as_ref().is_some_and(|t| t.sound.is_none())); + let not_audio = deckcraft_engine::cmd::base64_encode(b"hello, not a sound"); + assert!(s.execute("transition.sound", &json!({"data": not_audio, "name": "notes.txt"})).is_err()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..0dffbed6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -580,6 +580,13 @@ impl SlideApp { Ok(()) } + /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. + pub(crate) fn pick_transition_sound(&mut self) { + if self.services.pick_open.is_some() { + self.pick_and_insert("audio", "transition.sound"); + } + } + fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick(purpose) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..90535b56 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1389,6 +1389,28 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { + // Sound in its own column, so the timing column keeps its three rows. + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Sound:"); + let name = cur + .as_ref() + .and_then(|t| t.sound) + .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.clone()))) + .unwrap_or_else(|| "[No Sound]".into()); + egui::ComboBox::from_id_salt("transition_sound").width(120.0).selected_text(name).show_ui(ui, |ui| { + if ui.selectable_label(cur.as_ref().is_none_or(|t| t.sound.is_none()), "[No Sound]").clicked() { + run(app, "transition.sound", json!({"none": true})); + } + let can_pick = app.services.pick_open.is_some(); + let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); + if r.clicked() { + app.pick_transition_sound(); + } + r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + }); + }); + }); rows(ui, |ui| { ui.horizontal(|ui| { ui.label("Duration:"); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..aee3168a 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -686,6 +686,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } +/// The media-host voice for slide transition sounds (no shape has this id). +pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); + /// 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) { @@ -694,8 +697,16 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { return; } show.media_slide = cur; + let sound = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()).and_then(|t| t.sound); let keep: Vec = match cur { - Some(_) => app.media.ids().into_iter().filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides)).collect(), + Some(_) => app + .media + .ids() + .into_iter() + .filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides)) + // A transition sound plays to its end, as in PowerPoint, unless the new slide has its own. + .chain(sound.is_none().then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) + .collect(), None => vec![], }; for id in app.media.ids() { @@ -703,6 +714,10 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { app.media.stop(id); } } + if let Some(media) = sound { + let clip = deckcraft_model::MediaClip { media, volume: 1.0, ..Default::default() }; + app.media.play(doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); + } if let Some(i) = cur { for (id, _, clip) in crate::media::slide_media(doc, i) { if clip.autoplay && !keep.contains(&id) { diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 8ec5d0f4..436a0dee 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -39,7 +39,7 @@ perspective-correct subdivided meshes. |---|---|---| | Effect Options per transition | yes | yes | | Duration, advance on click / after time, Apply To All | yes | yes | -| Transition sounds | yes | **no** (the model has the field; no picker, no playback) | +| Transition sounds | yes | partly: Transitions › Sound › Other Sound… embeds a file, it plays as the slide comes in and round-trips; no built-in sounds, Stop Previous Sound or Loop Until Next Sound | | Preview in the editor | yes | yes | | Morph: objects move, resize, recolour | yes | yes | | Morph: Words and Characters | yes | yes (#5) | @@ -84,7 +84,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Animation Pane timeline, preview from selected, effect dialog depth | 5–8 | | The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 | | Rewind and other effect options on round trip (#77) | 1–2 | -| Transition sounds | 1–2 | +| Transition sounds: built-in sounds, Stop Previous Sound, Loop Until Next Sound | 1–2 | | Morph across pictures, `!!` names, align once per transition (#67) | 2–4 | | Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 | | **Total** | **~16–30** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..5906ee5a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -199,7 +199,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Transitions | Transition gallery (fade, push, wipe, split, cover, uncover, zoom, morph…) | P0 | D | | Transitions | Effect options and duration | P0 | D | | Transitions | Advance on click / after time, apply to all | P0 | D | -| Transitions | Transition sounds | P2 | M | +| Transitions | Transition sounds | P2 | P | | Transitions | Morph | P1 | D | | Animations | Entrance, emphasis, exit effects | P0 | D | | Animations | Motion paths | P1 | P |