diff --git a/CHANGELOG.md b/CHANGELOG.md index f73580470..4c876a548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Fixed actions started from a dialog sometimes not running when the dialog closed right away, Kitsu, Shikimori, MangaBaka, Bangumi and Hikka sometimes refreshing their login twice at once, adding an extension repo served over HTTP failing, and cancelling an extension install not stopping its download ([@AntsyLich](https://github.com/AntsyLich)) ([#4068](https://github.com/mihonapp/mihon/pull/4068)) - Fixed the next library update staying "Soon" after new chapters were found, and chapters that came out early, late or were dated earlier by the source pushing the next check back ([@AntsyLich](https://github.com/AntsyLich)) ([`ba44815`](https://github.com/mihonapp/mihon/commit/ba44815)) - Fixed long strip side padding being counted in zoom bounds ([@WizaHX](https://github.com/WizaHX)) ([#4072](https://github.com/mihonapp/mihon/pull/4072)) +- Fixed long strip stuttering between pages and using a lot of memory with very tall pages ([@AntsyLich](https://github.com/AntsyLich), [@matheuswr89](https://github.com/matheuswr89)) ### Other - Show "Nightly" instead of "Beta" for preview builds and "FOSS" for FOSS builds in About ([@AntsyLich](https://github.com/AntsyLich)) ([#3760](https://github.com/mihonapp/mihon/pull/3760)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt index 3cf84046b..2bdf47fc0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.coil import android.graphics.Bitmap +import android.graphics.Matrix import androidx.core.graphics.createBitmap import androidx.core.graphics.scale import ca.mpreg.imagedecoder.ImageDecoder @@ -14,8 +15,12 @@ import coil3.decode.Decoder import coil3.decode.ImageSource import coil3.fetch.SourceFetchResult import coil3.request.Options +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import okio.BufferedSource import tachiyomi.core.common.util.system.ImageUtil +import kotlin.math.min +import kotlin.math.sqrt /** * A [Decoder] that uses [ImageDecoder] to decode image formats not supported @@ -43,7 +48,22 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti override fun draw(canvas: Canvas) {} } + /** + * A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an + * immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of + * the whole page as one texture. + */ + class StripsImage(val strips: List) : Image { + override val size: Long = strips.sumOf { it.allocationByteCount.toLong() } + override val width: Int = strips.firstOrNull()?.width ?: 0 + override val height: Int = strips.sumOf { it.height } + override val shareable: Boolean get() = false + override fun draw(canvas: Canvas) {} + } + override suspend fun decode(): DecodeResult { + if (options.webtoonStrips) return decodeStrips() + val res = resources.source().use { ImageDecoder.open(it.inputStream()).use { dec -> DecodeResultImage( @@ -105,9 +125,67 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti ) } + /** + * Copies bands of rows straight from the native frame into strips, so the page never exists as + * one Java bitmap on top of the frame. + * + * Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming + * in still shows the page's detail, unless that would make the page take more than + * [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed. + */ + private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit { + val (frame, isHdr) = resources.source().use { + ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr } + } + frame.use { + val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888 + val bytesPerPixel = if (isHdr) 8 else 4 + val rowBytes = frame.width * bytesPerPixel + val viewWidth = options.size.widthPx(options.scale) { frame.width } + val sampleSize = DecodeUtils.calculateInSampleSize( + srcWidth = frame.width, + srcHeight = frame.height, + dstWidth = viewWidth, + dstHeight = options.size.heightPx(options.scale) { frame.height }, + scale = options.scale, + ) + val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt() + val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth)) + // Older GPUs can't draw a texture wider than this + .coerceAtMost(MAX_STRIP_WIDTH) + .coerceIn(1, frame.width) + val scale = stripWidth.toFloat() / frame.width + + // Strips of about the same size in bytes, so each takes about as long to upload + val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1) + val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height) + val matrix = Matrix().apply { setScale(scale, scale) } + val band = createBitmap(frame.width, bandRows, config) + val strips = (0.. + val rows = min(bandRows, frame.height - top) + if (rows != band.height) band.reconfigure(frame.width, rows, config) + band.copyPixelsFromBuffer( + frame.image.duplicate().apply { + position(top * rowBytes) + limit((top + rows) * rowBytes) + }, + ) + val strip = if (stripWidth < frame.width) { + Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true) + } else { + band + } + // createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one + strip.copy(config, false).also { if (strip !== band) strip.recycle() } + } + band.recycle() + DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width) + } + } + class Factory : Decoder.Factory { override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? { - return if (options.newDecoder || options.customDecoder || isApplicable(result.source.source())) { + return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) { ImageDecoder(result.source, options) } else { null @@ -134,3 +212,12 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti override fun hashCode() = javaClass.hashCode() } } + +private const val STRIP_BYTES = 4 * 1024 * 1024 + +private const val MAX_PAGE_BYTES = 128 * 1024 * 1024 + +private const val MAX_STRIP_WIDTH = 4096 + +// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel +private val stripDecodes = Semaphore(1) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt index 9987af64d..c796bda3a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt @@ -34,14 +34,14 @@ val Options.cropBorders: Boolean private val cropBordersKey = Extras.Key(default = false) -fun ImageRequest.Builder.customDecoder(enable: Boolean) = apply { - extras[customDecoderKey] = enable +fun ImageRequest.Builder.webtoonStrips(enable: Boolean) = apply { + extras[webtoonStripsKey] = enable } -val Options.customDecoder: Boolean - get() = getExtra(customDecoderKey) +val Options.webtoonStrips: Boolean + get() = getExtra(webtoonStripsKey) -private val customDecoderKey = Extras.Key(default = false) +private val webtoonStripsKey = Extras.Key(default = false) fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply { extras[newDecoderKey] = enable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt index 97f18b54a..46bc08af3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt @@ -11,6 +11,7 @@ import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import androidx.annotation.AttrRes import androidx.annotation.CallSuper @@ -18,11 +19,11 @@ import androidx.annotation.StyleRes import androidx.appcompat.widget.AppCompatImageView import androidx.core.os.postDelayed import androidx.core.view.isVisible -import coil3.BitmapImage import coil3.asDrawable import coil3.dispose import coil3.imageLoader import coil3.request.CachePolicy +import coil3.request.Disposable import coil3.request.ImageRequest import coil3.request.crossfade import coil3.size.Precision @@ -33,9 +34,10 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_IN_OUT import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_OUT_QUAD import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE import com.github.chrisbanes.photoview.PhotoView +import eu.kanade.tachiyomi.data.coil.ImageDecoder import eu.kanade.tachiyomi.data.coil.cropBorders -import eu.kanade.tachiyomi.data.coil.customDecoder -import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView +import eu.kanade.tachiyomi.data.coil.webtoonStrips +import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.view.isVisibleOnScreen import okio.BufferedSource @@ -45,7 +47,7 @@ import okio.BufferedSource * * Animated image will be drawn by [PhotoView] while [SubsamplingScaleImageView] will take non-animated image. * - * @param isWebtoon if true, [WebtoonSubsamplingImageView] will be used instead of [SubsamplingScaleImageView] + * @param isWebtoon if true, [WebtoonStripImageView] will be used instead of [SubsamplingScaleImageView] * and [AppCompatImageView] will be used instead of [PhotoView] */ open class ReaderPageImageView @JvmOverloads constructor( @@ -58,6 +60,8 @@ open class ReaderPageImageView @JvmOverloads constructor( private var pageView: View? = null + private var stripRequest: Disposable? = null + private var config: Config? = null var onImageLoaded: (() -> Unit)? = null @@ -155,18 +159,26 @@ open class ReaderPageImageView @JvmOverloads constructor( if (isAnimated) { prepareAnimatedImageView() setAnimatedImage(source, config) + } else if (isWebtoon) { + prepareStripImageView() + setStripImage(source, config) } else { prepareNonAnimatedImageView() setNonAnimatedImage(source, config) } } - fun recycle() = pageView?.let { - when (it) { - is SubsamplingScaleImageView -> it.recycle() - is AppCompatImageView -> it.dispose() + fun recycle() { + stripRequest?.dispose() + stripRequest = null + pageView?.let { + when (it) { + is SubsamplingScaleImageView -> it.recycle() + is AppCompatImageView -> it.dispose() + is WebtoonStripImageView -> it.recycle() + } + it.isVisible = false } - it.isVisible = false } /** @@ -227,11 +239,7 @@ open class ReaderPageImageView @JvmOverloads constructor( if (pageView is SubsamplingScaleImageView) return removeView(pageView) - pageView = if (isWebtoon) { - WebtoonSubsamplingImageView(context) - } else { - SubsamplingScaleImageView(context) - }.apply { + pageView = SubsamplingScaleImageView(context).apply { setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setMinimumTileDpi(180) @@ -292,35 +300,8 @@ open class ReaderPageImageView @JvmOverloads constructor( isVisible = true } is BufferedSource -> { - if (!isWebtoon) { - setImage(ImageSource.inputStream(data.inputStream())) - isVisible = true - return@apply - } - - ImageRequest.Builder(context) - .data(data) - .memoryCachePolicy(CachePolicy.DISABLED) - .diskCachePolicy(CachePolicy.DISABLED) - .target( - onSuccess = { result -> - val image = result as BitmapImage - setImage(ImageSource.bitmap(image.bitmap)) - isVisible = true - }, - ) - .listener( - onError = { _, result -> - onImageLoadError(result.throwable) - }, - ) - .size(ViewSizeResolver(this@ReaderPageImageView)) - .precision(Precision.INEXACT) - .cropBorders(config.cropBorders) - .customDecoder(true) - .crossfade(false) - .build() - .let(context.imageLoader::enqueue) + setImage(ImageSource.inputStream(data.inputStream())) + isVisible = true } else -> { throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}") @@ -328,6 +309,51 @@ open class ReaderPageImageView @JvmOverloads constructor( } } + private fun prepareStripImageView() { + if (pageView is WebtoonStripImageView) return + removeView(pageView) + + pageView = WebtoonStripImageView(context) + addView(pageView, MATCH_PARENT, WRAP_CONTENT) + } + + private fun setStripImage(data: BufferedSource, config: Config) { + val view = pageView as? WebtoonStripImageView ?: return + view.onReload = { requestStrips(view, data, config, isReload = true) } + requestStrips(view, data, config, isReload = false) + } + + private fun requestStrips(view: WebtoonStripImageView, data: BufferedSource, config: Config, isReload: Boolean) { + // A recycled view is reused for another page, where a request still running for the old one must not land + stripRequest?.dispose() + stripRequest = ImageRequest.Builder(context) + // Peeking leaves the data whole for decoding again after the strips were freed off screen + .data(data.peek()) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.DISABLED) + .target( + onSuccess = { result -> + view.setImage(result as ImageDecoder.StripsImage) + if (!isReload) { + view.isVisible = true + onImageLoaded() + } + }, + ) + .listener( + onError = { _, result -> + onImageLoadError(result.throwable) + }, + ) + .size(ViewSizeResolver(this)) + .precision(Precision.INEXACT) + .cropBorders(config.cropBorders) + .webtoonStrips(true) + .crossfade(false) + .build() + .let(context.imageLoader::enqueue) + } + private fun prepareAnimatedImageView() { if (pageView is AppCompatImageView) return removeView(pageView) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonStripImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonStripImageView.kt new file mode 100644 index 000000000..534da3a21 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonStripImageView.kt @@ -0,0 +1,103 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.webtoon + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.MotionEvent +import android.view.View +import eu.kanade.tachiyomi.data.coil.ImageDecoder +import kotlin.math.roundToInt + +/** + * Draws a webtoon page as a stack of strips, so a very tall page never becomes one huge GPU texture. + * + * Ignores all touch events, because the webtoon viewer handles all the gestures. + */ +class WebtoonStripImageView(context: Context) : View(context) { + + private var strips: List = emptyList() + private var imageWidth = 0 + private var imageHeight = 0 + + private val paint = Paint(Paint.FILTER_BITMAP_FLAG) + private val clipRect = Rect() + private val dstRect = Rect() + + /** Decodes the page again after its strips were freed off screen. */ + var onReload: (() -> Unit)? = null + + private var isReleasedOffScreen = false + + fun setImage(image: ImageDecoder.StripsImage) { + releaseStrips() + isReleasedOffScreen = false + strips = image.strips + imageWidth = image.width + imageHeight = image.height + requestLayout() + invalidate() + } + + fun recycle() { + releaseStrips() + isReleasedOffScreen = false + onReload = null + imageWidth = 0 + imageHeight = 0 + requestLayout() + } + + // The viewer keeps a few pages off screen without recycling them, which adds up for huge pages. + // The size stays, so the layout doesn't move while the page decodes again. + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + if (strips.sumOf { it.allocationByteCount.toLong() } > LARGE_PAGE_BYTES) { + releaseStrips() + isReleasedOffScreen = true + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (isReleasedOffScreen) { + isReleasedOffScreen = false + onReload?.invoke() + } + } + + // A page's strips can be hundreds of MB, which would otherwise wait for a garbage collection to be freed + private fun releaseStrips() { + val released = strips + strips = emptyList() + released.forEach(Bitmap::recycle) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = if (imageWidth > 0) (width.toLong() * imageHeight / imageWidth).toInt() else 0 + setMeasuredDimension(width, height) + } + + override fun onDraw(canvas: Canvas) { + if (imageWidth <= 0) return + canvas.getClipBounds(clipRect) + + val scale = width.toFloat() / imageWidth + var srcTop = 0 + for (strip in strips) { + val top = (srcTop * scale).roundToInt() + srcTop += strip.height + val bottom = (srcTop * scale).roundToInt() + if (bottom <= clipRect.top) continue + if (top >= clipRect.bottom) break + dstRect.set(0, top, width, bottom) + canvas.drawBitmap(strip, null, dstRect, paint) + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean = false +} + +private const val LARGE_PAGE_BYTES = 64L * 1024 * 1024 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt deleted file mode 100644 index 328d6725a..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt +++ /dev/null @@ -1,20 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.viewer.webtoon - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView - -/** - * Implementation of subsampling scale image view that ignores all touch events, because the - * webtoon viewer handles all the gestures. - */ -class WebtoonSubsamplingImageView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : SubsamplingScaleImageView(context, attrs) { - - override fun onTouchEvent(event: MotionEvent): Boolean { - return false - } -}