mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge pull request #999 from Faabyy/fix/media-clip-per-slide-994
This commit is contained in:
@@ -138,6 +138,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) |
|
||||
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
|
||||
@@ -1360,8 +1360,9 @@ impl SlideApp {
|
||||
}
|
||||
UiRequest::Media { action, shape, ms } => {
|
||||
let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor };
|
||||
if let Some((uid, doc)) = self.session.active().map(|d| (d.uid, d.doc.clone())) {
|
||||
self.media.request(uid, &doc, &action, shape, ms, owner);
|
||||
// The command resolved `shape` on the editor's slide, also while a show runs.
|
||||
if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) {
|
||||
self.media.request(uid, &doc, slide, &action, shape, ms, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+67
-12
@@ -176,6 +176,11 @@ impl MediaHost {
|
||||
self.owners.get(&id).map(|o| o.owner)
|
||||
}
|
||||
|
||||
/// The clip voice `id` was started with.
|
||||
pub fn clip(&self, id: ShapeId) -> Option<&MediaClip> {
|
||||
self.owners.get(&id).map(|o| &o.clip)
|
||||
}
|
||||
|
||||
/// Shapes with a voice (playing, paused or ended).
|
||||
pub fn ids(&self) -> Vec<ShapeId> {
|
||||
self.owners.keys().copied().collect()
|
||||
@@ -318,9 +323,10 @@ impl MediaHost {
|
||||
}
|
||||
|
||||
/// 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<u64>, owner: Owner) {
|
||||
/// `slide` is the slide the request comes from: shape ids are only unique per slide.
|
||||
pub fn request(&mut self, doc_uid: u64, doc: &Presentation, slide: usize, action: &str, id: ShapeId, ms: Option<u64>, owner: Owner) {
|
||||
self.ensure_doc(doc_uid);
|
||||
let Some(clip) = find_clip(doc, id) else { return };
|
||||
let Some(clip) = find_clip(doc, id, Some(slide)) else { return };
|
||||
let t = ms.map(|v| v as f64 / 1000.0);
|
||||
match action {
|
||||
"play" => self.play(doc_uid, doc, id, &clip, t, owner),
|
||||
@@ -333,16 +339,29 @@ impl MediaHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// The clip of media shape `id` anywhere in the deck.
|
||||
pub fn find_clip(doc: &Presentation, id: ShapeId) -> Option<MediaClip> {
|
||||
fn walk(shapes: &[deckcraft_model::Shape], id: ShapeId) -> Option<MediaClip> {
|
||||
shapes.iter().find_map(|s| match &s.kind {
|
||||
ShapeKind::Media(m) if s.id == id => Some(m.clone()),
|
||||
ShapeKind::Group { children, .. } => walk(children, id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
doc.slides.iter().find_map(|s| walk(&s.shapes, id))
|
||||
/// The clip of media shape `id`. Ids are only unique per slide (the PPTX import numbers each slide
|
||||
/// afresh), so the `near` slide is searched first, then the rest of the deck.
|
||||
pub fn find_clip(doc: &Presentation, id: ShapeId, near: Option<usize>) -> Option<MediaClip> {
|
||||
near.and_then(|i| clip_on(doc, i, id)).or_else(|| doc.slides.iter().find_map(|s| walk_clip(&s.shapes, id)))
|
||||
}
|
||||
|
||||
/// The clip `id` on slide `index` only (inside groups too).
|
||||
pub fn clip_on(doc: &Presentation, index: usize, id: ShapeId) -> Option<MediaClip> {
|
||||
doc.slides.get(index).and_then(|s| walk_clip(&s.shapes, id))
|
||||
}
|
||||
|
||||
fn walk_clip(shapes: &[deckcraft_model::Shape], id: ShapeId) -> Option<MediaClip> {
|
||||
shapes.iter().find_map(|s| match &s.kind {
|
||||
ShapeKind::Media(m) if s.id == id => Some(m.clone()),
|
||||
ShapeKind::Group { children, .. } => walk_clip(children, id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the voice `id`, playing `clip` (the clip it was started with), keeps playing on slide
|
||||
/// `to`: the clip plays across slides and `to` doesn't hold another clip under the same id.
|
||||
pub fn carries_over(clip: &MediaClip, doc: &Presentation, id: ShapeId, to: usize) -> bool {
|
||||
clip.play_across_slides && clip_on(doc, to, id).is_none_or(|c| c.media == clip.media)
|
||||
}
|
||||
|
||||
/// Media shapes directly on slide `index`: (id, box in slide points, clip).
|
||||
@@ -617,4 +636,40 @@ mod tests {
|
||||
host.set_document(None);
|
||||
assert!(host.gifs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_is_looked_up_on_the_slide_it_was_asked_from() {
|
||||
// The PPTX import numbers shapes per slide, so two slides can hold different clips under one id.
|
||||
let (mut doc, id, first) = deck(4000, wav_level(0.05, 0.5));
|
||||
let other = doc.add_media("two.wav", "audio/wav", wav_level(0.05, 0.25)).expect("media");
|
||||
let mut second = (*doc.slides[0]).clone();
|
||||
second.shapes.last_mut().expect("shape").kind = ShapeKind::Media(MediaClip { media: other, volume: 1.0, ..Default::default() });
|
||||
doc.slides.push(Arc::new(second));
|
||||
assert_eq!(find_clip(&doc, id, Some(0)).map(|c| c.media), Some(first.media));
|
||||
assert_eq!(find_clip(&doc, id, Some(1)).map(|c| c.media), Some(other), "the second slide's own clip");
|
||||
assert_eq!(find_clip(&doc, id, None).map(|c| c.media), Some(first.media), "without a hint: first match");
|
||||
assert_eq!(find_clip(&doc, id, Some(9)).map(|c| c.media), Some(first.media), "a slide that doesn't exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_across_slides_stops_where_the_next_slide_reuses_its_id() {
|
||||
let (mut doc, id, first) = deck(4000, wav_level(0.05, 0.5));
|
||||
let playing = MediaClip { play_across_slides: true, ..first.clone() };
|
||||
let mut plain = (*doc.slides[0]).clone();
|
||||
plain.shapes.clear();
|
||||
doc.slides.push(Arc::new(plain));
|
||||
assert!(carries_over(&playing, &doc, id, 1), "a slide without that id");
|
||||
// Slide 0 holds the same media under that id, without Play Across Slides: the voice that
|
||||
// came from elsewhere goes on (its own clip decides), here and on the next slide.
|
||||
assert!(carries_over(&playing, &doc, id, 0), "the same media under that id");
|
||||
assert!(!carries_over(&first, &doc, id, 1), "a clip that doesn't play across slides");
|
||||
let other = doc.add_media("two.wav", "audio/wav", wav_level(0.05, 0.25)).expect("media");
|
||||
let mut reused = (*doc.slides[0]).clone();
|
||||
reused.shapes.last_mut().expect("shape").kind = ShapeKind::Media(MediaClip { media: other, volume: 1.0, autoplay: true, ..first.clone() });
|
||||
doc.slides.push(Arc::new(reused));
|
||||
assert!(!carries_over(&playing, &doc, id, 2), "another clip under that id");
|
||||
let mut host = MediaHost::new(None);
|
||||
host.play(1, &doc, id, &playing, None, Owner::Show);
|
||||
assert_eq!(host.clip(id), Some(&playing), "the voice keeps the clip it was started with");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1250,11 +1250,11 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat
|
||||
// [Stop Previous Sound] ends a transition sound still playing.
|
||||
let stop_previous = trans.is_some_and(|t| t.stop_sound);
|
||||
let keep: Vec<ShapeId> = match cur {
|
||||
Some(_) => app
|
||||
Some(i) => app
|
||||
.media
|
||||
.ids()
|
||||
.into_iter()
|
||||
.filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides))
|
||||
.filter(|id| app.media.clip(*id).is_some_and(|c| crate::media::carries_over(c, doc, *id, i)))
|
||||
// A transition sound plays to its end, as in PowerPoint, unless the new slide has its own.
|
||||
.chain((sound.is_none() && !stop_previous).then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id)))
|
||||
.collect(),
|
||||
@@ -1283,7 +1283,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat
|
||||
/// the clip is already playing, Pause toggles, Stop stops.
|
||||
fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) {
|
||||
for (id, effect) in std::mem::take(&mut show.media_cues) {
|
||||
let Some(clip) = crate::media::find_clip(doc, id) else { continue };
|
||||
let Some(clip) = crate::media::find_clip(doc, id, Some(show.state.slide)) else { continue };
|
||||
match effect.as_str() {
|
||||
"pause" => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show),
|
||||
"stop" => app.media.stop(id),
|
||||
|
||||
Reference in New Issue
Block a user