From 98ba6930547310ca847c6757942602b0e2e498fb Mon Sep 17 00:00:00 2001 From: w Date: Thu, 1 Oct 2026 14:30:24 -0700 Subject: [PATCH] Bump webgpuviewer (#4029) * Add crop for long strip * Don't save all animation frames in memory * Fix navigation * Update libs.versions.toml --- .../ui/reader/viewer/webgpu/WebGpuConfig.kt | 8 + .../ui/reader/viewer/webgpu/WebGpuViewer.kt | 235 ++++++++++-------- .../viewer/webgpu/WebGpuViewerContinuous.kt | 2 +- gradle/libs.versions.toml | 4 +- 4 files changed, 145 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt index 610e49562..886aac5f5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt @@ -40,6 +40,9 @@ class WebGpuConfig( var imageCropBorders = false private set + var imageCropBordersWebtoon = false + private set + var navigateToPan = false private set @@ -85,6 +88,11 @@ class WebGpuConfig( readerPreferences.cropBorders.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) + readerPreferences.cropBordersWebtoon.register( + { imageCropBordersWebtoon = it }, + { imagePropertyChangedListener?.invoke() }, + ) + readerPreferences.navigateToPan.register({ navigateToPan = it }) readerPreferences.landscapeZoom.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() }) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt index bae400aa0..d567f2533 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt @@ -35,6 +35,7 @@ import ca.mpreg.webgpuviewer.transition.TransitionStackDown import ca.mpreg.webgpuviewer.transition.TransitionStackLeft import ca.mpreg.webgpuviewer.transition.TransitionStackRight import ca.mpreg.webgpuviewer.transition.TransitionStackUp +import ca.mpreg.webgpuviewer.viewer.AnimationFrame import ca.mpreg.webgpuviewer.viewer.ImagePage import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState import com.google.android.material.color.MaterialColors @@ -62,6 +63,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import tachiyomi.core.common.util.system.logcat @@ -123,6 +125,9 @@ open class WebGpuViewer( } private val streamDispatcher = streamExecutor.asCoroutineDispatcher() + // Animations decode as they play, a few at a time, apart from page decodes. + private val animationDispatcher = Dispatchers.Default.limitedParallelism(2) + // Guards pageCache, decodeQueue, deferredCleanup and chapterPreloadsInFlight. private val lock = Object() @@ -335,15 +340,17 @@ open class WebGpuViewer( /** * Evicts the page farthest from reference. Must be called while holding lock. * - * Never evicts [reference], [currentPage] or what [pinnedFromPage] draws. Returns false when - * nothing was evictable, so a trim loop stops instead of spinning. + * Never evicts [reference], [keep], [currentPage] or what [pinnedFromPage] draws. Returns false + * when nothing was evictable, so a trim loop stops instead of spinning. * * @param reference The page to use as reference (defaults to currentPage) + * @param keep A page about to be handed out: evicted, it would be drawn destroyed. */ - private fun evictFarthestPage(reference: ViewerPage? = null): Boolean { + private fun evictFarthestPage(reference: ViewerPage? = null, keep: ViewerPage? = null): Boolean { val current = reference ?: currentPage ?: return false - val candidates = - pageCache.values.filter { it !== current && it !== currentPage && !isPinned(it) }.toMutableSet() + val candidates = pageCache.values + .filter { it !== current && it !== keep && it !== currentPage && !isPinned(it) } + .toMutableSet() if (candidates.isEmpty()) return false // Read once - the getter measures the viewport. @@ -438,7 +445,7 @@ open class WebGpuViewer( pageCache[key] = newPage val limit = cacheSize while (pageCache.size > limit) { - if (!evictFarthestPage(referencePage ?: newPage)) break + if (!evictFarthestPage(referencePage ?: newPage, keep = newPage)) break } } } @@ -455,7 +462,7 @@ open class WebGpuViewer( pageCache[key] = newPage val limit = cacheSize while (pageCache.size > limit) { - if (!evictFarthestPage(referencePage ?: newPage)) break + if (!evictFarthestPage(referencePage ?: newPage, keep = newPage)) break } } } @@ -951,8 +958,8 @@ open class WebGpuViewer( onTap = { offset -> when (config.navigator.getAction(PointF(offset.x, offset.y))) { NavigationRegion.MENU -> activity.toggleMenu() - NavigationRegion.NEXT -> if (isReversed) moveToPrevious() else moveToNext() - NavigationRegion.PREV -> if (isReversed) moveToNext() else moveToPrevious() + NavigationRegion.NEXT -> moveToNext() + NavigationRegion.PREV -> moveToPrevious() NavigationRegion.RIGHT -> moveRight() NavigationRegion.LEFT -> moveLeft() } @@ -1006,6 +1013,7 @@ open class WebGpuViewer( backgroundColor = readerBackgroundColor() homeScale = config.continuousMinWidth / 100f scale = homeScale + cropBorders = this@WebGpuViewer.cropBorders minScale = if (config.zoomOutDisabled) 0f else 0.1f (this@WebGpuViewer as? WebGpuViewerContinuous)?.let { @@ -1077,6 +1085,9 @@ open class WebGpuViewer( override fun getView(): View = pager + private val cropBorders: Boolean + get() = if (isContinuous) config.imageCropBordersWebtoon else config.imageCropBorders + /** * Asks the loader for [page], after whatever it was asked for before; a request replaces * the page's last one. Cancelling it drops the download unless it has started. @@ -1207,7 +1218,17 @@ open class WebGpuViewer( } logcat(LogPriority.INFO) { "Decoding ${pageName(page)}" } - ImageDecoder.open(input).use { dec -> buildPage(page, dec, streaming = false) { false } } + val started = SystemClock.uptimeMillis() + var done = false + try { + buildPage(page, ImageDecoder.open(input), streaming = false) { false } + done = true + } finally { + val ms = SystemClock.uptimeMillis() - started + logcat(LogPriority.INFO) { + "${if (done) "Decoded" else "Failed decoding"} ${pageName(page)} in $ms ms" + } + } } } @@ -1231,6 +1252,8 @@ open class WebGpuViewer( ok } if (!claimed) return@launch + // Paced by the download, so this is mostly how long that took. + val started = SystemClock.uptimeMillis() try { live.reader().use { reader -> // available() 0 stops the read after the header. @@ -1238,8 +1261,8 @@ open class WebGpuViewer( override fun available() = 0 } logcat(LogPriority.INFO) { "Decoding while downloading ${pageName(page)}" } - ImageDecoder.open(headerOnly, complete = false).use { dec -> - val chunk = ByteArray(STREAM_CHUNK) + val chunk = ByteArray(STREAM_CHUNK) + ImageDecoder.open(headerOnly, complete = false).let { dec -> var fed = false var fedAt = 0L // Blocks for new bytes: decoding again on none only repeats work. @@ -1261,10 +1284,15 @@ open class WebGpuViewer( } } } + logcat(LogPriority.INFO) { + "Decoded while downloading ${pageName(page)} in ${SystemClock.uptimeMillis() - started} ms" + } } catch (e: CancellationException) { throw e } catch (e: Exception) { - logcat(LogPriority.DEBUG) { "Streaming decode gave up: ${e.message}" } + logcat(LogPriority.DEBUG) { + "Streaming decode gave up after ${SystemClock.uptimeMillis() - started} ms: ${e.message}" + } } catch (e: OutOfMemoryError) { logcat(LogPriority.WARN) { "Streaming decode ran out of memory" } } finally { @@ -1288,12 +1316,30 @@ open class WebGpuViewer( } } - /** Decodes [dec] into [page]. [feed] pushes more bytes, false once all are in. */ + /** + * Decodes [dec] into [page], then closes it - unless an animation keeps it, to decode frames + * past the first as they come due. [feed] pushes more bytes, false once all are in. + */ private suspend fun buildPage( page: ViewerReaderPage, dec: ImageDecoder, streaming: Boolean, feed: () -> Boolean, + ) { + var kept = false + try { + decodeInto(page, dec, streaming, feed) { kept = true } + } finally { + if (!kept) dec.close() + } + } + + private suspend fun decodeInto( + page: ViewerReaderPage, + dec: ImageDecoder, + streaming: Boolean, + feed: () -> Boolean, + keep: () -> Unit, ) { // The decoder hands the map over unapplied - see ImageDecoder.Gainmap - because how // much of it to use depends on the display, so the viewer applies it. @@ -1394,6 +1440,8 @@ open class WebGpuViewer( if (streaming && !previewing) return val firstFrame = nextWhole(onPartial = { showPartial(it) }) ?: return var secondFrame: ImageDecoder.Frame? = null + // A new image until a page takes it. + var orphan: Image? = null // Whole frames hold native pixels until closed, on every exit. try { // Streamed, a still's end and gain map are only known once all is in. @@ -1421,7 +1469,7 @@ open class WebGpuViewer( val hdrHeadroom = gainmap?.headroomStops ?: dec.hdrHeadroom // Only trim when not animated and not in dual page mode - val trimColors = if (!animated && config.imageCropBorders && !isDualPageMode()) { + val trimColors = if (!animated && cropBorders && !isDualPageMode()) { listOf( floatArrayOf(1f, 1f, 1f), floatArrayOf(0f, 0f, 0f), @@ -1450,103 +1498,87 @@ open class WebGpuViewer( hdr = dec.isHdr, hdrHeadroom = hdrHeadroom, gainmap = gainmap?.toInput(), - ) + ).also { orphan = it } + val firstDuration = firstFrame.duration firstFrame.close() - // A still is one frame of duration 0. - val frames = ArrayList>(if (animated) dec.pages else 1) - frames.add(firstImage to if (animated) firstFrame.duration else 0) - - // The preview's image belongs to its page. - fun discardFrames() { - val owned = if (shown != null) frames.drop(1) else frames - if (owned.isNotEmpty()) ImagePage.ImageSingle(owned).cleanup() - } - - if (animated) { - var pending = secondFrame - try { - while (pending != null || dec.hasNext) { - // Under lock: a decode this long gives an eviction's cleanup() time to land. - val stillWanted = synchronized(lock) { - pageInCache(page).also { inCache -> - if (inCache) { - (page.imagePage as? ProgressPage)?.progress = - (dec.page + 1).toFloat() / dec.pages - } - } - } - - // Scrolled past: the frames left are work nothing will draw. - if (!stillWanted) { - discardFrames() - return - } - - val frame = pending ?: dec.decodeNext() - pending = null - val image = frame.use { - Image( - it.image, - it.width, - it.height, - createMipMaps = false, - backgroundColor = firstImage.backgroundColor, - hdr = dec.isHdr, - hdrHeadroom = hdrHeadroom, - ) - } - frames.add(Pair(image, frame.duration)) - } - } catch (e: Throwable) { - discardFrames() - throw e - } - } - synchronized(lock) { - if (shown != null) { - if (pageInCache(page) && page.imagePage === shown && !shown.destroyed) { - // Keeps pan and zoom. + val target = when { + // Evicted. + !pageInCache(page) -> null + + // The preview becomes the image in place, keeping pan and zoom - unless + // something replaced it meanwhile. + shown != null -> shown.takeIf { page.imagePage === it && !it.destroyed }?.also { page.preview = null - shown.highQuality = true - if (animated) shown.startAnimationLoop(frames) - if (wasHome && firstImage.trim != null && !isDualPageMode()) rehome(shown) - noteIfLone(page) + // Tiles for a still; an animation swaps images every frame. + it.highQuality = !animated + if (wasHome && firstImage.trim != null && !isDualPageMode()) rehome(it) // Bumps frameVersion, so a transition's cached render of the preview is stale. - shown.invalidate() - pager.state.invalidate() - } else { - discardFrames() + it.invalidate() } - if (pageInCache(page)) page.state = PageState.IDLE - } else if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { - val imagePage = if (animated) ImagePage.ImageSingle(frames) else ImagePage.ImageSingle(firstImage) - val oldImagePage = page.imagePage - // A failed stream's preview is already showing. - val fromPreview = oldImagePage === page.preview - page.imagePage = imagePage - page.preview = null - noteIfLone(page) - page.state = PageState.IDLE - cleanupImage(oldImagePage) - // Fade up from the placeholder's colour, if that placeholder was on screen - - // one that decoded out of view has nothing left to fade from. - if (oldImagePage.isOnScreen && !fromPreview) imagePage.fadeIn() - if (!isDualPageMode()) { - if (!applyWideZoomIfNeeded(imagePage)) { + + // A new page in the placeholder's place. + !page.isDecoded && !page.imagePage.destroyed -> { + val imagePage = ImagePage.ImageSingle(firstImage) + orphan = null + val old = page.imagePage + // A failed stream's preview is already showing. + val fromPreview = old === page.preview + page.imagePage = imagePage + page.preview = null + cleanupImage(old) + // Fade up from the placeholder's colour, if that placeholder was on screen - + // one that decoded out of view has nothing left to fade from. + if (old.isOnScreen && !fromPreview) imagePage.fadeIn() + if (!isDualPageMode() && !applyWideZoomIfNeeded(imagePage)) { applyFitModeAnchor(imagePage) } + imagePage } + + else -> null + } + if (pageInCache(page)) page.state = PageState.IDLE + target?.also { installedPage -> + if (animated) { + // The page takes the decoder, which loops, and the second frame decoded + // to tell it's animated, which goes first: the rest decode as they're due. + keep() + var ahead = secondFrame + secondFrame = null + val name = pageName(page) + val release = { + ahead?.close() + ahead = null + dec.close() + } + installedPage.animate(firstDuration, release) { + withContext(animationDispatcher) { + val f = ahead?.also { ahead = null } ?: if (dec.hasNext) { + val started = SystemClock.uptimeMillis() + dec.decodeNext().also { + logcat(LogPriority.DEBUG) { + "Decoded frame ${it.index} of $name in " + + "${SystemClock.uptimeMillis() - started} ms" + } + } + } else { + null + } + f?.let { AnimationFrame(it.image, it.duration) { it.close() } } + } + } + } + noteIfLone(page) pager.state.invalidate() - } else { - if (pageInCache(page)) page.state = PageState.IDLE - discardFrames() } } } finally { firstFrame.close() secondFrame?.close() + // Nothing took it, normally or not. A preview's image belongs to its page either way. + orphan?.let { ImagePage.ImageSingle(it).cleanup() } } } @@ -1888,12 +1920,13 @@ open class WebGpuViewer( pager.state.animatePageTurn(if (isReversed) direction else -direction) } + // moveRight/moveLeft are screen directions, so a right-to-left book's next is to the left. fun moveToNext() { - moveRight() + if (isReversed) moveLeft() else moveRight() } fun moveToPrevious() { - moveLeft() + if (isReversed) moveRight() else moveLeft() } protected open fun moveRight() { @@ -1979,7 +2012,7 @@ open class WebGpuViewer( if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { return false } else if (isUp) { - if (!config.volumeKeysInverted.xor(isReversed)) moveDown() else moveUp() + if (!config.volumeKeysInverted) moveDown() else moveUp() } } @@ -1987,7 +2020,7 @@ open class WebGpuViewer( if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { return false } else if (isUp) { - if (!config.volumeKeysInverted.xor(isReversed)) moveUp() else moveDown() + if (!config.volumeKeysInverted) moveUp() else moveDown() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt index 0b0db5ed0..2c1ea8be8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt @@ -10,7 +10,7 @@ import kotlin.math.max class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) : WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) { - override val isContinuous: Boolean = true + override val isContinuous: Boolean get() = true // How many pages the viewport shows depends on the zoom, and a page on screen has to be // decoded rather than merely reserved - so the window follows what the last frame reached. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e95b4bea3..beb2ee0f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ firebase-bom = "34.19.0" firebase-crashlytics = "3.0.8" flexibleAdapter = "5.1.0" google-services = "4.5.0" -image-decoder = "15" +image-decoder = "16" injekt = "1.16.1" jsoup = "1.23.2" junit = "6.1.3" @@ -76,7 +76,7 @@ tapmoc = "0.4.2" unifile = "08f224c8f9" valkyrie = "0.5.2" voyager = "2.2.21-1.10.3" -webgpuviewer = "49" +webgpuviewer = "51" xmlutil = "1.0.2" [libraries]