Keep the crop of a PPTX video and apply it in the poster and playback (#972)

This commit is contained in:
MarekSpodBiedry
2026-10-18 23:38:11 +02:00
parent 010941ec60
commit a49570e45b
9 changed files with 56 additions and 10 deletions
+15 -2
View File
@@ -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]
+2
View File
@@ -231,6 +231,8 @@ pub struct MediaClip {
pub video: bool,
/// Poster frame image.
pub poster: Option<MediaId>,
/// 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,
+8 -2
View File
@@ -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"));
+1 -1
View File
@@ -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;
+5
View File
@@ -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) };
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
}
}
}
+20 -2
View File
@@ -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<u8> {
let rate = 8_000u32;
+1 -1
View File
@@ -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).