AnimatedWebPDecoder POC (#2426)

This commit is contained in:
FooIbar authored and GitHub committed 2025-06-24 23:58:52 +08:00
1 parent 6edc8f06f3
commit bd11a7fcc2
14 files changed
+373 -5

No files matched your search

+4
View File
@@ -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
@@ -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
+21
View File
@@ -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 $<IF:$<VERSION_GREATER_EQUAL:${ANDROID_NATIVE_API_LEVEL},26>,android-26,android>
FLAGS $<$<NOT:$<CONFIG:Debug>>:-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
@@ -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)
@@ -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()) }
@@ -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)
@@ -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()) {
@@ -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,
+13
View File
@@ -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"
+1
View File
@@ -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"
+27 -3
View File
@@ -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,
+35 -1
View File
@@ -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 {}
}
+1
View File
@@ -2,3 +2,4 @@ pub mod border;
pub mod copy_region;
pub mod core;
pub mod qr_code;
pub mod webp;
+42
View File
@@ -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)
}