Remove libvips based imagedecoder and add progressive rendering (#4023)

This commit is contained in:
w
2026-09-29 03:11:45 +06:00
committed by GitHub
parent 2d1d2e4cab
commit 65d7110e1e
9 changed files with 751 additions and 199 deletions
@@ -172,32 +172,36 @@ fun MangaCoverDialog(
.memoryCachePolicy(CachePolicy.DISABLED)
.newDecoder(true)
.target { result ->
val res = (result as ImageDecoder.DecodeResultImage).res
val page = runBlocking(Dispatchers.Default) {
ImagePage.ImageSingle(
Image(
res.image,
res.width,
res.height,
createMipMaps = true,
backgroundColor = 0,
hdr = res.isHdr,
hdrHeadroom = res.hdrHeadroom,
gainmap = res.gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
},
),
)
val res = (result as ImageDecoder.DecodeResultImage)
// Held, then freed, around the upload: its buffer alone doesn't keep
// the frame's native pixels alive.
val page = res.frame.use {
runBlocking(Dispatchers.Default) {
ImagePage.ImageSingle(
Image(
res.image,
res.width,
res.height,
createMipMaps = true,
backgroundColor = 0,
hdr = res.isHdr,
hdrHeadroom = res.hdrHeadroom,
gainmap = res.gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
},
),
)
}
}
state.apply {
fetchPage = { index ->
@@ -7,6 +7,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.DownloadStream
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.json.Json
@@ -147,10 +148,11 @@ class ChapterCache(
*
* @param imageUrl url of image.
* @param response http response from page.
* @param live also receives the bytes as they arrive.
* @throws IOException image error.
*/
@Throws(IOException::class)
fun putImageToCache(imageUrl: String, response: Response) {
fun putImageToCache(imageUrl: String, response: Response, live: DownloadStream? = null) {
// Initialize editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null
@@ -160,7 +162,22 @@ class ChapterCache(
editor = diskCache.edit(key) ?: return
// Get OutputStream and write image with Okio.
response.body.source().saveTo(editor.newOutputStream(0))
if (live == null) {
response.body.source().saveTo(editor.newOutputStream(0))
} else {
live.reserve(response.body.contentLength())
response.body.source().use { source ->
editor.newOutputStream(0).use { out ->
val buffer = ByteArray(1 shl 16)
while (true) {
val n = source.read(buffer)
if (n < 0) break
out.write(buffer, 0, n)
live.append(buffer, 0, n)
}
}
}
}
diskCache.flush()
editor.commit()
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import ca.mpreg.imagedecoder.ImageDecoder
@@ -17,26 +18,41 @@ import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
/**
* A [Decoder] that uses [ImageDecoder] (libvips-based) to decode image formats not supported
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
*/
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
/**
* Wraps a raw [ImageDecoder.DecodeResult] as a Coil [Image] for callers that want
* Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
*/
class DecodeResultImage(val res: ImageDecoder.DecodeResult) : Image {
override val size: Long get() = res.image.capacity().toLong()
override val width: Int get() = res.width
override val height: Int get() = res.height
class DecodeResultImage(
val frame: ImageDecoder.Frame,
val isHdr: Boolean,
val hdrHeadroom: Float,
val gainmap: ImageDecoder.Gainmap?,
) : Image {
val image: java.nio.ByteBuffer get() = frame.image
// Taken now: a caller may close the frame once it has the pixels.
override val size: Long = frame.image.capacity().toLong()
override val width: Int get() = frame.width
override val height: Int get() = frame.height
override val shareable: Boolean get() = true
override fun draw(canvas: Canvas) {}
}
override suspend fun decode(): DecodeResult {
val res = resources.source().use {
ImageDecoder.new(it.inputStream()).use { dec -> dec.decode() }
ImageDecoder.open(it.inputStream()).use { dec ->
DecodeResultImage(
dec.decodeNext(),
dec.isHdr,
dec.hdrHeadroom,
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
)
}
}
val srcWidth = res.width
@@ -46,7 +62,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
// Hand it back as-is; sampling is the caller's responsibility.
if (options.newDecoder) {
return DecodeResult(
image = DecodeResultImage(res),
image = res,
isSampled = false,
)
}
@@ -64,9 +80,12 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
// Copy RGBA pixels from the native buffer into a full-resolution bitmap.
// We must do this while `res` (and its native memory) is still alive.
val fullBitmap = createBitmap(srcWidth, srcHeight)
// HDR frames are half-float RGBA.
val config = if (res.isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
val fullBitmap = createBitmap(srcWidth, srcHeight, config)
res.image.rewind()
fullBitmap.copyPixelsFromBuffer(res.image)
res.frame.close()
// Downsample if needed. sampleSize is a power-of-two factor; the target
// dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour.
@@ -82,11 +82,14 @@ internal class HttpPageLoader(
/**
* Loads a page through the queue. Handles re-enqueueing pages if they were evicted from the cache.
*/
override suspend fun loadPage(page: ReaderPage) = withIOContext {
override suspend fun loadPage(page: ReaderPage) {
val imageUrl = page.imageUrl
// Check if the image has been deleted
if (page.status == Page.State.Ready && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
// Check if the image has been deleted. Only this touches the disk; the queueing below
// stays on the caller's thread, so requests queue in the order they were made.
if (page.status == Page.State.Ready && imageUrl != null &&
!withIOContext { chapterCache.isImageInCache(imageUrl) }
) {
page.status = Page.State.Queue
}
@@ -179,14 +182,23 @@ internal class HttpPageLoader(
val imageUrl = page.imageUrl!!
if (force || !chapterCache.isImageInCache(imageUrl)) {
val live = page.downloadStream
page.status = Page.State.DownloadImage
val imageResponse = source.getImage(page)
chapterCache.putImageToCache(imageUrl, imageResponse)
try {
val imageResponse = source.getImage(page)
chapterCache.putImageToCache(imageUrl, imageResponse, live)
live?.finish()
} catch (e: Throwable) {
live?.finish(e)
throw e
}
}
page.downloadStream = null
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.State.Ready
} catch (e: Throwable) {
page.downloadStream = null
page.status = Page.State.Error(e)
if (e is CancellationException) {
throw e
@@ -0,0 +1,130 @@
package eu.kanade.tachiyomi.ui.reader.model
import java.io.IOException
import java.io.InputStream
/**
* A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is
* closed, it stops keeping them; [append] never throws, so the download itself carries on.
*/
class DownloadStream {
private val lock = Object()
private var data = ByteArray(1 shl 16)
private var size = 0
private var done = false
private var error: Throwable? = null
/** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */
fun reserve(bytes: Long) {
synchronized(lock) {
if (error == null && bytes > data.size && bytes <= MAX_RESERVE) {
try {
data = data.copyOf(bytes.toInt())
} catch (e: OutOfMemoryError) {
// Only a hint.
}
}
}
}
fun append(src: ByteArray, offset: Int, length: Int) {
synchronized(lock) {
if (error != null || done) return
val needed = size.toLong() + length
if (needed > MAX_BYTES) {
// Too big to decode while downloading; the whole-file decode takes over.
drop("Download too large to stream")
return
}
if (needed > data.size) {
data = try {
data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt())
} catch (e: OutOfMemoryError) {
// As too large: the download mustn't fail over this copy.
drop("Out of memory buffering the download")
return
}
}
System.arraycopy(src, offset, data, size, length)
size += length
lock.notifyAll()
}
}
/** Ends the bytes; readers throw [error] if given. */
fun finish(error: Throwable? = null) {
synchronized(lock) {
done = true
if (this.error == null) this.error = error
lock.notifyAll()
}
}
/** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */
fun reader(): Reader = Reader()
// Rather than hold a second copy of the page.
private fun drop(why: String) {
if (error == null) error = IOException(why)
data = ByteArray(0)
size = 0
lock.notifyAll()
}
private companion object {
// As the decoder's own input limit.
const val MAX_BYTES = 1L shl 30
const val MAX_RESERVE = 64L shl 20
}
inner class Reader : InputStream() {
private var position = 0
/** All read; throws if the download failed. */
val ended: Boolean
get() = synchronized(lock) {
checkError()
done && position >= size
}
override fun read(): Int {
val b = ByteArray(1)
return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
synchronized(lock) {
if (len == 0) return 0
while (position >= size && !done && error == null) lock.wait()
checkError()
if (position >= size) return -1
val n = minOf(len, size - position)
System.arraycopy(data, position, b, off, n)
position += n
return n
}
}
override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position }
/** Waits up to [timeoutMs] for [min] unread bytes or the end. */
fun await(min: Int, timeoutMs: Long) {
synchronized(lock) {
val until = System.nanoTime() + timeoutMs * 1_000_000
while (size - position < min && !done && error == null) {
val left = (until - System.nanoTime()) / 1_000_000
if (left <= 0) return
lock.wait(left)
}
}
}
override fun close() {
synchronized(lock) { drop("No longer read") }
}
private fun checkError() {
error?.let { throw IOException("Download failed", it) }
}
}
}
@@ -11,4 +11,8 @@ open class ReaderPage(
) : Page(index, url, imageUrl, null) {
open lateinit var chapter: ReaderChapter
/** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */
@Volatile
var downloadStream: DownloadStream? = null
}
@@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import android.graphics.Color
import android.graphics.PointF
import android.graphics.Rect
import android.os.SystemClock
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
@@ -38,6 +40,8 @@ import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState
import com.google.android.material.color.MaterialColors
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
import eu.kanade.tachiyomi.ui.reader.model.DownloadStream
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@@ -49,6 +53,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@@ -60,12 +65,22 @@ import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.app.di.appGraph
import tachiyomi.core.common.util.system.logcat
import java.io.FilterInputStream
import java.lang.ref.WeakReference
import java.util.TreeSet
import java.util.concurrent.Executors
import kotlin.math.abs
import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds
private const val STREAM_CHUNK = 1 shl 18
/** Longest a streaming feed waits before rechecking that the page is still wanted. */
private const val STREAM_WAIT_MS = 1000L
/** Least time between feeds, so a trickle of small reads decodes and uploads in batches. */
private const val STREAM_BATCH_MS = 33L
open class WebGpuViewer(
val activity: ReaderActivity,
val isReversed: Boolean,
@@ -102,6 +117,12 @@ open class WebGpuViewer(
}
private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher()
// Streaming decodes block on the network.
private val streamExecutor = Executors.newSingleThreadExecutor { r ->
Thread(r, "WebGpuViewer-Stream").apply { isDaemon = true }
}
private val streamDispatcher = streamExecutor.asCoroutineDispatcher()
// Guards pageCache, decodeQueue, deferredCleanup and chapterPreloadsInFlight.
private val lock = Object()
@@ -114,6 +135,13 @@ open class WebGpuViewer(
// Processed LIFO - last added is highest priority.
private val decodeQueue = ArrayDeque<ViewerReaderPage>()
// Each page's loader.loadPage call, which suspends until cancelled; cancelling drops its
// still-queued downloads from the loader. Under [lock].
private val loadJobs = HashMap<PageKey, Job>()
// Each page's status watcher, from startPageLoad. Under [lock].
private val watchJobs = HashMap<PageKey, Job>()
/**
* Indices of the pages that take a spread to themselves, by chapter - see [spreadStartIndex].
* Outlives [pageCache]: every page after one of these depends on it, long since evicted.
@@ -209,13 +237,15 @@ open class WebGpuViewer(
} catch (e: CancellationException) {
// Caught below, the loop would park in wait() on a dead scope.
throw e
} catch (e: Exception) {
} catch (e: Throwable) {
if (e !is Exception && e !is OutOfMemoryError) throw e
logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" }
synchronized(lock) {
if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) {
val oldImagePage = page.imagePage
val errorMessage = e.message ?: "Failed to decode image"
page.imagePage = ErrorPage(errorMessage, page.spreadPosition)
page.preview = null
page.state = PageState.IDLE
cleanupImage(oldImagePage)
page.imagePage.invalidate()
@@ -695,8 +725,18 @@ open class WebGpuViewer(
override var imagePage: ImagePage = ProgressPage()
/** So a download streams once; weak, as it holds the whole download. Under [lock]. */
internal var streamedFrom: WeakReference<DownloadStream>? = null
/** A streaming decode owns the page. Under [lock]. */
internal var streaming = false
/** Not counted by [isDecoded]. */
@Volatile
internal var preview: ImagePage? = null
override val isDecoded
get() = (imagePage as? ImagePage.ImageSingle)?.isDecoded == true
get() = imagePage.let { it !== preview && (it as? ImagePage.ImageSingle)?.isDecoded == true }
override val prevChapter: ReaderChapter?
get() = when (page.chapter) {
@@ -978,6 +1018,10 @@ open class WebGpuViewer(
synchronized(lock) {
decodeQueue.clear()
loadJobs.values.forEach { it.cancel() }
loadJobs.clear()
watchJobs.values.forEach { it.cancel() }
watchJobs.clear()
pageCache.values.forEach {
it.state = PageState.IDLE
(it as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage)
@@ -1010,6 +1054,8 @@ open class WebGpuViewer(
// shutdownNow interrupts the worker out of lock.wait().
decodeExecutor.shutdownNow()
decodeDispatcher.close()
streamExecutor.shutdownNow()
streamDispatcher.close()
synchronized(lock) {
decodeQueue.clear()
@@ -1031,6 +1077,24 @@ open class WebGpuViewer(
override fun getView(): View = pager
/**
* 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.
*/
private fun requestDownload(page: ViewerReaderPage) {
val loader = page.page.chapter.pageLoader ?: return
if (page.page.downloadStream == null) page.page.downloadStream = DownloadStream()
synchronized(lock) {
loadJobs.remove(pageKey(page))?.cancel()
// Undispatched for the HTTP loader, which queues before suspending, so requests queue in
// order. Other loaders may work first, which mustn't happen here under the lock.
val start = if (loader is HttpPageLoader) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
loadJobs[pageKey(page)] = scope.launch(Dispatchers.IO, start = start) {
loader.loadPage(page.page)
}
}
}
/** Downloads [page] if needed, then re-queues it for decode once ready. */
private fun startPageLoad(page: ViewerReaderPage) {
val loader = page.page.chapter.pageLoader ?: run {
@@ -1040,7 +1104,7 @@ open class WebGpuViewer(
if (page.page.status == Page.State.Ready) {
synchronized(lock) {
if (pageInCache(page) && !page.isDecoded) {
if (pageInCache(page) && !page.isDecoded && !page.streaming) {
page.state = PageState.IDLE
queueForDecode(page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false)
} else if (pageInCache(page)) {
@@ -1056,12 +1120,10 @@ open class WebGpuViewer(
}
if (page.page.status == Page.State.Queue) {
scope.launch(Dispatchers.IO) {
loader.loadPage(page.page)
}
requestDownload(page)
}
scope.launch {
val watch = scope.launch(start = CoroutineStart.LAZY) {
try {
val downloadProgressJob = launch {
page.page.progressFlow.collect { value ->
@@ -1078,7 +1140,11 @@ open class WebGpuViewer(
if (!synchronized(lock) { pageInCache(page) }) return@takeWhile false
when (state) {
Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true
Page.State.Queue, Page.State.LoadPage -> true
Page.State.DownloadImage -> {
page.page.downloadStream?.let { startStreamDecode(page, it) }
true
}
is Page.State.Error -> {
logcat(LogPriority.ERROR) { "Page load error: ${state.error}" }
false
@@ -1093,7 +1159,7 @@ open class WebGpuViewer(
synchronized(lock) {
if (pageInCache(page) && page.state == PageState.LOADING) {
page.state = PageState.IDLE
if (page.page.status == Page.State.Ready && !page.isDecoded) {
if (page.page.status == Page.State.Ready && !page.isDecoded && !page.streaming) {
queueForDecode(
page,
prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false,
@@ -1101,11 +1167,24 @@ open class WebGpuViewer(
}
}
}
} catch (e: CancellationException) {
// Reset or destroy; whoever cancelled has already put the state right.
throw e
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "startPageLoad error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
} finally {
val self = coroutineContext[Job]
synchronized(lock) {
if (watchJobs[pageKey(page)] === self) watchJobs.remove(pageKey(page))
}
}
}
synchronized(lock) {
watchJobs.remove(pageKey(page))?.cancel()
watchJobs[pageKey(page)] = watch
}
watch.start()
}
private suspend fun decodeReaderPage(page: ViewerReaderPage) {
@@ -1120,159 +1199,381 @@ open class WebGpuViewer(
}
stream.use { input ->
// Not evicted, and not already decoded by a concurrent call.
synchronized(lock) {
if (!pageInCache(page) || page.isDecoded) {
if (!pageInCache(page) || page.isDecoded || page.streaming) {
if (pageInCache(page)) page.state = PageState.IDLE
return
}
}
// 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.
fun ImageDecoder.DecodeResult.gainmapInput(): GainmapInput? = gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
logcat(LogPriority.INFO) { "Decoding ${pageName(page)}" }
ImageDecoder.open(input).use { dec -> buildPage(page, dec, streaming = false) { false } }
}
}
private fun pageName(page: ViewerReaderPage): String {
val p = page.page
return p.imageUrl?.takeIf { it.isNotEmpty() }
?: p.url.takeIf { it.isNotEmpty() }
?: "${p.chapter.chapter.name} page ${p.index + 1}"
}
/** Decodes [page] while it downloads; on failure the whole-file decode takes over. */
private fun startStreamDecode(page: ViewerReaderPage, live: DownloadStream) {
synchronized(lock) {
if (!pageInCache(page) || page.isDecoded || page.streamedFrom?.get() === live) return
page.streamedFrom = WeakReference(live)
}
scope.launch(streamDispatcher) {
val claimed = synchronized(lock) {
val ok = pageInCache(page) && !page.isDecoded && page.page.status == Page.State.DownloadImage
if (ok) page.streaming = true
ok
}
ImageDecoder.new(input).use { dec ->
if (isDualPageMode()) {
page.taggedSpreadPosition = when (dec.getTag("PageName")) {
"Left" -> SpreadPosition.LEFT
"Right" -> SpreadPosition.RIGHT
null -> null
else -> SpreadPosition.SINGLE
if (!claimed) return@launch
try {
live.reader().use { reader ->
// available() 0 stops the read after the header.
val headerOnly = object : FilterInputStream(reader) {
override fun available() = 0
}
}
val pageCount = dec.pages
if (pageCount == 0) throw Exception("No frames decoded")
val backgroundColor = if (config.automaticBackground) null else readerBackgroundColor()
val firstFrame = dec.decodeNext()
val imagePage = if (pageCount == 1) {
// Only trim when not animated and not in dual page mode
val trimColors = if (config.imageCropBorders && !isDualPageMode()) {
listOf(
floatArrayOf(1f, 1f, 1f),
floatArrayOf(0f, 0f, 0f),
)
} else {
null
}
val firstImage = Image(
firstFrame.image,
firstFrame.width,
firstFrame.height,
createMipMaps = true,
trimColors = trimColors,
trimThreshold = 0.15f,
backgroundColor = backgroundColor,
hdr = firstFrame.isHdr,
hdrHeadroom = firstFrame.hdrHeadroom,
gainmap = firstFrame.gainmapInput(),
)
ImagePage.ImageSingle(firstImage)
} else {
val frames = ArrayList<Pair<Image, Int>>(pageCount)
// Built frames hold uploaded textures, and ImageSingle owns the only teardown.
fun discardFrames() {
if (frames.isNotEmpty()) ImagePage.ImageSingle(frames).cleanup()
}
val firstImage = Image(
firstFrame.image,
firstFrame.width,
firstFrame.height,
createMipMaps = false,
backgroundColor = backgroundColor,
hdr = firstFrame.isHdr,
hdrHeadroom = firstFrame.hdrHeadroom,
gainmap = firstFrame.gainmapInput(),
)
frames.add(Pair(firstImage, firstFrame.duration))
try {
for (i in 1 until pageCount) {
// 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 = i.toFloat() / pageCount
}
}
logcat(LogPriority.INFO) { "Decoding while downloading ${pageName(page)}" }
ImageDecoder.open(headerOnly, complete = false).use { dec ->
val chunk = ByteArray(STREAM_CHUNK)
var fed = false
var fedAt = 0L
// Blocks for new bytes: decoding again on none only repeats work.
buildPage(page, dec, streaming = true) {
if (fed) return@buildPage false
while (reader.available() == 0 && !reader.ended) {
if (!synchronized(lock) { pageInCache(page) }) return@buildPage false
reader.await(1, STREAM_WAIT_MS)
}
// Scrolled past: the frames left are work nothing will draw.
if (!stillWanted) {
discardFrames()
return
val gap = fedAt + STREAM_BATCH_MS - SystemClock.uptimeMillis()
if (gap > 0) reader.await(Int.MAX_VALUE, gap)
while (reader.available() > 0) dec.pushData(chunk, 0, reader.read(chunk))
fedAt = SystemClock.uptimeMillis()
if (reader.ended) {
dec.markComplete()
fed = true
}
val frame = dec.decodeNext()
val image = Image(
frame.image,
frame.width,
frame.height,
createMipMaps = false,
backgroundColor = firstImage.backgroundColor,
hdr = frame.isHdr,
hdrHeadroom = frame.hdrHeadroom,
gainmap = frame.gainmapInput(),
)
frames.add(Pair(image, frame.duration))
true
}
} catch (e: Throwable) {
discardFrames()
throw e
}
ImagePage.ImageSingle(frames)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.DEBUG) { "Streaming decode gave up: ${e.message}" }
} catch (e: OutOfMemoryError) {
logcat(LogPriority.WARN) { "Streaming decode ran out of memory" }
} finally {
synchronized(lock) {
if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) {
val oldImagePage = page.imagePage
page.imagePage = imagePage
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) imagePage.fadeIn()
if (!isDualPageMode()) {
(page.imagePage as? ImagePage.ImageSingle)?.let {
if (!applyWideZoomIfNeeded(it)) {
applyFitModeAnchor(it)
}
}
}
page.streaming = false
// Failed download: back to the ring for a retry.
val preview = page.preview
if (pageInCache(page) && page.imagePage === preview && page.page.status is Page.State.Error) {
page.preview = null
page.imagePage = ProgressPage()
cleanupImage(preview)
pager.state.invalidate()
} else {
if (pageInCache(page)) page.state = PageState.IDLE
imagePage.cleanup()
}
if (pageInCache(page) && !page.isDecoded && page.state == PageState.IDLE &&
page.page.status == Page.State.Ready
) {
queueForDecode(page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false)
}
}
}
}
}
/** Decodes [dec] into [page]. [feed] pushes more bytes, false once all are in. */
private suspend fun buildPage(
page: ViewerReaderPage,
dec: ImageDecoder,
streaming: Boolean,
feed: () -> Boolean,
) {
// 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.
fun ImageDecoder.Gainmap.toInput() = let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
}
if (isDualPageMode()) {
page.taggedSpreadPosition = when (dec.getTag("PageName")) {
"Left" -> SpreadPosition.LEFT
"Right" -> SpreadPosition.RIGHT
null -> null
else -> SpreadPosition.SINGLE
}
}
val backgroundColor = if (config.automaticBackground) null else readerBackgroundColor()
// SDR only: HDR skips Image's tone mapping; a gain map may need float textures.
var previewing = streaming && !dec.isHdr && dec.hdrKind != ImageDecoder.HdrKind.GAINMAP
// Null once the page is unwanted.
suspend fun nextWhole(onPartial: (suspend (ImageDecoder.Frame) -> Unit)? = null): ImageDecoder.Frame? {
while (true) {
if (!synchronized(lock) { pageInCache(page) }) return null
val f = try {
dec.decodeNext()
} catch (e: ImageDecoder.NeedMoreDataException) {
if (!feed()) throw e
continue
}
if (!f.partial) return f
// A partial views the decoder's canvas, valid only until the next step.
f.use { if (onPartial != null && previewing) onPartial(it) }
feed()
}
}
var preview: ImagePage.ImageSingle? = null
// Replaces the placeholder, then updates its texture in place.
suspend fun showPartial(f: ImageDecoder.Frame) {
if (!previewing || !f.changed) return
val shown = preview
if (shown == null) {
val single = ImagePage.ImageSingle(
Image(
f.image,
f.width,
f.height,
createMipMaps = false,
backgroundColor = backgroundColor ?: readerBackgroundColor(),
),
).apply {
// The tile cache would go stale.
highQuality = false
}
val installed = synchronized(lock) {
val old = page.imagePage
(pageInCache(page) && old is ProgressPage && !old.destroyed).also { ok ->
if (!ok) return@also
page.preview = single
page.imagePage = single
cleanupImage(old)
if (old.isOnScreen) single.fadeIn()
if (!isDualPageMode() && !applyWideZoomIfNeeded(single)) applyFitModeAnchor(single)
pager.state.invalidate()
}
}
if (installed) preview = single else single.cleanup()
previewing = installed
} else {
// Replaced, as by an error page.
if (synchronized(lock) { page.imagePage !== shown } || shown.destroyed) {
previewing = false
return
}
val rect = Rect(f.dirtyX, f.dirtyY, f.dirtyX + f.dirtyWidth, f.dirtyY + f.dirtyHeight)
shown.image?.update(f.image, rect)
shown.invalidate()
// A spread's page isn't on screen itself.
pager.state.invalidate()
}
}
// Nothing to preview: the whole-file decode after the download does as well,
// without holding the one streaming thread through it.
if (streaming && !previewing) return
val firstFrame = nextWhole(onPartial = { showPartial(it) }) ?: return
var secondFrame: ImageDecoder.Frame? = 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.
if (streaming) {
while (feed()) {
if (!synchronized(lock) { pageInCache(page) }) return
}
if (dec.hasNext) {
secondFrame = try {
nextWhole() ?: return
} catch (e: ImageDecoder.DecodeException) {
logcat(LogPriority.DEBUG) { "Treating as a still: ${e.message}" }
null
}
}
}
val animated = if (streaming) secondFrame != null else dec.hasNext
// A gain map belongs to a still; its headroom is the map's, not the decoder's.
val gainmap = if (!animated && dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) {
dec.getGainmap()
} else {
null
}
val hdrHeadroom = gainmap?.headroomStops ?: dec.hdrHeadroom
// Only trim when not animated and not in dual page mode
val trimColors = if (!animated && config.imageCropBorders && !isDualPageMode()) {
listOf(
floatArrayOf(1f, 1f, 1f),
floatArrayOf(0f, 0f, 0f),
)
} else {
null
}
// The preview's textures become the image, or frame 0; one since replaced or freed
// (a settings reset) leaves a fresh image to install instead.
val shown = preview?.takeIf { synchronized(lock) { page.imagePage === it && !it.destroyed } }
// Only a page the reader hasn't moved re-homes for its trim.
val wasHome = shown?.atHome == true
val firstImage = shown?.image?.also {
it.update(firstFrame.image)
if (!animated) it.createMipMaps(firstFrame.image)
it.measure(firstFrame.image, trimColors, trimThreshold = 0.15f, backgroundColor = backgroundColor)
} ?: Image(
firstFrame.image,
firstFrame.width,
firstFrame.height,
createMipMaps = !animated,
trimColors = trimColors,
trimThreshold = 0.15f,
backgroundColor = backgroundColor,
hdr = dec.isHdr,
hdrHeadroom = hdrHeadroom,
gainmap = gainmap?.toInput(),
)
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.
page.preview = null
shown.highQuality = true
if (animated) shown.startAnimationLoop(frames)
if (wasHome && firstImage.trim != null && !isDualPageMode()) rehome(shown)
noteIfLone(page)
// Bumps frameVersion, so a transition's cached render of the preview is stale.
shown.invalidate()
pager.state.invalidate()
} else {
discardFrames()
}
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)) {
applyFitModeAnchor(imagePage)
}
}
pager.state.invalidate()
} else {
if (pageInCache(page)) page.state = PageState.IDLE
discardFrames()
}
}
} finally {
firstFrame.close()
secondFrame?.close()
}
}
/** Animates [page] to where a fresh install would place it, e.g. once its trim is known. */
private fun rehome(page: ImagePage.ImageSingle) {
val fromX = page.x
val fromY = page.y
val fromScale = page.scale
// Cached from the old size.
page.homeScale = -1f
page.homeX = 0f
page.homeY = 0f
page.minScale = 0f
// The placement a fresh install gets, read back and undone so it can animate there.
page.x = page.homeX
page.y = page.homeY
page.scale = page.homeScale
if (!applyWideZoomIfNeeded(page)) applyFitModeAnchor(page)
val toX = page.x
val toY = page.y
val toScale = page.scale
page.x = fromX
page.y = fromY
page.scale = fromScale
page.animateTo(targetX = toX, targetY = toY, targetScale = toScale)
}
private fun applyWideZoomIfNeeded(page: ImagePage.ImageSingle): Boolean {
if (!config.landscapeZoom) return false
@@ -1366,6 +1667,60 @@ open class WebGpuViewer(
}
}
/**
* Drops decodes outside [window], and every download not yet started so that [preloadPages]
* asks again in order. A download already running finishes; its page decodes if it's back
* in the window by then.
*/
private fun resetQueues(window: Set<PageKey>) {
synchronized(lock) {
val stale = decodeQueue.filter { pageKey(it) !in window }
decodeQueue.removeAll(stale.toSet())
stale.forEach { if (it.state == PageState.QUEUED) it.state = PageState.IDLE }
// All of them: what stays wanted is asked again, in this window's order.
val iterator = loadJobs.entries.iterator()
while (iterator.hasNext()) {
val (key, job) = iterator.next()
job.cancel()
iterator.remove()
if (key in window) continue
// Never started: its watcher goes too. One downloading keeps it, to decode when done.
(pageCache[key] as? ViewerReaderPage)?.let {
if (it.state == PageState.LOADING && it.page.status == Page.State.Queue) {
watchJobs.remove(key)?.cancel()
it.state = PageState.IDLE
}
}
}
}
}
/**
* Starts [page]'s download now, rather than when the one decode worker reaches it - which a
* slow decode ahead of it holds up, and which left pages past the loader's own preload
* waiting. Ready then queues the decode.
*/
private fun startDownload(page: ViewerPage) {
synchronized(lock) {
val cachedPage = findInCache(pageKey(page)) as? ViewerReaderPage ?: return
if (cachedPage.page.status == Page.State.Ready || cachedPage.isDecoded) return
when (cachedPage.state) {
// Watched already: only re-request, in this order.
PageState.LOADING -> {
if (cachedPage.page.status == Page.State.Queue) requestDownload(cachedPage)
return
}
PageState.DECODING -> return
// Ready requeues it, once downloaded.
PageState.QUEUED -> decodeQueue.remove(cachedPage)
PageState.IDLE -> {}
}
cachedPage.state = PageState.IDLE
startPageLoad(cachedPage)
}
}
protected fun preloadPages(page: ViewerPage) {
// page may be a stale copy - resolve the live cache entry.
val key = pageKey(page)
@@ -1386,6 +1741,13 @@ open class WebGpuViewer(
p = p?.next ?: break
nextPages.add(p)
}
resetQueues((prevPages + cachedPage + nextPages).mapTo(HashSet()) { pageKey(it) })
// Downloads in reading order, as the loader serves requests of equal priority.
startDownload(cachedPage)
nextPages.forEach { startDownload(it) }
prevPages.forEach { startDownload(it) }
nextPages.asReversed().forEach { preloadPage(it) }
cachedPage.next?.let { preloadPage(it, prioritize = true) }
@@ -47,15 +47,16 @@ object ImageUtil {
fun findImageType(stream: InputStream): ImageType? {
return try {
val format = ImageDecoder.new(stream).use { dec -> dec.format }
val format = ImageDecoder.open(stream).use { dec -> dec.format }
when (format) {
"jpeg" -> ImageType.JPEG
"png" -> ImageType.PNG
"webp" -> ImageType.WEBP
"gif" -> ImageType.GIF
"heif" -> ImageType.HEIF
"jxl" -> ImageType.JXL
"jp2" -> ImageType.JP2
ImageDecoder.Format.JPEG -> ImageType.JPEG
ImageDecoder.Format.PNG -> ImageType.PNG
ImageDecoder.Format.WEBP -> ImageType.WEBP
ImageDecoder.Format.GIF -> ImageType.GIF
ImageDecoder.Format.AVIF -> ImageType.AVIF
ImageDecoder.Format.HEIF -> ImageType.HEIF
ImageDecoder.Format.JXL -> ImageType.JXL
ImageDecoder.Format.JP2 -> ImageType.JP2
else -> null
}
} catch (e: Exception) {
@@ -76,7 +77,7 @@ object ImageUtil {
ImageType.GIF -> true
ImageType.WEBP, ImageType.HEIF -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
ImageDecoder.new(source.peek().inputStream()).use { dec -> dec.pages > 1 }
ImageDecoder.open(source.peek().inputStream()).use { dec -> dec.pages > 1 }
}
else -> false
@@ -303,10 +304,13 @@ object ImageUtil {
*/
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
val image = try {
ImageDecoder.new(imageStream).use { it.decode() }.let { res ->
createBitmap(res.width, res.height).also { bitmap ->
res.image.rewind()
bitmap.copyPixelsFromBuffer(res.image)
ImageDecoder.open(imageStream).use { it.decodeNext() to it.isHdr }.let { (res, hdr) ->
res.use {
val config = if (hdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
createBitmap(res.width, res.height, config).also { bitmap ->
res.image.rewind()
bitmap.copyPixelsFromBuffer(res.image)
}
}
}
} catch (e: Exception) {
+3 -3
View File
@@ -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 = "14"
image-decoder = "15"
injekt = "1.16.1"
jsoup = "1.23.2"
junit = "6.1.3"
@@ -70,13 +70,13 @@ spotless = "8.10.3"
sqldelight = "2.4.0"
sqldelight-androidx-driver = "0.2.2"
stringSimilarity = "0.1.0"
subsamplingScaleImageView = "94915e6f73"
subsamplingScaleImageView = "1b283e2bc4"
swipe = "1.3.0"
tapmoc = "0.4.2"
unifile = "08f224c8f9"
valkyrie = "0.5.2"
voyager = "2.2.21-1.10.3"
webgpuviewer = "47"
webgpuviewer = "49"
xmlutil = "1.0.2"
[libraries]