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:
parent
e8c4c60884
commit
3985d768e6
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: ×, ..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);
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: ×,
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue