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
@@ -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());@@ -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);@@ -13,6 +13,7 @@ mod kept_parts; mod locks; mod malformed; mod math_ns;+mod media_timing; mod notes; mod ole; mod ole_vml;new file mode 100644@@ -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));+}