mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 11:36:58 -06:00
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:
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user