diff --git a/ROADMAP.md b/ROADMAP.md index 70b7e1c..1c75ffa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/contributors/people.toml b/contributors/people.toml index a8084ab..0bd95f6 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 5402ad8..855370a 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -33,6 +33,7 @@ pub fn specs() -> Vec { 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 { 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 { + 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::>().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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a7d47aa..d87653d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -309,6 +309,8 @@ pub struct Session { pub recovery_dir: Option, /// Media playback state, written by the UI host every frame (empty without a host). pub media_status: std::collections::HashMap, + /// Animated GIF pictures paused with `media.gifPlay` (the others play). + pub gif_paused: std::collections::HashSet, } 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. diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index cfca026..f95102a 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -21,6 +21,41 @@ struct Cache { bytes: usize, clock: u64, bad: HashMap>>, + /// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them. + anims: HashMap<(usize, u64), (Arc>, Option>)>, +} + +/// 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>, + ends: Vec, +} + +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> = Mutex::new(None); @@ -94,18 +129,7 @@ pub fn decode_for(bytes: &Arc>, 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 = 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>, want: f64, adj: &PictureAdjust) -> Optio Some(pm) } +/// Straight RGBA to a premultiplied pixmap. +fn pixmap(img: &image::RgbaImage) -> Option> { + let (w, h) = img.dimensions(); + if w > u16::MAX as u32 || h > u16::MAX as u32 { + return None; + } + let data: Vec = 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 { + 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>, adj: &PictureAdjust) -> Option> { + 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>, 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>, adj: &PictureAdjust, t: f64) -> Option> { + 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); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 950c143..0bbdcfb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, + /// 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); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index e377042..1dcbcd8 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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) { 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; }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 1f7a713..b07244a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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 { + 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| { + 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: ×, ..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); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dcd6274..d267f76 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, ×) } 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))) diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index a101ce2..f887c2b 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -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, /// Probed durations by media bytes (for media imported without a probe). durations: HashMap, + /// Animated GIF clocks: when each started and, while paused, the time it holds. + gifs: HashMap)>, +} + +/// 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>) -> 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) -> Vec { + 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, diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 60b5b39..cf13263 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -47,6 +47,8 @@ struct Key { doc: usize, /// Media hidden while not playing. hidden: Vec, + /// 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: ×, 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 { + 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())); diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index b81360f..84cf275 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -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 { 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() {