feat(pictures): animated GIFs play, with pause (media.gifPlay) (#39)

* feat(pictures): animated GIFs play, with pause (media.gifPlay)

GIF pictures used to show only their first frame. The renderer now decodes every frame (cached, capped at 256 MB and 1024 px a side) and draws the frame for each GIF's play time, passed in RenderOpts::gif_times; exports and thumbnails pass none and keep frame 1. The editor canvas and the slide show redraw only when a GIF reaches its next frame. A corner button on hover (or selection) pauses and resumes a GIF from the same frame, through the new media.gifPlay command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(pictures): animated GIFs play, with pause (media.gifPlay)

GIF pictures used to show only their first frame. The renderer now decodes every frame (cached, capped at 256 MB and 1024 px a side) and draws the frame for each GIF's play time, passed in RenderOpts::gif_times; exports and thumbnails pass none and keep frame 1. The editor canvas and the slide show redraw only when a GIF reaches its next frame. A corner button on hover (or selection) pauses and resumes a GIF from the same frame, through the new media.gifPlay command.

Also adds my entry to contributors/people.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Animated GIFs: cap frames (1000), canvas (4096²), per-GIF (64 MB) and cache (192 MB) memory

A few MB of hostile GIF could decode to millions of tiny frames or a 100 Mpx canvas per frame,
and eight cached GIFs could hold 2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmytro Smaha 2026-10-10 13:21:29 +03:00 committed by GitHub
parent e8c4c60884
commit 3985d768e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 394 additions and 29 deletions

View File

@ -35,7 +35,8 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV (pure-Rust decoders); poster frames, trim, fades,
volume, loop, rewind, play across slides, hide during show, full screen; an in-place control bar
in the editor and `media.play/pause/stop/seek/info/posterFrame` for agents. WMA/WMV are
recognised and embedded but not yet playable.
recognised and embedded but not yet playable. Animated GIFs play in the editor and the show;
pause them from their corner button or `media.gifPlay`.
- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…).
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
palette, light/dark.

View File

@ -29,3 +29,8 @@ real_name = "Alexander Alber"
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.DimaSmaha]
real_name = "Dmytro Smaha"
display_name = "Dima Smaha"
sync_github_name = false

View File

@ -33,6 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
poster_frame
),
cmd!(noundo "media.gifPlay", "Play/Pause GIF", [], None, "{id?, play?: bool (default: toggle)} → {id, playing}", has_doc, gif_play),
]
}
@ -148,6 +149,34 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id, "ms": ms}))
}
/// Pause or play an animated GIF picture (`id` / `ids` / the selection, else the only one on the
/// slide). The UI host keeps its clock: a paused GIF holds its frame and resumes from it.
fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "media.gifPlay";
let st = s.doc()?;
let is_gif = |id: ShapeId| {
let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { return false };
st.doc.media(fill.media).and_then(|m| crate::render::gif_frame(&m.data, &fill.adjust, 0.0)).is_some()
};
let ids = targets(s, p)?;
let id = match ids.iter().copied().find(|id| is_gif(*id)) {
Some(id) => id,
None if !ids.is_empty() && (p.get("id").is_some() || p.get("ids").is_some()) => return Err(bad(cmd, "not an animated GIF")),
None => match st.shapes().iter().map(|sh| sh.id).filter(|id| is_gif(*id)).collect::<Vec<_>>().as_slice() {
[id] => *id,
[] => return Err(bad(cmd, "no animated GIF on this slide")),
_ => return Err(bad(cmd, "select an animated GIF, or pass `id`")),
},
};
let play = bool_param(p, "play").unwrap_or_else(|| s.gif_paused.contains(&id));
if play {
s.gif_paused.remove(&id);
} else {
s.gif_paused.insert(id);
}
Ok(json!({"id": id, "playing": play}))
}
#[cfg(test)]
mod tests {
use super::*;
@ -208,6 +237,27 @@ mod tests {
assert!(s.execute("media.play", &json!({"id": sh["id"]})).is_err());
}
#[test]
fn gif_play_pauses_and_resumes_an_animated_gif() {
use image::{Delay, Frame, Rgba, RgbaImage};
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
let mut gif = Vec::new();
image::codecs::gif::GifEncoder::new(&mut gif).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
let mut s = Session::with_new();
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
assert!(s.execute("media.gifPlay", &json!({})).is_err(), "no GIF yet");
let r = s.execute("insert.picture", &json!({"name": "a.gif", "data": base64_encode(&gif)})).expect("insert");
let id = ShapeId(r["id"].as_u64().expect("id") as u32);
let r = s.execute("media.gifPlay", &json!({})).expect("toggle");
assert_eq!(r["playing"], false);
assert!(s.gif_paused.contains(&id));
let r = s.execute("media.gifPlay", &json!({"id": id, "play": true})).expect("play");
assert_eq!(r["playing"], true);
assert!(s.gif_paused.is_empty());
let sh = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 10, 10]})).expect("shape");
assert!(s.execute("media.gifPlay", &json!({"id": sh["id"]})).is_err());
}
#[test]
fn wma_inserts_but_reports_unplayable() {
let mut s = Session::with_new();

View File

@ -309,6 +309,8 @@ pub struct Session {
pub recovery_dir: Option<std::path::PathBuf>,
/// Media playback state, written by the UI host every frame (empty without a host).
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
pub gif_paused: std::collections::HashSet<ShapeId>,
}
impl Default for Session {
@ -334,6 +336,7 @@ impl Session {
depth: 0,
recovery_dir: None,
media_status: Default::default(),
gif_paused: Default::default(),
}
}
/// A session with one new presentation open.

View File

@ -21,6 +21,41 @@ struct Cache {
bytes: usize,
clock: u64,
bad: HashMap<usize, Arc<Vec<u8>>>,
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
}
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
/// decoded at all; and the decoded size of all cached GIFs together. Hostile GIFs (millions of
/// tiny frames, a huge canvas) stop at these instead of exhausting memory.
const GIF_SIDE: u32 = 1024;
const GIF_BUDGET: usize = 64 << 20;
const GIF_MAX_FRAMES: usize = 1000;
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
const GIF_CACHE_BUDGET: usize = 192 << 20;
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
struct Anim {
frames: Vec<Arc<Pixmap>>,
ends: Vec<f64>,
}
impl Anim {
/// Decoded bytes held by the frames.
fn bytes(&self) -> usize {
self.frames.iter().map(|f| f.width() as usize * f.height() as usize * 4).sum()
}
/// The frame showing `t` seconds into the (looping) animation and the seconds until the next.
fn at(&self, t: f64) -> (usize, f64) {
let total = self.ends.last().copied().unwrap_or(0.0);
if total <= 0.0 || !t.is_finite() {
return (0, 1.0);
}
let local = t.max(0.0) % total;
let i = self.ends.partition_point(|e| *e <= local).min(self.ends.len().saturating_sub(1));
(i, (self.ends.get(i).copied().unwrap_or(total) - local).max(0.001))
}
}
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
@ -94,18 +129,7 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
}
apply_adjust(&mut img, adj);
let (w, h) = img.dimensions();
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
let pm = Arc::new(Pixmap::from_parts(data, w as u16, h as u16));
let pm = pixmap(&img)?;
with(|c| {
c.clock += 1;
let n = w as usize * h as usize * 4;
@ -121,6 +145,89 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
Some(pm)
}
/// Straight RGBA to a premultiplied pixmap.
fn pixmap(img: &image::RgbaImage) -> Option<Arc<Pixmap>> {
let (w, h) = img.dimensions();
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
}
/// Decode every frame of an animated GIF (`None` for a still or broken one).
fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
use image::{AnimationDecoder, ImageDecoder};
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
dec.set_limits(image::Limits::default()).ok()?;
let (w, h) = dec.dimensions();
if w == 0 || h == 0 || (w as u64) * (h as u64) > GIF_MAX_CANVAS {
return None;
}
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
let size = nw as usize * nh as usize * 4;
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
for f in dec.into_frames() {
let Ok(f) = f else { break };
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
log::info!("animated GIF: keeping its first {} frames", frames.len());
break;
}
let (n, d) = f.delay().numer_denom_ms();
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
// Like browsers: frames of 10 ms or less show for 100 ms.
t += if ms <= 10.0 { 0.1 } else { ms / 1000.0 };
let mut img = f.into_buffer();
if img.dimensions() != (nw, nh) {
img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle);
}
apply_adjust(&mut img, adj);
frames.push(pixmap(&img)?);
ends.push(t);
}
(frames.len() > 1).then_some(Anim { frames, ends })
}
/// The decoded animation of GIF bytes, once per bytes and adjustments.
fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
if !bytes.starts_with(b"GIF8") {
return None;
}
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
return a;
}
let a = decode_anim(bytes, adj).map(Arc::new);
with(|c| {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
c.anims.clear();
}
c.anims.insert(key, (bytes.clone(), a.clone()));
});
a
}
/// For an animated GIF: the frame showing `t` seconds into its (looping) play and the seconds until
/// the next frame. `None` for anything else.
pub fn gif_frame(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<(usize, f64)> {
anim(bytes, adj).map(|a| a.at(t))
}
/// The frame of an animated GIF at `t` seconds.
pub(crate) fn gif_pixmap(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<Arc<Pixmap>> {
let a = anim(bytes, adj)?;
a.frames.get(a.at(t).0).cloned()
}
fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
let sat = a.saturation.unwrap_or(1.0);
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
@ -152,3 +259,24 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
#[test]
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
use image::{Delay, Frame, Rgba, RgbaImage};
let frames = (0..GIF_MAX_FRAMES + 200).map(|i| {
let c = if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
Frame::from_parts(RgbaImage::from_pixel(1, 1, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(20, 1))
});
let mut out = Vec::new();
image::codecs::gif::GifEncoder::new(&mut out).encode_frames(frames).expect("gif");
let a = decode_anim(&out, &PictureAdjust::default()).expect("animated");
assert_eq!(a.frames.len(), GIF_MAX_FRAMES);
assert_eq!(a.ends.len(), GIF_MAX_FRAMES);
}
}

View File

@ -30,7 +30,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::{RenderContext, Resources, peniko};
pub use images::decode as decode_image;
pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
@ -145,6 +145,8 @@ pub struct RenderOpts<'a> {
pub skip: &'a [ShapeId],
/// Hide the text of this shape (the UI draws it while editing).
pub skip_text: Option<ShapeId>,
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
pub gif_times: &'a [(ShapeId, f64)],
/// Worker threads (0 = this thread; required when effects use filters).
pub threads: u16,
/// Translate the slide within the output (pixels) and output size override.
@ -164,6 +166,7 @@ impl Default for RenderOpts<'_> {
state: None,
skip: &[],
skip_text: None,
gif_times: &[],
threads: 0,
offset: (0.0, 0.0),
size: None,
@ -663,7 +666,8 @@ impl Renderer {
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(&outline);
paint::picture(ctx, f.pres, pf, bounds, m);
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
paint::picture(ctx, f.pres, pf, bounds, m, t);
ctx.pop_layer();
}
ShapeKind::Media(mc) => {
@ -671,7 +675,7 @@ impl Renderer {
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() };
paint::picture(ctx, f.pres, &pf, bounds, m);
paint::picture(ctx, f.pres, &pf, bounds, m, None);
} else if mc.video {
ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255));
ctx.fill_rect(&bounds);

View File

@ -65,7 +65,7 @@ pub fn fill_path(
}
Fill::Picture(pf) => {
ctx.push_clip_layer(path);
picture(ctx, pres, pf, bounds, m);
picture(ctx, pres, pf, bounds, m, None);
ctx.pop_layer();
}
Fill::Pattern(p) => {
@ -120,15 +120,17 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
}
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine) {
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
/// GIF shows its frame at `gif_time` seconds.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
let Some(item) = pres.media(pf.media) else {
missing(ctx, bounds);
return;
};
let px_scale = crate::scale_of(m);
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
let Some(pm) = images::decode_for(&item.data, want, &pf.adjust) else {
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
missing(ctx, bounds);
return;
};

View File

@ -273,6 +273,39 @@ fn morph_text_ends_match_the_slides() {
assert!(ink > 50, "ink {ink}");
}
/// Two 8×8 frames, red then blue, 100 ms each.
fn two_frame_gif() -> Vec<u8> {
use image::{Delay, Frame, Rgba, RgbaImage};
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
let mut out = Vec::new();
image::codecs::gif::GifEncoder::new(&mut out).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
out
}
#[test]
fn animated_gif_shows_the_frame_for_its_time() {
let mut p = Presentation::default();
let media = p.add_media("a.gif", "image/gif", two_frame_gif());
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let at = |t: Option<f64>| {
let times: Vec<(ShapeId, f64)> = t.map(|t| vec![(ShapeId(600), t)]).unwrap_or_default();
render_slide(&p, 0, &RenderOpts { scale: 0.1, gif_times: &times, ..Default::default() }).pixel(48, 27)
};
let red = |px: [u8; 4]| px[0] > 200 && px[2] < 60;
let blue = |px: [u8; 4]| px[2] > 200 && px[0] < 60;
assert!(red(at(None)), "no time: the first frame, as exports draw it");
assert!(red(at(Some(0.05))));
assert!(blue(at(Some(0.15))));
assert!(red(at(Some(0.25))), "it loops");
let bytes = p.media(media).map(|m| m.data.clone()).expect("media");
let adj = Default::default();
assert_eq!(gif_frame(&bytes, &adj, 0.15).map(|f| f.0), Some(1));
assert_eq!(gif_frame(&bytes, &adj, f64::NAN).map(|f| f.0), Some(0));
assert!(gif_frame(&std::sync::Arc::new(b"\x89PNG".to_vec()), &adj, 0.0).is_none());
}
#[test]
fn morph_path_ends_match_the_slides() {
let from = Xfrm::new(100.0, 100.0, 200.0, 200.0);

View File

@ -100,12 +100,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
let ppp = ui.ctx().pixels_per_point();
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
let now = ui.input(|i| i.time);
let gifs = match app.session.active() {
Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused),
_ => vec![],
};
crate::media::gif_repaint(ui.ctx(), &gifs);
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
let st = app.session.active();
let tex = match st.map(|d| (d.selection.target, d)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
app.textures.slide(ui.ctx(), &doc, idx, px, true, 0, app.ui.grayscale)
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
@ -146,9 +153,37 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
media_bar(app, ui, xf);
gif_buttons(app, ui, xf, &gifs);
context_menu(app, &resp);
}
/// Play/pause on an animated GIF under the pointer or selected on its own.
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
let Some(d) = app.session.active() else { return };
if d.selection.text.is_some() || app.session.tool.dragging() {
return;
}
let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None };
let pointer = ui.input(|i| i.pointer.hover_pos());
let mut toggle = None;
for g in gifs {
let r = screen_rect(xf, &g.rect);
if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) {
continue;
}
let b = crate::media::gif_button_rect(r);
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
if resp.clicked() {
toggle = Some(g.id);
}
}
if let Some(id) = toggle {
let _ = app.run("media.gifPlay", json!({"id": id}));
}
}
/// Screen rect of a slide-space box (rotation ignored).
fn screen_rect(xf: Xf, x: &Xfrm) -> Rect {
Rect::from_min_max(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)))

View File

@ -1,7 +1,7 @@
//! Media playback in the UI host: the [`deckcraft_media::Player`] (audio out + clocks), video
//! feeds and their textures, the editor's media control bar, and the engine's `media.*` requests.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_engine::{MediaStatus, Session};
@ -31,6 +31,18 @@ pub struct MediaHost {
videos: HashMap<ShapeId, Video>,
/// Probed durations by media bytes (for media imported without a probe).
durations: HashMap<usize, f64>,
/// Animated GIF clocks: when each started and, while paused, the time it holds.
gifs: HashMap<ShapeId, (f64, Option<f64>)>,
}
/// An animated GIF picture on a slide: its box, play time, frame and seconds to the next frame.
pub struct Gif {
pub id: ShapeId,
pub rect: deckcraft_geom::Xfrm,
pub t: f64,
pub frame: usize,
pub next: f64,
pub paused: bool,
}
impl Default for MediaHost {
@ -45,7 +57,30 @@ fn key(id: ShapeId) -> u64 {
impl MediaHost {
pub fn new(out: Option<Box<dyn AudioOut>>) -> MediaHost {
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new() }
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new(), gifs: HashMap::new() }
}
/// The animated GIF pictures directly on slide `index` at `now`: each plays from when it was
/// first shown; a paused one holds its time and resumes from it.
pub fn gifs(&mut self, doc: &Presentation, index: usize, now: f64, paused: &HashSet<ShapeId>) -> Vec<Gif> {
let Some(slide) = doc.slides.get(index) else { return vec![] };
let mut out = vec![];
for sh in &slide.shapes {
let (ShapeKind::Picture { fill }, Some(rect)) = (&sh.kind, sh.xfrm) else { continue };
let Some(item) = doc.media(fill.media).filter(|m| m.data.starts_with(b"GIF8")) else { continue };
let p = paused.contains(&sh.id);
let c = self.gifs.entry(sh.id).or_insert((now, None));
if p && c.1.is_none() {
c.1 = Some(now - c.0);
} else if !p && let Some(held) = c.1.take() {
c.0 = now - held;
}
let t = c.1.unwrap_or(now - c.0);
if let Some((frame, next)) = deckcraft_render::gif_frame(&item.data, &fill.adjust, t) {
out.push(Gif { id: sh.id, rect, t, frame, next, paused: p });
}
}
out
}
/// Replace the audio output (the host injects cpal after start-up).
@ -261,6 +296,37 @@ pub fn paint_frame(painter: &egui::Painter, host: &MediaHost, id: ShapeId, rect:
true
}
/// Changes whenever a GIF shows another frame (part of the slide texture's cache key).
pub fn gif_key(gifs: &[Gif]) -> u64 {
gifs.iter().fold(0, |h: u64, g| (h ^ (((g.id.0 as u64) << 32) | g.frame as u64)).wrapping_mul(1099511628211))
}
/// Come back when the next frame of a playing GIF is due.
pub fn gif_repaint(ctx: &egui::Context, gifs: &[Gif]) {
if let Some(next) = gifs.iter().filter(|g| !g.paused).map(|g| g.next).reduce(f64::min) {
ctx.request_repaint_after(std::time::Duration::from_millis((next * 1000.0).clamp(5.0, 1000.0) as u64));
}
}
/// The play/pause button in the bottom-left corner of a GIF's box `r` (PowerPoint puts it there).
pub fn gif_button_rect(r: Rect) -> Rect {
let s = 24.0f32.min(r.width() - 8.0).min(r.height() - 8.0).max(12.0);
Rect::from_min_size(pos2(r.min.x + 6.0, r.max.y - 6.0 - s), vec2(s, s))
}
pub fn paint_gif_button(painter: &egui::Painter, b: Rect, paused: bool, hot: bool) {
painter.circle_filled(b.center(), b.width() / 2.0, Color32::from_black_alpha(if hot { 210 } else { 150 }));
let r = b.shrink(b.width() * 0.3);
let white = Color32::from_gray(235);
if paused {
icons::paint(painter, r, Icon::Play, white, false);
} else {
let w = r.width() * 0.3;
painter.rect_filled(Rect::from_min_size(r.min, vec2(w, r.height())), CornerRadius::ZERO, white);
painter.rect_filled(Rect::from_min_size(pos2(r.max.x - w, r.min.y), vec2(w, r.height())), CornerRadius::ZERO, white);
}
}
/// What the control bar asks for.
pub enum BarAction {
Toggle,

View File

@ -47,6 +47,8 @@ struct Key {
doc: usize,
/// Media hidden while not playing.
hidden: Vec<ShapeId>,
/// The frames animated GIFs show.
gif: u64,
}
struct Transition {
@ -279,8 +281,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
_ => {}
}
}
let gif_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { gif_hit(&ctx, ui, &show, &doc, app) } else { None };
let media_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
if let Some((id, clip)) = media_click {
if let Some(id) = gif_click {
let _ = app.run("media.gifPlay", json!({"id": id}));
} else if let Some((id, clip)) = media_click {
app.media.toggle(&doc, id, &clip, crate::media::Owner::Show);
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) {
if show.ended {
@ -357,7 +362,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
})
.map(|m| m.0)
.collect();
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone() };
let gifs = app.media.gifs(&doc, idx, now, &app.session.gif_paused);
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
crate::media::gif_repaint(&ctx, &gifs);
let gif = crate::media::gif_key(&gifs);
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif };
let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {
show.tex.as_ref().map(|(_, t)| t.clone())
} else {
@ -379,6 +388,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
scale: size.0 as f64 / doc.slide_size.width.max(1.0),
size: Some(size),
state: Some(&f),
gif_times: &times,
threads,
..Default::default()
},
@ -477,6 +487,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
};
crate::media::paint_frame(&painter, &app.media, *id, r);
}
// Play/pause on the animated GIF under the pointer.
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && now - show.last_move < 2.5) {
for g in &gifs {
let r = on_screen(srect, k, &g.rect);
if r.contains(p) {
let b = crate::media::gif_button_rect(r);
crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p));
}
}
}
}
if animating {
ctx.request_repaint();
@ -641,6 +661,22 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O
})
}
/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point.
fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect {
Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k))
}
/// The animated GIF whose play/pause button is under the pointer on the shown slide.
fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation, app: &mut SlideApp) -> Option<ShapeId> {
let p = ctx.input(|i| i.pointer.interact_pos())?;
let full = ui.max_rect();
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
let srect = fit(avail, doc);
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
let gifs = app.media.gifs(doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
}
impl Show {
fn reading_bar_hit(&self, ctx: &egui::Context) -> bool {
// Clicks on the on-screen controls must not also advance.
@ -761,7 +797,7 @@ mod tests {
let ctx = egui::Context::default();
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
let mut show = Show::new(&doc, 0, false, false);
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![] }, red.clone()));
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone()));
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
let tr = show.trans.as_ref().expect("the fade plays");
assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id()));

View File

@ -4,7 +4,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::{Master, Presentation, Slide};
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
use deckcraft_render::{Image, RenderOpts};
use egui::{ColorImage, TextureHandle, TextureOptions};
@ -60,7 +60,7 @@ fn threads() -> u16 {
}
impl Textures {
/// The canvas texture for slide `index` at `size` pixels.
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
pub fn slide(
&mut self,
ctx: &egui::Context,
@ -70,6 +70,7 @@ impl Textures {
edit: bool,
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
@ -87,7 +88,8 @@ impl Textures {
}
let t0 = crate::now_ms();
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, edit, threads: threads(), size: Some(size), ..Default::default() });
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
let img = deckcraft_render::render_slide(p, index, &opts);
self.last_render_ms = crate::now_ms() - t0;
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {