From a49570e45ba9d8f1874c7fb8a2f5f1ed44ccb443 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:36:13 +0200 Subject: [PATCH] Keep the crop of a PPTX video and apply it in the poster and playback (#972) --- crates/format/src/lib.rs | 17 +++++++++++++++-- crates/model/src/lib.rs | 2 ++ crates/pptx/src/read/shapes.rs | 10 ++++++++-- crates/pptx/src/write/shapes.rs | 2 +- crates/pptx/tests/roundtrip.rs | 5 +++++ crates/render/src/lib.rs | 4 +++- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/media.rs | 22 ++++++++++++++++++++-- crates/ui-egui/src/show.rs | 2 +- 9 files changed, 56 insertions(+), 10 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5701a1ec..d78309aa 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -488,8 +488,9 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { } } match &mut sh.kind { - deckcraft_model::ShapeKind::Picture { fill } => { - for c in &mut fill.crop { + deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, .. } } + | deckcraft_model::ShapeKind::Media(deckcraft_model::MediaClip { crop, .. }) => { + for c in crop { *c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 }; } } @@ -769,6 +770,18 @@ mod tests { assert!(load(&b).is_err()); } + /// A video's crop is brought into the same range as a picture's (#972). + #[test] + fn a_video_crop_is_repaired_like_a_picture_crop() { + use deckcraft_model::{MediaClip, Shape}; + let mut p = Presentation::default(); + let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; + assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); + } + /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each /// such number repaired (it failed as "invalid type: null, expected f64"). #[test] diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 275f9440..4023d8cb 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -231,6 +231,8 @@ pub struct MediaClip { pub video: bool, /// Poster frame image. pub poster: Option, + /// Crop of the picture (left, top, right, bottom as fractions), PowerPoint's `srcRect` on the video. + pub crop: [f64; 4], pub trim_start_ms: u32, pub trim_end_ms: u32, pub fade_in_ms: u32, diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 3f7072f4..50e6e250 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -347,8 +347,14 @@ fn pic(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { if vf.is_some() || af.is_some() || p14.is_some() { let media = p14.and_then(|m| dml::media_ref(imp, part, m)).or_else(|| vf.or(af).and_then(|f| dml::media_ref(imp, part, f))); if let Some(media) = media { - let mut clip = - MediaClip { media, video: vf.is_some() || af.is_none(), poster: pf.as_ref().map(|p| p.media), volume: 1.0, ..Default::default() }; + let mut clip = MediaClip { + media, + video: vf.is_some() || af.is_none(), + poster: pf.as_ref().map(|p| p.media), + crop: pf.as_ref().map(|p| p.crop).unwrap_or_default(), + volume: 1.0, + ..Default::default() + }; if let Some(m) = p14 { if let Some(t) = m.child("trim") { clip.trim_start_ms = ms(t.attr("st")); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 3c3f95eb..14710cfa 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -828,7 +828,7 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_ } w.close("p:nvPr"); w.close("p:nvPicPr"); - let poster = deckcraft_model::style::PictureFill { media: m.poster.unwrap_or_default(), ..Default::default() }; + let poster = deckcraft_model::style::PictureFill { media: m.poster.unwrap_or_default(), crop: m.crop, ..Default::default() }; dml::blip_fill(w, x, o, "p:blipFill", &poster); let mut s3 = s.clone(); s3.fill = None; diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 697dad20..f4d2c2b7 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -256,6 +256,7 @@ pub fn rich_deck() -> Presentation { media: video, video: true, poster: Some(img), + crop: [0.1, 0.0, 0.2, 0.25], volume: 1.0, trim_start_ms: 500, fade_in_ms: 250, @@ -505,6 +506,10 @@ fn pictures_media_and_ink_round_trip() { assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250)); assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp"))); assert!(m.poster.is_some()); + // The crop of the video survives the round trip (#972). + for (got, want) in m.crop.iter().zip([0.1, 0.0, 0.2, 0.25]) { + assert!((got - want).abs() < 1e-4, "video crop {:?}", m.crop); + } // Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width. let ink = by_name(&q.slides[2], "Ink"); let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cf124a9f..907a8c40 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1094,8 +1094,10 @@ impl Renderer { ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); if let Some(p) = mc.poster { - let pf = deckcraft_model::style::PictureFill { media: p, ..Default::default() }; + let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; + ctx.push_clip_layer(&outline); paint::picture(ctx, f.pres, &pf, bounds, m, None); + ctx.pop_layer(); } else if mc.video { ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255)); ctx.fill_rect(&bounds); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad8..1e5522e6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,7 +371,7 @@ fn media_frames(app: &SlideApp, painter: &egui::Painter, xf: Xf) { } for (id, x, clip) in crate::media::slide_media(&d.doc, d.selection.slide) { if clip.video { - crate::media::paint_frame(painter, &app.media, id, screen_rect(xf, &x)); + crate::media::paint_frame(painter, &app.media, id, screen_rect(xf, &x), clip.crop); } } } diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 7fd8fda7..30130d72 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -363,9 +363,21 @@ pub fn clock(secs: f64) -> String { format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0) } -/// Draw the playing video frame of `id` into `rect` (letterboxed to the frame's aspect). -pub fn paint_frame(painter: &egui::Painter, host: &MediaHost, id: ShapeId, rect: Rect) -> bool { +/// The part of a video frame a picture crop (left, top, right, bottom fractions) leaves visible. +fn crop_uv(crop: [f64; 4]) -> Rect { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.99) as f32 } else { 0.0 }); + Rect::from_min_max(pos2(l, t), pos2((1.0 - r).max(l + 0.01), (1.0 - b).max(t + 0.01))) +} + +/// Draw the playing video frame of `id` into `rect`: letterboxed to the frame's aspect, or, when +/// the video is cropped, the cropped part stretched over the whole box as PowerPoint shows it. +pub fn paint_frame(painter: &egui::Painter, host: &MediaHost, id: ShapeId, rect: Rect, crop: [f64; 4]) -> bool { let Some(tex) = host.texture(id) else { return false }; + if crop.iter().any(|v| *v != 0.0) { + painter.rect_filled(rect, CornerRadius::ZERO, Color32::BLACK); + painter.image(tex.id(), rect, crop_uv(crop), Color32::WHITE); + return true; + } let [w, h] = tex.size(); let (w, h) = (w.max(1) as f32, h.max(1) as f32); let k = (rect.width() / w).min(rect.height() / h); @@ -512,6 +524,12 @@ mod tests { use super::*; + #[test] + fn a_video_crop_shows_only_the_cropped_part_of_the_frame() { + assert_eq!(crop_uv([0.0; 4]), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))); + assert_eq!(crop_uv([0.25, 0.5, 0.25, 0.0]), Rect::from_min_max(pos2(0.25, 0.5), pos2(0.75, 1.0))); + } + /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. fn wav_level(secs: f64, amp: f32) -> Vec { let rate = 8_000u32; diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 51f859af..75df8677 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -851,7 +851,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } else { slide_box(srect, uv, sw, sh, x.x as f32, x.y as f32, x.w as f32, x.h as f32) }; - crate::media::paint_frame(&painter, &app.media, *id, r); + crate::media::paint_frame(&painter, &app.media, *id, r, clip.crop); } // Play/pause on the animated GIF under the pointer, and the audio/video bar. // Slide Show ▸ Show Media Controls hides both (the click still plays a clip).