Skip to main content

maple_render_core/
webp_anim.rs

1//! Native animated WebP output backed by libwebp.
2
3#[cfg(windows)]
4use std::sync::OnceLock;
5use std::{ffi::CStr, fs::File, io::Write, mem, path::Path, ptr, slice};
6
7use libwebp_sys as ffi;
8use rayon::prelude::*;
9
10use crate::{
11    error::{Error, Result},
12    renders::Renders,
13};
14
15/// Quality used for lossy WebP encoding.
16pub const DEFAULT_WEBP_QUALITY: f32 = 95.0;
17
18/// Balanced libwebp method used by the published encoder.
19pub const DEFAULT_WEBP_METHOD: usize = 4;
20
21const MAX_WEBP_DIMENSION: u32 = 16_383;
22const MAX_WEBP_FRAME_DURATION_MS: i64 = (1 << 24) - 1;
23const PARALLEL_ENCODER_MEMORY_BUDGET: usize = 256 * 1024 * 1024;
24const ESTIMATED_NATIVE_BYTES_PER_PIXEL: usize = 16;
25
26// libwebp 1.6.0 synchronizes lazy DSP initialization on Unix, but its
27// WEBP_USE_THREAD guard excludes Windows. Warm every native path Maple uses
28// once there before Rayon starts concurrent frame encodes.
29#[cfg(windows)]
30static LIBWEBP_INITIALIZED: OnceLock<std::result::Result<(), String>> = OnceLock::new();
31
32/// Per-frame WebP compression options.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct WebpOptions {
35    /// Lossy quality or lossless compression effort in the range 0 through 100.
36    pub quality: f32,
37    /// Use lossless compression when true.
38    pub lossless: bool,
39    /// Compression method in the range 0 through 6. Zero is fastest.
40    pub method: usize,
41}
42
43impl Default for WebpOptions {
44    fn default() -> Self {
45        Self { quality: DEFAULT_WEBP_QUALITY, lossless: false, method: DEFAULT_WEBP_METHOD }
46    }
47}
48
49/// Animation-level WebP options.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub struct WebpAnimationOptions {
52    /// Number of animation loops. Zero means infinite.
53    pub loop_count: u16,
54    /// Spend additional time minimizing the complete animation size.
55    pub minimize_size: bool,
56    /// Minimum distance between keyframes.
57    pub kmin: i32,
58    /// Maximum distance between keyframes. Zero disables keyframe insertion.
59    pub kmax: i32,
60    /// Permit libwebp to choose lossy or lossless compression per frame.
61    pub allow_mixed: bool,
62}
63
64/// One borrowed RGBA frame in an animation timeline.
65#[derive(Debug, Clone, Copy)]
66pub struct WebpFrame<'a> {
67    /// Tightly packed RGBA pixels for the complete canvas.
68    pub rgba: &'a [u8],
69    /// Start time in milliseconds. Frame timestamps must strictly increase.
70    pub timestamp_ms: i32,
71}
72
73impl<'a> WebpFrame<'a> {
74    pub fn new(rgba: &'a [u8], timestamp_ms: i32) -> Self {
75        Self { rgba, timestamp_ms }
76    }
77}
78
79/// Encode complete RGBA frames concurrently and assemble an animated WebP.
80///
81/// This is an opt-in high-throughput path for callers that already hold every
82/// frame in memory. Unchanged pixels are omitted from later frames, while
83/// libwebp encodes independent frame rectangles in parallel through Rayon.
84/// Because frames are encoded independently, output can be materially larger
85/// than [`WebpEncoder`]'s animation-wide optimization. Use [`WebpEncoder`] for
86/// bounded memory and smaller output.
87pub fn encode_webp_animation(
88    dimensions: (u32, u32),
89    frames: &[WebpFrame<'_>],
90    final_timestamp_ms: i32,
91    options: WebpOptions,
92    loop_count: u16,
93) -> Result<Vec<u8>> {
94    let expected_frame_bytes = frame_bytes(dimensions)?;
95    let options = normalize_options(options)?;
96    let durations = validate_frames(frames, final_timestamp_ms, expected_frame_bytes)?;
97    initialize_libwebp()?;
98    let (width, height) = (dimensions.0 as usize, dimensions.1 as usize);
99
100    let rectangles: Vec<_> = frames
101        .par_iter()
102        .enumerate()
103        .map(|(index, frame)| {
104            if index == 0 {
105                Some(FrameRect::full(width, height))
106            } else {
107                dirty_rect(frames[index - 1].rgba, frame.rgba, width)
108            }
109        })
110        .collect();
111
112    let mut plans: Vec<FramePlan<'_>> = Vec::with_capacity(frames.len());
113    for ((frame, rectangle), duration_ms) in
114        frames.iter().zip(rectangles).zip(durations.into_iter())
115    {
116        if let Some(rectangle) = rectangle {
117            plans.push(FramePlan { rgba: frame.rgba, rectangle, duration_ms });
118        } else {
119            let previous = plans.last_mut().expect("the first frame is always present");
120            if previous.duration_ms <= MAX_WEBP_FRAME_DURATION_MS - duration_ms {
121                previous.duration_ms += duration_ms;
122            } else {
123                plans.push(FramePlan {
124                    rgba: frame.rgba,
125                    rectangle: FrameRect::full(width, height),
126                    duration_ms,
127                });
128            }
129        }
130    }
131
132    let mut encoded = Vec::with_capacity(plans.len());
133    let mut remaining = plans.as_slice();
134    while !remaining.is_empty() {
135        let chunk_len = parallel_chunk_len(remaining);
136        let (chunk, rest) = remaining.split_at(chunk_len);
137        let encoded_chunk: Result<Vec<_>> =
138            chunk.par_iter().map(|plan| encode_frame_rect(plan, width, options)).collect();
139        encoded.extend(encoded_chunk?);
140        remaining = rest;
141    }
142    mux_frames(
143        dimensions,
144        &plans,
145        &encoded,
146        WebpAnimationOptions { loop_count, ..Default::default() },
147    )
148}
149
150#[derive(Debug, Clone, Copy)]
151struct FrameRect {
152    x: usize,
153    y: usize,
154    width: usize,
155    height: usize,
156}
157
158impl FrameRect {
159    fn full(width: usize, height: usize) -> Self {
160        Self { x: 0, y: 0, width, height }
161    }
162}
163
164#[derive(Debug, Clone, Copy)]
165struct FramePlan<'a> {
166    rgba: &'a [u8],
167    rectangle: FrameRect,
168    duration_ms: i64,
169}
170
171fn parallel_chunk_len(plans: &[FramePlan<'_>]) -> usize {
172    parallel_chunk_len_for(plans, rayon::current_num_threads())
173}
174
175fn parallel_chunk_len_for(plans: &[FramePlan<'_>], available_threads: usize) -> usize {
176    let mut estimated_bytes = 0usize;
177    let mut chunk_len = 0;
178    for plan in plans.iter().take(available_threads.max(1)) {
179        let plan_bytes = plan
180            .rectangle
181            .width
182            .saturating_mul(plan.rectangle.height)
183            .saturating_mul(ESTIMATED_NATIVE_BYTES_PER_PIXEL)
184            .max(1);
185        if chunk_len > 0
186            && estimated_bytes.saturating_add(plan_bytes) > PARALLEL_ENCODER_MEMORY_BUDGET
187        {
188            break;
189        }
190        estimated_bytes = estimated_bytes.saturating_add(plan_bytes);
191        chunk_len += 1;
192    }
193    chunk_len.max(1)
194}
195
196fn validate_frames(
197    frames: &[WebpFrame<'_>],
198    final_timestamp_ms: i32,
199    expected_frame_bytes: usize,
200) -> Result<Vec<i64>> {
201    if frames.is_empty() {
202        return Err(webp_error("no frames were provided"));
203    }
204    for frame in frames {
205        if frame.rgba.len() != expected_frame_bytes {
206            return Err(Error::WebpEncode(format!(
207                "RGBA frame has {} bytes; expected {}",
208                frame.rgba.len(),
209                expected_frame_bytes
210            )));
211        }
212    }
213    for timestamps in frames.windows(2) {
214        if timestamps[1].timestamp_ms <= timestamps[0].timestamp_ms {
215            return Err(webp_error("frame timestamps must be strictly increasing"));
216        }
217    }
218    if final_timestamp_ms < frames.last().unwrap().timestamp_ms {
219        return Err(webp_error("final timestamp must not precede the last frame"));
220    }
221
222    frames
223        .iter()
224        .enumerate()
225        .map(|(index, frame)| {
226            let next_timestamp =
227                frames.get(index + 1).map_or(final_timestamp_ms, |next| next.timestamp_ms);
228            let duration = i64::from(next_timestamp) - i64::from(frame.timestamp_ms);
229            if duration > MAX_WEBP_FRAME_DURATION_MS {
230                Err(webp_error("frame duration exceeds WebP's limit"))
231            } else {
232                Ok(duration)
233            }
234        })
235        .collect()
236}
237
238fn dirty_rect(previous: &[u8], current: &[u8], width: usize) -> Option<FrameRect> {
239    let row_bytes = width * 4;
240    let mut rows = previous.chunks_exact(row_bytes).zip(current.chunks_exact(row_bytes));
241    let top = rows.clone().position(|(before, after)| before != after)?;
242    let bottom = rows.rposition(|(before, after)| before != after).unwrap();
243    let mut left = width;
244    let mut right = 0;
245
246    for y in top..=bottom {
247        let start = y * row_bytes;
248        let before = &previous[start..start + row_bytes];
249        let after = &current[start..start + row_bytes];
250        if before == after {
251            continue;
252        }
253        let first_byte = before.iter().zip(after).position(|(a, b)| a != b).unwrap();
254        let last_byte = before.iter().zip(after).rposition(|(a, b)| a != b).unwrap();
255        left = left.min(first_byte / 4);
256        right = right.max(last_byte / 4 + 1);
257    }
258
259    let x = left & !1;
260    let y = top & !1;
261    Some(FrameRect { x, y, width: right - x, height: bottom + 1 - y })
262}
263
264unsafe extern "C" fn write_webp_memory(
265    data: *const u8,
266    size: usize,
267    picture: *const ffi::WebPPicture,
268) -> i32 {
269    // SAFETY: libwebp invokes this callback with its live picture and output
270    // bytes. custom_ptr was initialized to a live WebPMemoryWriter below.
271    unsafe { ffi::WebPMemoryWrite(data, size, picture) }
272}
273
274fn encode_frame_rect(
275    plan: &FramePlan<'_>,
276    canvas_width: usize,
277    options: WebpOptions,
278) -> Result<Vec<u8>> {
279    let rect = plan.rectangle;
280    // SAFETY: the picture, config, and memory writer are initialized through
281    // libwebp before use. Every native allocation is released before return.
282    unsafe {
283        let mut config = mem::zeroed();
284        if ffi::WebPConfigInitInternal(
285            &mut config,
286            ffi::WebPPreset::WEBP_PRESET_DEFAULT,
287            75.0,
288            ffi::WEBP_ENCODER_ABI_VERSION as i32,
289        ) == 0
290        {
291            return Err(webp_error("could not initialize frame options"));
292        }
293        config.lossless = i32::from(options.lossless);
294        config.quality = options.quality;
295        config.method = options.method as i32;
296        config.exact = i32::from(options.lossless);
297        if ffi::WebPValidateConfig(&config) == 0 {
298            return Err(webp_error("libwebp rejected the frame options"));
299        }
300
301        let mut picture = mem::zeroed();
302        if ffi::WebPPictureInitInternal(&mut picture, ffi::WEBP_ENCODER_ABI_VERSION as i32) == 0 {
303            return Err(webp_error("could not initialize a frame"));
304        }
305        picture.width = rect.width as i32;
306        picture.height = rect.height as i32;
307        picture.use_argb = 1;
308        let offset = (rect.y * canvas_width + rect.x) * 4;
309        if ffi::WebPPictureImportRGBA(
310            &mut picture,
311            plan.rgba.as_ptr().add(offset),
312            (canvas_width * 4) as i32,
313        ) == 0
314        {
315            ffi::WebPPictureFree(&mut picture);
316            return Err(webp_error("could not import an RGBA frame"));
317        }
318
319        let mut writer = mem::zeroed();
320        ffi::WebPMemoryWriterInit(&mut writer);
321        picture.writer = Some(write_webp_memory);
322        picture.custom_ptr = (&mut writer as *mut ffi::WebPMemoryWriter).cast();
323        let encoded = ffi::WebPEncode(&config, &mut picture) != 0;
324        let output = if encoded && !writer.mem.is_null() {
325            slice::from_raw_parts(writer.mem, writer.size).to_vec()
326        } else {
327            Vec::new()
328        };
329        ffi::WebPMemoryWriterClear(&mut writer);
330        ffi::WebPPictureFree(&mut picture);
331
332        if !encoded || output.is_empty() {
333            Err(webp_error("could not encode a frame rectangle"))
334        } else {
335            Ok(output)
336        }
337    }
338}
339
340#[cfg(not(windows))]
341fn initialize_libwebp() -> Result<()> {
342    Ok(())
343}
344
345#[cfg(windows)]
346fn initialize_libwebp() -> Result<()> {
347    LIBWEBP_INITIALIZED
348        .get_or_init(|| initialize_libwebp_inner().map_err(|error| error.to_string()))
349        .as_ref()
350        .map_err(|message| Error::WebpEncode(message.clone()))
351        .copied()
352}
353
354#[cfg(windows)]
355fn initialize_libwebp_inner() -> Result<()> {
356    let rgba = [255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 0, 255, 255, 255, 255];
357    let plan = FramePlan { rgba: &rgba, rectangle: FrameRect::full(2, 2), duration_ms: 1 };
358
359    for lossless in [false, true] {
360        let encoded =
361            encode_frame_rect(&plan, 2, WebpOptions { lossless, ..WebpOptions::default() })?;
362        let mut width = 0;
363        let mut height = 0;
364        // SAFETY: encoded is a live WebP bitstream. libwebp owns the returned
365        // decode buffer until WebPFree releases it below.
366        let decoded = unsafe {
367            ffi::WebPDecodeRGBA(encoded.as_ptr(), encoded.len(), &mut width, &mut height)
368        };
369        if decoded.is_null() {
370            return Err(webp_error("could not initialize libwebp's decoder"));
371        }
372        unsafe { ffi::WebPFree(decoded.cast()) };
373    }
374    Ok(())
375}
376
377fn mux_frames(
378    dimensions: (u32, u32),
379    plans: &[FramePlan<'_>],
380    encoded: &[Vec<u8>],
381    animation: WebpAnimationOptions,
382) -> Result<Vec<u8>> {
383    // SAFETY: encoded frame buffers remain alive until assembly because mux
384    // receives non-owning references. The mux and assembled data are released
385    // on every return path.
386    unsafe {
387        let mux = ffi::WebPMuxNew();
388        if mux.is_null() {
389            return Err(webp_error("could not create WebP muxer"));
390        }
391        let result = (|| {
392            let canvas_status =
393                ffi::WebPMuxSetCanvasSize(mux, dimensions.0 as i32, dimensions.1 as i32);
394            if canvas_status != ffi::WebPMuxError::WEBP_MUX_OK {
395                return Err(mux_error("could not set animation canvas", canvas_status));
396            }
397            let params = ffi::WebPMuxAnimParams {
398                bgcolor: 0xffff_ffff,
399                loop_count: i32::from(animation.loop_count),
400            };
401            let params_status = ffi::WebPMuxSetAnimationParams(mux, &params);
402            if params_status != ffi::WebPMuxError::WEBP_MUX_OK {
403                return Err(mux_error("could not set animation options", params_status));
404            }
405
406            for (plan, bitstream) in plans.iter().zip(encoded) {
407                let frame = ffi::WebPMuxFrameInfo {
408                    bitstream: ffi::WebPData { bytes: bitstream.as_ptr(), size: bitstream.len() },
409                    x_offset: plan.rectangle.x as i32,
410                    y_offset: plan.rectangle.y as i32,
411                    duration: plan.duration_ms as i32,
412                    id: ffi::WebPChunkId::WEBP_CHUNK_ANMF,
413                    dispose_method: ffi::WebPMuxAnimDispose::WEBP_MUX_DISPOSE_NONE,
414                    blend_method: ffi::WebPMuxAnimBlend::WEBP_MUX_NO_BLEND,
415                    pad: [0],
416                };
417                let frame_status = ffi::WebPMuxPushFrame(mux, &frame, 0);
418                if frame_status != ffi::WebPMuxError::WEBP_MUX_OK {
419                    return Err(mux_error("could not add animation frame", frame_status));
420                }
421            }
422
423            let mut data = mem::zeroed();
424            ffi::WebPDataInit(&mut data);
425            let assemble_status = ffi::WebPMuxAssemble(mux, &mut data);
426            let output =
427                if assemble_status == ffi::WebPMuxError::WEBP_MUX_OK && !data.bytes.is_null() {
428                    slice::from_raw_parts(data.bytes, data.size).to_vec()
429                } else {
430                    Vec::new()
431                };
432            ffi::WebPDataClear(&mut data);
433            if assemble_status != ffi::WebPMuxError::WEBP_MUX_OK {
434                Err(mux_error("could not assemble animation", assemble_status))
435            } else if output.is_empty() {
436                Err(webp_error("muxer returned an empty file"))
437            } else {
438                Ok(output)
439            }
440        })();
441        ffi::WebPMuxDelete(mux);
442        result
443    }
444}
445
446fn mux_error(message: &str, status: ffi::WebPMuxError) -> Error {
447    Error::WebpEncode(format!("{message} (libwebp mux status {status:?})"))
448}
449
450/// Incremental RGBA animation encoder shared by Maple consumers.
451///
452/// The input slice is borrowed only for the duration of [`Self::add_frame`].
453/// libwebp performs architecture-specific runtime dispatch internally and
454/// falls back to its scalar implementation on unsupported CPUs.
455pub struct WebpEncoder {
456    raw: *mut ffi::WebPAnimEncoder,
457    picture: ffi::WebPPicture,
458    config: ffi::WebPConfig,
459    expected_frame_bytes: usize,
460    stride: i32,
461    previous_timestamp: Option<i32>,
462    failed: bool,
463}
464
465impl WebpEncoder {
466    /// Create an encoder with default animation settings.
467    pub fn new(dimensions: (u32, u32), options: WebpOptions) -> Result<Self> {
468        Self::new_with_animation_options(dimensions, options, WebpAnimationOptions::default())
469    }
470
471    /// Create an encoder with explicit frame and animation settings.
472    pub fn new_with_animation_options(
473        dimensions: (u32, u32),
474        options: WebpOptions,
475        animation: WebpAnimationOptions,
476    ) -> Result<Self> {
477        let expected_frame_bytes = frame_bytes(dimensions)?;
478        let options = normalize_options(options)?;
479        validate_animation_options(animation)?;
480        initialize_libwebp()?;
481        let (width, height) = (dimensions.0 as i32, dimensions.1 as i32);
482
483        // SAFETY: each libwebp structure is initialized before use, checked for
484        // failure, owned by this value, and released in Drop.
485        unsafe {
486            let mut animation_config = mem::zeroed();
487            if ffi::WebPAnimEncoderOptionsInitInternal(
488                &mut animation_config,
489                ffi::WEBP_MUX_ABI_VERSION as i32,
490            ) == 0
491            {
492                return Err(webp_error("could not initialize animation options"));
493            }
494            animation_config.anim_params.loop_count = i32::from(animation.loop_count);
495            animation_config.minimize_size = i32::from(animation.minimize_size);
496            animation_config.kmin = animation.kmin;
497            animation_config.kmax = animation.kmax;
498            animation_config.allow_mixed = i32::from(animation.allow_mixed);
499
500            let raw = ffi::WebPAnimEncoderNewInternal(
501                width,
502                height,
503                &animation_config,
504                ffi::WEBP_MUX_ABI_VERSION as i32,
505            );
506            if raw.is_null() {
507                return Err(webp_error("could not create animation encoder"));
508            }
509
510            let mut config = mem::zeroed();
511            if ffi::WebPConfigInitInternal(
512                &mut config,
513                ffi::WebPPreset::WEBP_PRESET_DEFAULT,
514                75.0,
515                ffi::WEBP_ENCODER_ABI_VERSION as i32,
516            ) == 0
517            {
518                ffi::WebPAnimEncoderDelete(raw);
519                return Err(webp_error("could not initialize frame options"));
520            }
521            config.lossless = i32::from(options.lossless);
522            config.quality = options.quality;
523            config.method = options.method as i32;
524            config.exact = i32::from(options.lossless);
525            if ffi::WebPValidateConfig(&config) == 0 {
526                ffi::WebPAnimEncoderDelete(raw);
527                return Err(webp_error("libwebp rejected the frame options"));
528            }
529
530            let mut picture = mem::zeroed();
531            if ffi::WebPPictureInitInternal(&mut picture, ffi::WEBP_ENCODER_ABI_VERSION as i32) == 0
532            {
533                ffi::WebPAnimEncoderDelete(raw);
534                return Err(webp_error("could not initialize a frame"));
535            }
536            picture.width = width;
537            picture.height = height;
538            picture.use_argb = 1;
539
540            Ok(Self {
541                raw,
542                picture,
543                config,
544                expected_frame_bytes,
545                stride: width * 4,
546                previous_timestamp: None,
547                failed: false,
548            })
549        }
550    }
551
552    /// Encode one tightly packed RGBA frame at an increasing timestamp.
553    pub fn add_frame(&mut self, rgba: &[u8], timestamp_ms: i32) -> Result<()> {
554        if self.failed {
555            return Err(webp_error("encoder cannot be reused after a native failure"));
556        }
557        if rgba.len() != self.expected_frame_bytes {
558            return Err(Error::WebpEncode(format!(
559                "RGBA frame has {} bytes; expected {}",
560                rgba.len(),
561                self.expected_frame_bytes
562            )));
563        }
564        if let Some(previous) = self.previous_timestamp {
565            if validate_frame_duration(previous, timestamp_ms)? == 0 {
566                return Err(webp_error("frame timestamps must be strictly increasing"));
567            }
568        }
569
570        // SAFETY: rgba has the validated canvas length and remains alive for
571        // both calls. libwebp imports it into picture-owned memory before the
572        // animation encoder consumes the picture synchronously.
573        if unsafe { ffi::WebPPictureImportRGBA(&mut self.picture, rgba.as_ptr(), self.stride) } == 0
574        {
575            self.failed = true;
576            return Err(webp_error("could not import an RGBA frame"));
577        }
578        if unsafe {
579            ffi::WebPAnimEncoderAdd(self.raw, &mut self.picture, timestamp_ms, &self.config)
580        } == 0
581        {
582            let error = self.encoder_error("could not encode frame");
583            self.failed = true;
584            return Err(Error::WebpEncode(error));
585        }
586        self.previous_timestamp = Some(timestamp_ms);
587        Ok(())
588    }
589
590    /// Finalize the timeline and return the complete WebP file.
591    pub fn finish(self, final_timestamp_ms: i32) -> Result<Vec<u8>> {
592        if self.failed {
593            return Err(webp_error("encoder cannot be finalized after a native failure"));
594        }
595        let Some(previous_timestamp) = self.previous_timestamp else {
596            return Err(webp_error("no frames were added"));
597        };
598        validate_frame_duration(previous_timestamp, final_timestamp_ms)?;
599
600        // SAFETY: self.raw is live and the null frame is libwebp's documented
601        // end-of-timeline sentinel.
602        if unsafe {
603            ffi::WebPAnimEncoderAdd(self.raw, ptr::null_mut(), final_timestamp_ms, ptr::null())
604        } == 0
605        {
606            return Err(Error::WebpEncode(self.encoder_error("could not finalize timeline")));
607        }
608
609        // SAFETY: WebPData is initialized before assembly. Its storage is
610        // copied into Rust ownership and cleared exactly once on every path.
611        let mut data = unsafe {
612            let mut data = mem::zeroed();
613            ffi::WebPDataInit(&mut data);
614            data
615        };
616        if unsafe { ffi::WebPAnimEncoderAssemble(self.raw, &mut data) } == 0 {
617            let error = self.encoder_error("could not assemble animation");
618            unsafe { ffi::WebPDataClear(&mut data) };
619            return Err(Error::WebpEncode(error));
620        }
621        let output = if data.bytes.is_null() {
622            Vec::new()
623        } else {
624            unsafe { slice::from_raw_parts(data.bytes, data.size) }.to_vec()
625        };
626        unsafe { ffi::WebPDataClear(&mut data) };
627        if output.is_empty() {
628            return Err(webp_error("encoder returned an empty file"));
629        }
630
631        Ok(output)
632    }
633
634    fn encoder_error(&self, fallback: &str) -> String {
635        let message = unsafe { ffi::WebPAnimEncoderGetError(self.raw) };
636        if message.is_null() {
637            fallback.to_string()
638        } else {
639            unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned()
640        }
641    }
642}
643
644impl Drop for WebpEncoder {
645    fn drop(&mut self) {
646        // SAFETY: both values were initialized in the constructor and are
647        // owned exclusively by this encoder.
648        unsafe {
649            ffi::WebPPictureFree(&mut self.picture);
650            ffi::WebPAnimEncoderDelete(self.raw);
651        }
652    }
653}
654
655pub struct WebpAnim {
656    renders: Renders,
657    period: f64,
658    hold: f64,
659    first_frame: i32,
660    options: WebpOptions,
661}
662
663impl WebpAnim {
664    pub fn new(renders: Renders) -> Self {
665        Self { renders, period: 0.1, hold: 5.0, first_frame: -1, options: WebpOptions::default() }
666    }
667
668    pub fn set_first_frame(&mut self, index: i32) {
669        self.first_frame = index;
670    }
671
672    pub fn set_timing(&mut self, period: f64, hold: f64) {
673        self.period = period;
674        self.hold = hold;
675    }
676
677    pub fn set_options(&mut self, options: WebpOptions) {
678        self.options = options;
679    }
680
681    /// Encode one RGBA image as a still WebP file.
682    pub fn encode_single(img: &image::RgbaImage, options: &WebpOptions) -> Result<Vec<u8>> {
683        let mut encoder = WebpEncoder::new(img.dimensions(), *options)?;
684        encoder.add_frame(img.as_raw(), 0)?;
685        encoder.finish(1)
686    }
687
688    /// Render and encode every template frame with bounded memory.
689    pub fn encode(&mut self) -> Result<Vec<u8>> {
690        let frame_count = i32::try_from(self.renders.length())
691            .map_err(|_| webp_error("frame count exceeds WebP's timestamp range"))?;
692        if frame_count == 0 {
693            return Err(webp_error("no frames to encode"));
694        }
695        let (timeline, final_timestamp) =
696            animation_timeline(self.period, self.hold, frame_count, self.first_frame)?;
697
698        let dimensions = self.renders.get_render(0)?.get().dimensions();
699        let mut encoder = WebpEncoder::new_with_animation_options(
700            dimensions,
701            self.options,
702            WebpAnimationOptions { kmin: 3, kmax: 5, ..Default::default() },
703        )?;
704        for (index, timestamp) in timeline {
705            encoder.add_frame(self.renders.get_render(index)?.get().as_raw(), timestamp)?;
706            self.renders.remove_render(index);
707        }
708
709        encoder.finish(final_timestamp)
710    }
711
712    /// Encode all frames and write the result to `path`.
713    pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
714        let data = self.encode()?;
715        let mut writer = std::io::BufWriter::new(File::create(path)?);
716        writer.write_all(&data)?;
717        Ok(())
718    }
719}
720
721fn validate_timing(period: f64, hold: f64, frame_count: i32) -> Result<()> {
722    if !period.is_finite() || !hold.is_finite() {
723        return Err(webp_error("animation timing must be finite"));
724    }
725    if period < 0.0 || hold < 0.0 {
726        return Err(webp_error("animation timing must not be negative"));
727    }
728
729    let total_ms = (period * f64::from(frame_count) + hold) * 1000.0;
730    let timestamp_upper_bound = total_ms.round() + f64::from(frame_count);
731    if !timestamp_upper_bound.is_finite() || timestamp_upper_bound > f64::from(i32::MAX) {
732        return Err(webp_error("animation timing exceeds WebP's timestamp range"));
733    }
734    Ok(())
735}
736
737fn animation_timeline(
738    period: f64,
739    hold: f64,
740    frame_count: i32,
741    first_frame: i32,
742) -> Result<(Vec<(i32, i32)>, i32)> {
743    validate_timing(period, hold, frame_count)?;
744    let offset = if first_frame >= 0 { first_frame % frame_count } else { 0 };
745    let mut timeline = Vec::with_capacity(frame_count as usize);
746    let mut current_ms = 0.0f64;
747    let mut previous_timestamp = -1;
748
749    for base in 0..frame_count {
750        let index = ((i64::from(base) + i64::from(offset)) % i64::from(frame_count)) as i32;
751        let timestamp = rounded_timestamp_ms(current_ms, previous_timestamp)?;
752        if previous_timestamp >= 0 {
753            validate_frame_duration(previous_timestamp, timestamp)?;
754        }
755        timeline.push((index, timestamp));
756        let step = if index == frame_count - 1 { period + hold } else { period };
757        previous_timestamp = timestamp;
758        current_ms += step * 1000.0;
759    }
760
761    let final_timestamp = rounded_timestamp_ms(current_ms, previous_timestamp)?;
762    validate_frame_duration(previous_timestamp, final_timestamp)?;
763    Ok((timeline, final_timestamp))
764}
765
766fn rounded_timestamp_ms(current_ms: f64, previous_timestamp: i32) -> Result<i32> {
767    let rounded = current_ms.round();
768    if !rounded.is_finite() || rounded < 0.0 || rounded > f64::from(i32::MAX) {
769        return Err(webp_error("animation timing exceeds WebP's timestamp range"));
770    }
771
772    let timestamp = rounded as i32;
773    if timestamp <= previous_timestamp {
774        previous_timestamp
775            .checked_add(1)
776            .ok_or_else(|| webp_error("animation timing exceeds WebP's timestamp range"))
777    } else {
778        Ok(timestamp)
779    }
780}
781
782fn validate_frame_duration(timestamp_ms: i32, next_timestamp_ms: i32) -> Result<i64> {
783    let duration = i64::from(next_timestamp_ms) - i64::from(timestamp_ms);
784    if duration < 0 {
785        Err(webp_error("frame timestamps must be non-decreasing"))
786    } else if duration > MAX_WEBP_FRAME_DURATION_MS {
787        Err(webp_error("frame duration exceeds WebP's limit"))
788    } else {
789        Ok(duration)
790    }
791}
792
793fn frame_bytes((width, height): (u32, u32)) -> Result<usize> {
794    if width == 0 || height == 0 {
795        return Err(webp_error("dimensions must be positive"));
796    }
797    if width > MAX_WEBP_DIMENSION || height > MAX_WEBP_DIMENSION {
798        return Err(Error::WebpEncode(format!(
799            "dimensions exceed WebP's {MAX_WEBP_DIMENSION}px limit"
800        )));
801    }
802    (width as usize)
803        .checked_mul(height as usize)
804        .and_then(|pixels| pixels.checked_mul(4))
805        .ok_or_else(|| webp_error("RGBA frame size overflowed"))
806}
807
808fn normalize_options(mut options: WebpOptions) -> Result<WebpOptions> {
809    if !options.quality.is_finite() {
810        return Err(webp_error("quality must be a finite number"));
811    }
812    options.quality = options.quality.clamp(0.0, 100.0);
813    options.method = options.method.min(6);
814    Ok(options)
815}
816
817fn validate_animation_options(options: WebpAnimationOptions) -> Result<()> {
818    let valid_keyframes = options.kmax <= 0
819        || options.kmax == 1
820        || (options.kmin > options.kmax / 2 && options.kmin < options.kmax);
821    if !valid_keyframes {
822        return Err(webp_error("invalid keyframe interval"));
823    }
824    Ok(())
825}
826
827fn webp_error(message: &str) -> Error {
828    Error::WebpEncode(message.to_string())
829}
830
831#[cfg(test)]
832mod tests {
833    #[cfg(not(miri))]
834    use super::WebpEncoder;
835    use super::{
836        FramePlan, FrameRect, WebpAnimationOptions, WebpFrame, WebpOptions, animation_timeline,
837        encode_webp_animation, frame_bytes, normalize_options, parallel_chunk_len_for,
838        rounded_timestamp_ms, validate_animation_options, validate_timing,
839    };
840
841    #[test]
842    fn validates_dimensions() {
843        assert!(frame_bytes((0, 1)).is_err());
844        assert!(frame_bytes((16_384, 1)).is_err());
845    }
846
847    #[test]
848    fn validates_batch_inputs_before_ffi() {
849        let options = WebpOptions::default();
850        assert!(encode_webp_animation((1, 1), &[], 0, options, 0).is_err());
851
852        let short = [0; 3];
853        let malformed = [WebpFrame::new(&short, 0)];
854        assert!(encode_webp_animation((1, 1), &malformed, 1, options, 0).is_err());
855
856        let pixel = [0; 4];
857        let duplicate_timestamps = [WebpFrame::new(&pixel, 0), WebpFrame::new(&pixel, 0)];
858        assert!(encode_webp_animation((1, 1), &duplicate_timestamps, 1, options, 0).is_err());
859    }
860
861    #[test]
862    fn limits_large_frame_parallelism() {
863        let rgba = [0; 4];
864        let small = FramePlan { rgba: &rgba, rectangle: FrameRect::full(640, 360), duration_ms: 1 };
865        let large =
866            FramePlan { rgba: &rgba, rectangle: FrameRect::full(8_192, 8_192), duration_ms: 1 };
867
868        assert_eq!(parallel_chunk_len_for(&[small; 32], 24), 24);
869        assert_eq!(parallel_chunk_len_for(&[large; 2], 24), 1);
870        let mut full_then_dirty = vec![large];
871        full_then_dirty.extend([small; 24]);
872        assert_eq!(parallel_chunk_len_for(&full_then_dirty, 24), 1);
873        assert_eq!(parallel_chunk_len_for(&full_then_dirty[1..], 24), 24);
874    }
875
876    #[cfg(not(miri))]
877    #[test]
878    fn validates_frame_lengths_before_import() {
879        let mut encoder = WebpEncoder::new((2, 2), WebpOptions::default()).unwrap();
880        assert!(encoder.add_frame(&[0; 15], 0).is_err());
881    }
882
883    #[cfg(not(miri))]
884    #[test]
885    fn validates_timestamps_without_entering_ffi() {
886        let frame = [0; 16];
887        let mut encoder = WebpEncoder::new((2, 2), WebpOptions::default()).unwrap();
888        encoder.add_frame(&frame, 0).unwrap();
889        assert!(encoder.add_frame(&frame, 0).is_err());
890    }
891
892    #[test]
893    fn normalizes_public_options() {
894        let options =
895            normalize_options(WebpOptions { quality: 120.0, lossless: false, method: 20 }).unwrap();
896        assert_eq!(options.quality, 100.0);
897        assert_eq!(options.method, 6);
898        assert!(
899            normalize_options(WebpOptions { quality: f32::NAN, ..Default::default() }).is_err()
900        );
901    }
902
903    #[test]
904    fn validates_animation_timing() {
905        assert!(validate_timing(0.1, 5.0, 10).is_ok());
906        assert!(validate_timing(f64::NAN, 0.0, 1).is_err());
907        assert!(validate_timing(0.1, -1.0, 1).is_err());
908        assert!(validate_timing(f64::MAX, 0.0, 1).is_err());
909        assert!(validate_timing(f64::from(i32::MAX) / 1000.0, 0.0, 1).is_err());
910        assert_eq!(rounded_timestamp_ms(0.1, 0).unwrap(), 1);
911        assert!(rounded_timestamp_ms(f64::from(i32::MAX), i32::MAX).is_err());
912
913        let (timeline, final_timestamp) =
914            animation_timeline(0.1, 0.0, 3, i32::MAX).expect("large rotation is normalized");
915        assert_eq!(timeline, vec![(1, 0), (2, 100), (0, 200)]);
916        assert_eq!(final_timestamp, 300);
917        assert!(
918            animation_timeline(0.1, (super::MAX_WEBP_FRAME_DURATION_MS + 1) as f64 / 1000.0, 2, 0)
919                .is_err()
920        );
921    }
922
923    #[test]
924    fn validates_keyframe_intervals() {
925        assert!(validate_animation_options(WebpAnimationOptions::default()).is_ok());
926        assert!(
927            validate_animation_options(WebpAnimationOptions {
928                kmin: 1,
929                kmax: 5,
930                ..Default::default()
931            })
932            .is_err()
933        );
934    }
935}