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 bce9a809..60eefc1c 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1214,3 +1214,48 @@ fn text_transparency_round_trips() { let Some(deckcraft_model::Fill::Solid { color }) = &sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.fill else { panic!() }; assert!((color.alpha() - 0.6).abs() < 1e-9, "{color:?}"); } + +/// 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 170f1230..8725a925 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -619,6 +619,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 699dea55..4aa1c94e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1400,6 +1400,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 dc751887..c1c854b7 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -721,6 +721,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) { @@ -729,8 +732,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() { @@ -738,6 +749,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 5f190902..70f8986b 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 |