Bump webgpuviewer (#4029)

* Add crop for long strip

* Don't save all animation frames in memory

* Fix navigation

* Update libs.versions.toml
This commit is contained in:
w
2026-10-02 03:30:24 +06:00
committed by GitHub
parent 972c9d43da
commit 98ba693054
4 changed files with 145 additions and 104 deletions
@@ -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() })
@@ -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<Pair<Image, Int>>(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()
}
}
@@ -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.