Merge PR #505 from NikitkaRa: Transitions: choose a transition sound and play it in the show

# Conflicts:
#	crates/pptx/tests/roundtrip.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:47 -04:00
7 changed files with 138 additions and 4 deletions
+45
View File
@@ -30,6 +30,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
s.edit(|doc, sel| {
let t = doc.slides.get(sel.slide).and_then(|x| x.transition.clone());
+45
View File
@@ -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<u8> {
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());
}
+7
View File
@@ -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) {
+22
View File
@@ -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:");
+16 -1
View File
@@ -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<ShapeId> = 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) {
+2 -2
View File
@@ -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) |
+1 -1
View File
@@ -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 |