collective

Browse / github.com/storytold/deckcraft / patch

pptx: read media volume, mute, loop, hide and full screen from the timing tree (refs #1368)

pptx: read media volume, mute, loop, hide and full screen from the timing tree (refs #1368) Reads the media settings PowerPoint keeps in the slide's timing tree (part of #1368). PowerPoint stores a video's or sound's volume, mute, Loop until Stopped, Hide While Not Playing and Play Full Screen on `p:video`/`p:audio` > `p:cMediaNode` (`vol`, `mute`, `showWhenStopped`, `p:cTn repeatCount`) and `fullScrn`, not on the picture, so `MediaClip::volume` stayed 1 and `loop_play`, `hide_while_not_playing` and `full_screen` stayed false after opening a file. `timing::media_settings` now copies them onto the target's `MediaClip` (also inside groups). Absent attributes leave the existing defaul

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1465, open)
Base
88c2ceff5fab9d34dd262a8184bbbd61ca369b97
Size
+108 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
190720a32867030468b8ba6e

How it went

No other operator has reported using it yet.

Diff

raw · crates/pptx/src/read.rs crates/pptx/src/read/timing.rs crates/pptx/tests/it/main.rs crates/pptx/tests/it/media_timing.rs

diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs--- a/crates/pptx/src/read.rs+++ b/crates/pptx/src/read.rs@@ -1133,6 +1133,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout     }     if let Some(t) = root.child("timing") {         s.animations = timing::animations(imp, part, &ctx, t, &s.shapes);+        timing::media_settings(t, &ctx, &mut s.shapes);     }     if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {         s.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs--- a/crates/pptx/src/read/timing.rs+++ b/crates/pptx/src/read/timing.rs@@ -1,7 +1,7 @@ //! Slide transitions (`p:transition`) and animations (`p:timing`).  use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};-use deckcraft_model::{Shape, TextBody, find_shape};+use deckcraft_model::{Shape, ShapeKind, TextBody, find_shape, find_shape_mut};  use super::shapes::IdCtx; use super::{Imp, Part, dml};@@ -94,6 +94,35 @@ fn max_dur(e: &El, depth: usize) -> Option<u32> {     best } +/// Volume, mute, Loop until Stopped, Hide While Not Playing and Play Full Screen live in the timing tree+/// (`p:video`/`p:audio` > `p:cMediaNode`), not on the picture: copy them onto the target's `MediaClip`.+pub fn media_settings(timing: &El, ctx: &IdCtx, shapes: &mut [Shape]) {+    let mut nodes = vec![];+    for tag in ["video", "audio"] {+        timing.find_all(tag, &mut nodes);+    }+    for node in nodes.into_iter().take(10_000) {+        let Some(cm) = node.child("cMediaNode") else { continue };+        let Some(id) = cm.path(&["tgtEl", "spTgt"]).and_then(|t| t.u32("spid")).and_then(|f| ctx.lookup(f)) else { continue };+        let Some(ShapeKind::Media(clip)) = find_shape_mut(shapes, id).map(|s| &mut s.kind) else { continue };+        if let Some(v) = cm.attr("vol").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()) {+            clip.volume = (v / 100_000.0).clamp(0.0, 1.0);+        }+        if cm.bool("mute") == Some(true) {+            clip.volume = 0.0;+        }+        if cm.bool("showWhenStopped") == Some(false) {+            clip.hide_while_not_playing = true;+        }+        if cm.child("cTn").and_then(|c| c.attr("repeatCount")) == Some("indefinite") {+            clip.loop_play = true;+        }+        if node.bool("fullScrn") == Some(true) {+            clip.full_screen = true;+        }+    }+}+ /// Flatten the timing tree into the Animation Pane list. pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec<Animation> {     let _ = (imp, part);diff --git a/crates/pptx/tests/it/main.rs b/crates/pptx/tests/it/main.rs--- a/crates/pptx/tests/it/main.rs+++ b/crates/pptx/tests/it/main.rs@@ -13,6 +13,7 @@ mod kept_parts; mod locks; mod malformed; mod math_ns;+mod media_timing; mod notes; mod ole; mod ole_vml;diff --git a/crates/pptx/tests/it/media_timing.rs b/crates/pptx/tests/it/media_timing.rsnew file mode 100644--- /dev/null+++ b/crates/pptx/tests/it/media_timing.rs@@ -0,0 +1,76 @@+//! Media settings PowerPoint keeps in the timing tree, spliced into an exported deck and read back.++use std::io::{Cursor, Read, Write};+use std::sync::Arc;++use deckcraft_geom::Xfrm;+use deckcraft_model::{MediaClip, ShapeId, ShapeKind, defaults};++fn node(tag: &str, attrs: &str, spid: u32, media: &str, ctn: &str) -> String {+    format!(+        r#"<p:par><p:cTn id="2" fill="hold"><p:childTnLst><p:{tag}{attrs}><p:cMediaNode{media}><p:cTn id="3"{ctn}/><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl></p:cMediaNode></p:{tag}></p:childTnLst></p:cTn></p:par>"#+    )+}++fn clips(nodes: impl Fn(u32, u32) -> String) -> Vec<MediaClip> {+    let mut p = defaults::new_presentation(None);+    let video = p.add_media("clip.mp4", "video/mp4", b"video".to_vec()).expect("media");+    let audio = p.add_media("clip.m4a", "audio/mp4", b"audio".to_vec()).expect("media");+    let mut ids = vec![];+    for (media, is_video) in [(video, true), (audio, false)] {+        let id = ShapeId(p.alloc_id().expect("id"));+        ids.push(id.0);+        let s = deckcraft_model::Shape {+            id,+            name: "Clip".into(),+            xfrm: Some(Xfrm::new(40.0, 40.0, 200.0, 100.0)),+            kind: ShapeKind::Media(MediaClip { media, video: is_video, volume: 1.0, ..Default::default() }),+            ..Default::default()+        };+        Arc::make_mut(&mut p.slides[0]).shapes.push(s);+    }+    let timing = format!(+        r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>"#,+        nodes(ids[0], ids[1])+    );+    let bytes = deckcraft_pptx::export(&p).expect("export");+    let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip");+    let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));+    for i in 0..z.len() {+        let mut f = z.by_index(i).expect("entry");+        let mut d = vec![];+        f.read_to_end(&mut d).expect("read");+        if f.name() == "ppt/slides/slide1.xml" {+            let s = String::from_utf8(d).expect("utf8");+            assert!(!s.contains("<p:timing"));+            let end = s.rfind("</p:sld>").expect("end");+            d = format!("{}{timing}{}", &s[..end], &s[end..]).into_bytes();+        }+        out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");+        out.write_all(&d).expect("write");+    }+    let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");+    q.slides[0]+        .shapes+        .iter()+        .filter_map(|s| match &s.kind {+            ShapeKind::Media(m) => Some(m.clone()),+            _ => None,+        })+        .collect()+}++#[test]+fn video_settings_are_read_from_the_timing_tree() {+    let c = clips(|v, _| node("video", r#" fullScrn="1""#, v, r#" vol="80000" showWhenStopped="0""#, r#" repeatCount="indefinite""#));+    assert_eq!((c[0].volume, c[0].hide_while_not_playing, c[0].loop_play, c[0].full_screen), (0.8, true, true, true));+    // The sound next to it has no node: defaults stay.+    assert_eq!((c[1].volume, c[1].hide_while_not_playing, c[1].loop_play, c[1].full_screen), (1.0, false, false, false));+}++#[test]+fn muted_audio_has_no_volume_and_other_clips_keep_theirs() {+    let c = clips(|_, a| node("audio", "", a, r#" vol="50000" mute="1""#, ""));+    assert_eq!(c[0].volume, 1.0);+    assert_eq!((c[1].volume, c[1].loop_play, c[1].full_screen), (0.0, false, false));+}