diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ba567571..83e730d9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,6 +1,10 @@ //! File menu: new, open, save, export, close, properties. -use deckcraft_model::{Presentation, defaults}; +use std::collections::HashMap; +use std::sync::Arc; + +use deckcraft_media::MediaInfo; +use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults}; use serde_json::{Value, json}; use super::*; @@ -156,6 +160,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> { if deckcraft_pptx::sniff(bytes) { let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?; deckcraft_format::repair(&mut p); + probe_clips(&mut p); return Ok((p, lost_slides)); } if deckcraft_pptx::is_encrypted(bytes) { @@ -178,6 +183,42 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> { Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read"))) } +/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't +/// store them. Headers only, so opening never decodes a whole audio file. +fn probe_clips(p: &mut Presentation) { + fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap>, depth: usize) { + if depth > 64 { + return; + } + for s in shapes { + if let ShapeKind::Media(m) = &mut s.kind + && (m.duration_ms == 0 || m.width == 0 && m.height == 0) + && let Some(info) = probed + .entry(m.media) + .or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok())) + { + if m.duration_ms == 0 { + m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32; + } + if m.width == 0 + && m.height == 0 + && let Some(v) = &info.video + { + (m.width, m.height) = (v.width, v.height); + } + } + if let Some(children) = s.children_mut() { + rec(children, media, probed, depth + 1); + } + } + } + let mut probed = HashMap::new(); + // Just read, so no slide is shared yet and `make_mut` doesn't copy. + for slide in &mut p.slides { + rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0); + } +} + pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presentation) -> usize { let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); crate::fonts::install_embedded_fonts(&p); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index d9a3147d..85ed6c3f 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -228,6 +228,21 @@ mod tests { assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame"); } + #[test] + fn a_video_reopened_from_pptx_keeps_its_length_and_size() { + let mut s = Session::with_new(); + let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4"); + s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert"); + let before = s.execute("media.info", &json!({})).expect("info"); + assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0)); + let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save"); + s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open"); + let after = s.execute("media.info", &json!({})).expect("info"); + for key in ["durationMs", "width", "height"] { + assert_eq!(after[key], before[key], "{key}"); + } + } + #[test] fn media_commands_need_media() { let mut s = Session::with_new(); diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index dc1a892b..1f924820 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -31,7 +31,7 @@ pub use audio::Pcm; pub use clip::ClipParams; pub use feed::VideoFeed; pub use player::{AudioOut, PlayState, Player, VoiceSpec, VoiceStatus}; -pub use probe::{AudioInfo, MediaInfo, VideoInfo, probe}; +pub use probe::{AudioInfo, MediaInfo, VideoInfo, probe, probe_headers}; pub use video::{Frame, VideoDecoder, poster_frame, poster_png}; /// Shared media bytes (the model's `MediaItem::data`). diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2453317a..97d9a751 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -48,6 +48,19 @@ fn ms(secs: f64) -> u64 { /// Probe `bytes`. pub fn probe(bytes: &Bytes) -> Result { + let mut info = probe_headers(bytes)?; + if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() { + // No length in the header (raw MP3 / ADTS without an index): decode to measure. + if let Ok(pcm) = crate::audio::decode(bytes) { + info.duration_ms = ms(pcm.duration()); + } + } + Ok(info) +} + +/// [`probe`] from the headers alone, without decoding: a raw MP3 or ADTS stream without an +/// index reports a `duration_ms` of 0. +pub fn probe_headers(bytes: &Bytes) -> Result { let c = Container::sniff(bytes); let mut info = match c { Container::Mp4 => probe_mp4(bytes)?, @@ -73,12 +86,6 @@ pub fn probe(bytes: &Bytes) -> Result { info.duration_ms = ms(n as f64 / rate as f64); } } - if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() { - // No length in the header (raw MP3 / ADTS without an index): decode to measure. - if let Ok(pcm) = crate::audio::decode(bytes) { - info.duration_ms = ms(pcm.duration()); - } - } Ok(info) }