From bd11a7fcc28fba3d4a8727956ca9ba9f747cf55e Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Tue, 24 Jun 2025 23:58:52 +0800 Subject: [PATCH] AnimatedWebPDecoder POC (#2426) --- app/proguard-rules.pro | 4 + ...le-runtime-detection-of-NEON-support.patch | 25 ++++ app/src/main/cpp/CMakeLists.txt | 21 +++ .../kotlin/com/hippo/ehviewer/Settings.kt | 1 + .../ehviewer/coil/AnimatedWebPDecoder.kt | 49 ++++++ .../ehviewer/coil/AnimatedWebPDrawable.kt | 139 ++++++++++++++++++ .../kotlin/com/hippo/ehviewer/image/Image.kt | 10 +- .../ehviewer/ui/settings/AdvancedScreen.kt | 6 + app/src/main/rust/Cargo.lock | 13 ++ app/src/main/rust/Cargo.toml | 1 + app/src/main/rust/src/ffi/android.rs | 30 +++- app/src/main/rust/src/ffi/jvm.rs | 36 ++++- app/src/main/rust/src/img.rs | 1 + app/src/main/rust/src/img/webp.rs | 42 ++++++ 14 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 app/src/main/cpp/0001-Disable-runtime-detection-of-NEON-support.patch create mode 100644 app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDecoder.kt create mode 100644 app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDrawable.kt create mode 100644 app/src/main/rust/src/img/webp.rs diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 6bf87c470..ef57f289b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,6 +11,10 @@ public static final ** CREATOR; } +-keepclassmembers,allowobfuscation class com.hippo.ehviewer.coil.AnimatedWebPDrawable { + java.nio.ByteBuffer source; +} + # Ktor logger -dontwarn org.slf4j.impl.StaticLoggerBinder diff --git a/app/src/main/cpp/0001-Disable-runtime-detection-of-NEON-support.patch b/app/src/main/cpp/0001-Disable-runtime-detection-of-NEON-support.patch new file mode 100644 index 000000000..fd3e522ae --- /dev/null +++ b/app/src/main/cpp/0001-Disable-runtime-detection-of-NEON-support.patch @@ -0,0 +1,25 @@ +From 07931ec2f127ce2408690cc85399ca750ca12657 Mon Sep 17 00:00:00 2001 +From: FooIbar <118464521+FooIbar@users.noreply.github.com> +Date: Thu, 19 Jun 2025 00:03:46 +0800 +Subject: [PATCH] Disable runtime detection of NEON support + +--- + CMakeLists.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index d7e8963f..9dda3bb1 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -181,7 +181,7 @@ endif() + + # ############################################################################## + # Android only. +-if(ANDROID) ++if(0) + include_directories(${ANDROID_NDK}/sources/android/cpufeatures) + add_library(cpufeatures-webp STATIC + ${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c) +-- +2.45.2 + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 956bb8b16..15d2e1514 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -109,6 +109,26 @@ FetchContent_Declare( FetchContent_MakeAvailable(libarchive) include_directories(${libarchive_SOURCE_DIR}/libarchive) +set(LIBWEBP_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Disable-runtime-detection-of-NEON-support.patch) +FetchContent_Declare( + libwebp + GIT_REPOSITORY https://chromium.googlesource.com/webm/libwebp + GIT_TAG v1.5.0 + GIT_SHALLOW 1 + PATCH_COMMAND git apply --check -R ${LIBWEBP_PATCH} || git apply ${LIBWEBP_PATCH} +) + +option(WEBP_BUILD_ANIM_UTILS OFF) +option(WEBP_BUILD_CWEBP OFF) +option(WEBP_BUILD_DWEBP OFF) +option(WEBP_BUILD_VWEBP OFF) +option(WEBP_BUILD_WEBPINFO OFF) +option(WEBP_BUILD_LIBWEBPMUX OFF) +option(WEBP_BUILD_EXTRAS OFF) +option(WEBP_ENABLE_SWAP_16BIT_CSP ON) + +FetchContent_MakeAvailable(libwebp) + FetchContent_Declare( Corrosion GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git @@ -123,6 +143,7 @@ corrosion_import_crate( FEATURES $,android-26,android> FLAGS $<$>:-Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort> ) +corrosion_link_libraries(ehviewer_rust webp webpdemux) corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384") # Build and link our app's native lib diff --git a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt index 47038ada4..eb43c1f9f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt @@ -127,6 +127,7 @@ object Settings : DataStorePreferences(null) { val animateItems = boolPref("animate_items", true) val enableCronet = boolPref("enable_cronet", true) val enableQuic = boolPref("enable_quic", true) + val webpDecoder = boolPref("webp_decoder", false) val desktopSite = boolPref("desktop_site", true) val displayName = stringOrNullPref("display_name", null) val avatar = stringOrNullPref("avatar", null) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDecoder.kt b/app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDecoder.kt new file mode 100644 index 000000000..0787abb53 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDecoder.kt @@ -0,0 +1,49 @@ +package com.hippo.ehviewer.coil + +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.ByteBufferMetadata +import coil3.decode.ContentMetadata +import coil3.decode.DecodeResult +import coil3.decode.DecodeUtils +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.gif.isAnimatedWebP +import coil3.request.Options +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import okio.FileSystem + +class AnimatedWebPDecoder(private val source: ByteBuffer) : Decoder { + override suspend fun decode() = DecodeResult(AnimatedWebPDrawable(source).asImage(), false) + + object Factory : Decoder.Factory { + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader, + ) = if (DecodeUtils.isAnimatedWebP(result.source.source())) { + result.source.toByteBufferOrNull()?.let { AnimatedWebPDecoder(it) } + } else { + null + } + } +} + +private fun ImageSource.toByteBufferOrNull(): ByteBuffer? { + if (fileSystem === FileSystem.SYSTEM) { + val file = fileOrNull() + if (file != null) { + return file.toFile().inputStream().mapReadOnly() + } + } + return when (val metadata = metadata) { + is ContentMetadata -> metadata.assetFileDescriptor.createInputStream().mapReadOnly() + is ByteBufferMetadata -> metadata.byteBuffer + else -> null + } +} + +private fun FileInputStream.mapReadOnly(): ByteBuffer = channel.use { it.map(FileChannel.MapMode.READ_ONLY, 0, it.size()) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDrawable.kt b/app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDrawable.kt new file mode 100644 index 000000000..8dba3455f --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/coil/AnimatedWebPDrawable.kt @@ -0,0 +1,139 @@ +package com.hippo.ehviewer.coil + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import androidx.core.graphics.createBitmap +import java.nio.ByteBuffer +import kotlin.time.TimeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// Hold a reference to the buffer as it's used by the decoder +@Suppress("CanBeParameter") +class AnimatedWebPDrawable(private val source: ByteBuffer) : Drawable(), Animatable { + private val decodeScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG or Paint.FILTER_BITMAP_FLAG) + private val decoder = nativeCreateDecoder(source) + private val width: Int + private val height: Int + private val loopCount: Int + + private var decodeJob: Job? = null + private var loopsCompleted = 0 + private var frameDuration: Int + private var currentFrame: Frame + private var nextFrame: Frame + + init { + check(decoder != 0L) { "Failed to create decoder" } + val packed = nativeGetImageInfo(decoder) + width = (packed shr 40).toInt() + height = (packed shr 16 and 0xFFFFFF).toInt() + loopCount = (packed and 0xFFFF).toInt() + val bitmap = createBitmap(width, height) + val timestamp = nativeDecodeNextFrame(decoder, false, bitmap) + check(timestamp != 0) { + nativeDestroyDecoder(decoder) + "Failed to decode first frame" + } + frameDuration = timestamp + currentFrame = Frame(bitmap, timestamp) + nextFrame = Frame(createBitmap(width, height), 0) + } + + override fun getIntrinsicWidth() = width + + override fun getIntrinsicHeight() = height + + private fun decodeNextFrame(reset: Boolean) { + nextFrame.timestamp = nativeDecodeNextFrame(decoder, reset, nextFrame.bitmap) + if (nextFrame.timestamp == 0) { + throw CancellationException("Failed to decode next frame") + } + nextFrame.bitmap.prepareToDraw() + } + + override fun draw(canvas: Canvas) { + if (decodeJob?.isCompleted == true && nextFrame.timestamp != 0) { + val timeMark = TimeSource.Monotonic.markNow() + frameDuration = if (nextFrame.timestamp > currentFrame.timestamp) { + nextFrame.timestamp - currentFrame.timestamp + } else { + loopsCompleted++ + nextFrame.timestamp + } + currentFrame = nextFrame.also { nextFrame = currentFrame } + decodeJob = if (loopCount == 0 || loopsCompleted < loopCount) { + decodeScope.launch { + decodeNextFrame(false) + delay(frameDuration - timeMark.elapsedNow().inWholeMilliseconds) + invalidateSelf() + } + } else { + null + } + } + canvas.drawBitmap(currentFrame.bitmap, null, bounds, paint) + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun setAlpha(alpha: Int) { + if (alpha != paint.alpha) { + paint.alpha = alpha + invalidateSelf() + } + } + + override fun getAlpha(): Int = paint.alpha + + override fun setColorFilter(colorFilter: ColorFilter?) { + if (colorFilter != paint.colorFilter) { + paint.colorFilter = colorFilter + invalidateSelf() + } + } + + override fun getColorFilter(): ColorFilter? = paint.colorFilter + + override fun isRunning() = decodeJob != null + + override fun start() { + if (decodeJob == null) { + val timeMark = TimeSource.Monotonic.markNow() + loopsCompleted = 0 + decodeJob = decodeScope.launch { + val isFirstFrame = currentFrame.timestamp == frameDuration + decodeNextFrame(!isFirstFrame) + delay(frameDuration - timeMark.elapsedNow().inWholeMilliseconds) + invalidateSelf() + } + } + } + + override fun stop() { + decodeJob?.cancel() + decodeJob = null + } + + fun dispose() { + nativeDestroyDecoder(decoder) + } +} + +private class Frame(val bitmap: Bitmap, var timestamp: Int) + +private external fun nativeCreateDecoder(source: ByteBuffer): Long +private external fun nativeGetImageInfo(decoder: Long): Long +private external fun nativeDecodeNextFrame(decoder: Long, reset: Boolean, bitmap: Bitmap): Int +private external fun nativeDestroyDecoder(decoder: Long) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/image/Image.kt b/app/src/main/kotlin/com/hippo/ehviewer/image/Image.kt index 89580544a..b46e97c5c 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/image/Image.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/image/Image.kt @@ -37,6 +37,8 @@ import coil3.size.Precision import coil3.size.Size import coil3.size.SizeResolver import com.hippo.ehviewer.Settings +import com.hippo.ehviewer.coil.AnimatedWebPDecoder +import com.hippo.ehviewer.coil.AnimatedWebPDrawable import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo import com.hippo.ehviewer.coil.detectQrCode import com.hippo.ehviewer.coil.hardwareThreshold @@ -79,7 +81,10 @@ class Image private constructor(image: CoilImage, private val src: ImageSource) private fun recycle() { when (val image = innerImage!!) { - is DrawableImage -> src.close() + is DrawableImage -> { + (image.drawable as? AnimatedWebPDrawable)?.dispose() + src.close() + } is BitmapImage -> image.bitmap.recycle() } innerImage = null @@ -101,6 +106,9 @@ class Image private constructor(image: CoilImage, private val src: ImageSource) maybeCropBorder(Settings.cropBorder.value) detectQrCode(checkExtraneousAds) memoryCachePolicy(CachePolicy.DISABLED) + if (Settings.webpDecoder.value) { + decoderFactory(AnimatedWebPDecoder.Factory) + } } } return when (val result = request.execute()) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt index e298bc8fc..a024400b0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/settings/AdvancedScreen.kt @@ -223,6 +223,12 @@ fun AnimatedVisibilityScope.AdvancedScreen(navigator: DestinationsNavigator) = S } } } + var webpDecoder by Settings.webpDecoder.asMutableState() + SwitchPref( + checked = webpDecoder, + onMutate = { webpDecoder = !webpDecoder }, + title = "Experimental animated WebP decoder", + ) if (isAtLeastO) { IntSliderPreference( maxValue = 16384, diff --git a/app/src/main/rust/Cargo.lock b/app/src/main/rust/Cargo.lock index e9b014fd5..5f1bcf523 100644 --- a/app/src/main/rust/Cargo.lock +++ b/app/src/main/rust/Cargo.lock @@ -273,6 +273,7 @@ dependencies = [ "image", "jni", "jni_fn", + "libwebp-sys2", "log", "ndk", "quick-xml", @@ -877,6 +878,18 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libwebp-sys2" +version = "0.2.0" +source = "git+https://github.com/FooIbar/libwebp-sys2-rs.git#138c7114eb7cc9c3d0a6c5d074d1dda30550b765" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" diff --git a/app/src/main/rust/Cargo.toml b/app/src/main/rust/Cargo.toml index 5bd7ddd3a..ebba7bfc1 100644 --- a/app/src/main/rust/Cargo.toml +++ b/app/src/main/rust/Cargo.toml @@ -27,6 +27,7 @@ anyhow = "1.0" image = { default-features = false, version = "0.25" } rxing = { default-features = false, version = "0.7" } ndk = { version = "0.9", optional = true } +libwebp-sys2 = { git = "https://github.com/FooIbar/libwebp-sys2-rs.git", features = ["1_5", "demux", "extern-types"] } [dev-dependencies] reqwest = "0.12" diff --git a/app/src/main/rust/src/ffi/android.rs b/app/src/main/rust/src/ffi/android.rs index 77f47bcc9..30e8ed553 100644 --- a/app/src/main/rust/src/ffi/android.rs +++ b/app/src/main/rust/src/ffi/android.rs @@ -4,15 +4,16 @@ use super::jvm::jni_throwing; use crate::img::border::DetectBorder; use crate::img::core::{CustomPixel, ImageConsumer, Rgb565, Rgba8888, RgbaF16}; use crate::img::qr_code::QrCode; +use crate::img::webp::{decode_next_frame, get_image_info}; use anyhow::{Ok, Result, anyhow}; use image::ImageBuffer; use jni::JNIEnv; use jni::objects::JClass; -use jni::sys::jboolean; -use jni::sys::{jintArray, jobject}; +use jni::sys::{jboolean, jint, jintArray, jlong, jobject}; use jni_fn::jni_fn; +use libwebp_sys::WebPAnimDecoder; use ndk::bitmap::{Bitmap, BitmapFormat}; -use std::ptr::slice_from_raw_parts; +use std::ptr::{copy_nonoverlapping, slice_from_raw_parts}; #[jni_fn("com.hippo.ehviewer.image.ImageKt")] pub fn detectBorder(mut env: JNIEnv, _class: JClass, object: jobject) -> jintArray { @@ -31,6 +32,29 @@ pub fn hasQrCode(mut env: JNIEnv, _class: JClass, object: jobject) -> jboolean { }) } +#[jni_fn("com.hippo.ehviewer.coil.AnimatedWebPDrawableKt")] +pub fn nativeDecodeNextFrame( + mut env: JNIEnv, + _: JClass, + decoder: jlong, + reset: jboolean, + bitmap: jobject, +) -> jint { + jni_throwing(&mut env, |env| { + let dec = decoder as *mut WebPAnimDecoder; + let (buf, timestamp) = unsafe { decode_next_frame(dec, reset != 0) }; + if !buf.is_null() { + let info = unsafe { get_image_info(dec) }; + let handle = unsafe { Bitmap::from_jni(env.get_raw(), bitmap) }; + let dst = handle.lock_pixels()? as *mut u8; + let size = info.canvas_width * info.canvas_height * 4; + unsafe { copy_nonoverlapping(buf, dst, size as usize) }; + handle.unlock_pixels()?; + } + Ok(timestamp) + }) +} + fn ptr_as_image<'local, P: CustomPixel>( ptr: *const !, w: u32, diff --git a/app/src/main/rust/src/ffi/jvm.rs b/app/src/main/rust/src/ffi/jvm.rs index 88ffa5981..d30acc08b 100644 --- a/app/src/main/rust/src/ffi/jvm.rs +++ b/app/src/main/rust/src/ffi/jvm.rs @@ -1,6 +1,7 @@ #![cfg(feature = "jvm")] use crate::EhError; +use crate::img::webp::{create_decoder, get_image_info, pack_image_info}; use crate::parser::api::parse_vote_tag; use crate::parser::archive::{parse_archive_url, parse_archives, parse_archives_with_funds}; use crate::parser::config::parse_fav_cat; @@ -14,9 +15,10 @@ use crate::parser::torrent::parse_torrent_list; use android_logger::Config; use anyhow::{Context, Result, ensure}; use jni::objects::{JByteBuffer, JClass}; -use jni::sys::{JNI_TRUE, JNI_VERSION_1_6, jboolean, jint, jobject}; +use jni::sys::{JNI_TRUE, JNI_VERSION_1_6, jboolean, jint, jlong, jobject}; use jni::{JNIEnv, JavaVM}; use jni_fn::jni_fn; +use libwebp_sys::{WebPAnimDecoder, WebPAnimDecoderDelete}; use log::LevelFilter; use serde::Serialize; use std::ffi::c_void; @@ -137,6 +139,32 @@ pub fn parseProfile(mut env: JNIEnv, _class: JClass, buffer: JByteBuffer, limit: }) } +#[jni_fn("com.hippo.ehviewer.coil.AnimatedWebPDrawableKt")] +pub fn nativeCreateDecoder(mut env: JNIEnv, _: JClass, buffer: JByteBuffer) -> jlong { + jni_throwing(&mut env, |env| { + let buffer = deref_mut_direct_bytebuffer(env, buffer)?; + Ok(create_decoder(buffer) as i64) + }) +} + +#[jni_fn("com.hippo.ehviewer.coil.AnimatedWebPDrawableKt")] +pub fn nativeGetImageInfo(mut env: JNIEnv, _: JClass, decoder: jlong) -> jlong { + jni_throwing(&mut env, |_| { + let dec = decoder as *const WebPAnimDecoder; + let info = unsafe { get_image_info(dec) }; + Ok(pack_image_info(info) as i64) + }) +} + +#[jni_fn("com.hippo.ehviewer.coil.AnimatedWebPDrawableKt")] +pub fn nativeDestroyDecoder(mut env: JNIEnv, _: JClass, decoder: jlong) { + jni_throwing(&mut env, |_| { + let dec = decoder as *mut WebPAnimDecoder; + unsafe { WebPAnimDecoderDelete(dec) }; + Ok(()) + }) +} + fn deref_mut_direct_bytebuffer<'local>( env: &JNIEnv, buffer: JByteBuffer<'local>, @@ -170,6 +198,12 @@ impl ThrowingHasDefault for i32 { } } +impl ThrowingHasDefault for i64 { + fn default() -> Self { + 0 + } +} + impl ThrowingHasDefault for () { fn default() -> Self {} } diff --git a/app/src/main/rust/src/img.rs b/app/src/main/rust/src/img.rs index 239b2ceb3..0ec770ca3 100644 --- a/app/src/main/rust/src/img.rs +++ b/app/src/main/rust/src/img.rs @@ -2,3 +2,4 @@ pub mod border; pub mod copy_region; pub mod core; pub mod qr_code; +pub mod webp; diff --git a/app/src/main/rust/src/img/webp.rs b/app/src/main/rust/src/img/webp.rs new file mode 100644 index 000000000..35e6489f8 --- /dev/null +++ b/app/src/main/rust/src/img/webp.rs @@ -0,0 +1,42 @@ +use libwebp_sys::{ + MODE_rgbA, WebPAnimDecoder, WebPAnimDecoderGetInfo, WebPAnimDecoderGetNext, + WebPAnimDecoderHasMoreFrames, WebPAnimDecoderNew, WebPAnimDecoderOptions, WebPAnimDecoderReset, + WebPAnimInfo, WebPData, +}; +use std::mem::zeroed; +use std::ptr::null_mut; + +pub fn create_decoder(data: &[u8]) -> *mut WebPAnimDecoder { + let webp_data = WebPData { + bytes: data.as_ptr(), + size: data.len(), + }; + let dec_options = WebPAnimDecoderOptions { + color_mode: MODE_rgbA, + use_threads: 1, + padding: [0; 7], + }; + unsafe { WebPAnimDecoderNew(&webp_data, &dec_options) } +} + +pub(crate) unsafe fn get_image_info(dec: *const WebPAnimDecoder) -> WebPAnimInfo { + let mut info = unsafe { zeroed() }; + let _ = unsafe { WebPAnimDecoderGetInfo(dec, &mut info) }; + info +} + +pub fn pack_image_info(info: WebPAnimInfo) -> u64 { + (info.canvas_width as u64) << 40 | (info.canvas_height as u64) << 16 | (info.loop_count as u64) +} + +pub(crate) unsafe fn decode_next_frame(dec: *mut WebPAnimDecoder, reset: bool) -> (*const u8, i32) { + let mut buf = null_mut(); + let mut timestamp = 0; + unsafe { + if reset || WebPAnimDecoderHasMoreFrames(dec) == 0 { + WebPAnimDecoderReset(dec); + } + let _ = WebPAnimDecoderGetNext(dec, &mut buf, &mut timestamp); + } + (buf, timestamp) +}