mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user