diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/paste/side/preview/SkiaAnimatedImageView.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/paste/side/preview/SkiaAnimatedImageView.kt index 37ecaae3e..c0bb20b7a 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/paste/side/preview/SkiaAnimatedImageView.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/paste/side/preview/SkiaAnimatedImageView.kt @@ -9,16 +9,17 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap import com.composables.icons.materialsymbols.MaterialSymbols import com.composables.icons.materialsymbols.rounded.Broken_image @@ -28,7 +29,9 @@ import com.crosspaste.ui.theme.AppUISize.tiny import com.crosspaste.utils.extension import com.crosspaste.utils.ioDispatcher import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import okio.Path import org.jetbrains.skia.Bitmap @@ -52,14 +55,15 @@ fun Path.isAnimatedImage(): Boolean = extension.lowercase() in animatedImageExte internal fun frameDurationMs(rawDurationMs: Int?): Int = (rawDurationMs ?: MIN_FRAME_DURATION_MS).coerceAtLeast(MIN_FRAME_DURATION_MS) -private sealed interface CodecState { - data object Loading : CodecState +private sealed interface FrameState { + data object Loading : FrameState - data object Failed : CodecState + data object Failed : FrameState data class Ready( - val codec: Codec, - ) : CodecState + val imageBitmap: ImageBitmap, + val srcSize: Size, + ) : FrameState } @Composable @@ -70,126 +74,106 @@ fun SkiaAnimatedImageView( isPlaying: Boolean, modifier: Modifier = Modifier, ) { - val state by produceCodecState(path) - - when (val current = state) { - CodecState.Loading -> LoadingIndicator() - CodecState.Failed -> BrokenIcon(path.name) - is CodecState.Ready -> - AnimatedFrames( - codec = current.codec, - targetSizePx = targetSizePx, - smartImageDisplayStrategy = smartImageDisplayStrategy, - isPlaying = isPlaying, - modifier = modifier, - contentDescription = path.name, - ) - } -} - -@Composable -private fun AnimatedFrames( - codec: Codec, - targetSizePx: Size, - smartImageDisplayStrategy: SmartImageDisplayStrategy, - isPlaying: Boolean, - modifier: Modifier, - contentDescription: String, -) { - // Two bitmaps so the next frame can be decoded into the back buffer on IO - // while the front buffer keeps drawing. The swap publishes on the UI - // thread once readPixels returns. - val bitmaps = - remember(codec) { - Array(2) { Bitmap().apply { allocPixels(codec.imageInfo) } } - } - - DisposableEffect(bitmaps) { - onDispose { bitmaps.forEach { it.close() } } - } - - val frameInfos = remember(codec) { codec.framesInfo } - val frameCount = remember(codec) { codec.frameCount.coerceAtLeast(1) } - - var frameIndex by remember(codec) { mutableIntStateOf(0) } - var frontIndex by remember(codec) { mutableIntStateOf(0) } - var frameTick by remember(codec) { mutableIntStateOf(0) } - - LaunchedEffect(codec, isPlaying, frameCount) { - // Prime frame 0 once per codec so something is drawable before the - // loop runs and so single-frame inputs (frameCount == 1) still render. - // Skipping on re-launch keeps the current frame visible when - // isPlaying toggles mid-playback. - if (frameTick == 0) { + var frameState by remember(path) { mutableStateOf(FrameState.Loading) } + val playing by rememberUpdatedState(isPlaying) + + // Codec + double-buffered bitmaps are owned by this single LaunchedEffect + // so the finally block runs only after any in-flight readPixels returns. + // readPixels is a synchronous JNI call that ignores coroutine cancellation, + // so releasing native resources from a sibling DisposableEffect.onDispose + // (or produceState's awaitDispose) could free pixel buffers under an + // active decode and crash with SIGSEGV. + LaunchedEffect(path) { + val codec = withContext(ioDispatcher) { - runCatching { codec.readPixels(bitmaps[0], 0) } - .onFailure { logger.warn(it) { "Failed to decode initial frame" } } + runCatching { + val bytes = path.toFile().readBytes() + Codec.makeFromData(Data.makeFromBytes(bytes)) + }.onFailure { + logger.warn(it) { "Failed to decode animated image: $path" } + }.getOrNull() } - frameTick = 1 + + if (codec == null) { + frameState = FrameState.Failed + return@LaunchedEffect } - if (!isPlaying || frameCount <= 1) return@LaunchedEffect + try { + val bitmaps = Array(2) { Bitmap().apply { allocPixels(codec.imageInfo) } } + try { + val frameInfos = codec.framesInfo + val frameCount = codec.frameCount.coerceAtLeast(1) + val srcSize = Size(codec.imageInfo.width.toFloat(), codec.imageInfo.height.toFloat()) - while (true) { - val durationMs = frameDurationMs(frameInfos.getOrNull(frameIndex)?.duration) - delay(durationMs.milliseconds) - val nextIndex = (frameIndex + 1) % frameCount - val backIndex = 1 - frontIndex - // Decode off the UI thread; LZW decompression on large frames - // can otherwise eat the next render window. - val success = + // Prime frame 0 before publishing Ready so the first paint is the + // real first frame, not an empty bitmap. withContext(ioDispatcher) { - runCatching { codec.readPixels(bitmaps[backIndex], nextIndex) } - .onFailure { logger.warn(it) { "Failed to decode frame $nextIndex" } } - .isSuccess + runCatching { codec.readPixels(bitmaps[0], 0) } + .onFailure { logger.warn(it) { "Failed to decode initial frame" } } + } + + var frameIndex = 0 + var frontIndex = 0 + frameState = FrameState.Ready(bitmaps[frontIndex].asComposeImageBitmap(), srcSize) + + if (frameCount <= 1) awaitCancellation() + + while (true) { + if (!playing) { + // Suspend until isPlaying flips back to true instead of + // polling, so a hidden preview costs no wake-ups. + snapshotFlow { playing }.first { it } + continue + } + val durationMs = frameDurationMs(frameInfos.getOrNull(frameIndex)?.duration) + delay(durationMs.milliseconds) + if (!playing) continue + val nextIndex = (frameIndex + 1) % frameCount + val backIndex = 1 - frontIndex + // Decode off the UI thread; LZW decompression on large frames + // can otherwise eat the next render window. + val success = + withContext(ioDispatcher) { + runCatching { codec.readPixels(bitmaps[backIndex], nextIndex) } + .onFailure { logger.warn(it) { "Failed to decode frame $nextIndex" } } + .isSuccess + } + if (success) { + frameIndex = nextIndex + frontIndex = backIndex + frameState = FrameState.Ready(bitmaps[frontIndex].asComposeImageBitmap(), srcSize) + } } - if (success) { - frameIndex = nextIndex - frontIndex = backIndex - frameTick++ + } finally { + bitmaps.forEach { it.close() } } + } finally { + codec.close() } } - val imageBitmap = - remember(frontIndex, frameTick) { - bitmaps[frontIndex].asComposeImageBitmap() - } - - val displayResult = - remember(codec, targetSizePx) { - smartImageDisplayStrategy.compute( - srcSize = Size(codec.imageInfo.width.toFloat(), codec.imageInfo.height.toFloat()), - dstSize = targetSizePx, + when (val current = frameState) { + FrameState.Loading -> LoadingIndicator() + FrameState.Failed -> BrokenIcon(path.name) + is FrameState.Ready -> { + val displayResult = + remember(current.srcSize, targetSizePx) { + smartImageDisplayStrategy.compute( + srcSize = current.srcSize, + dstSize = targetSizePx, + ) + } + Image( + bitmap = current.imageBitmap, + contentDescription = path.name, + contentScale = displayResult.contentScale, + alignment = displayResult.alignment, + modifier = modifier, ) } - - Image( - bitmap = imageBitmap, - contentDescription = contentDescription, - contentScale = displayResult.contentScale, - alignment = displayResult.alignment, - modifier = modifier, - ) -} - -@Composable -private fun produceCodecState(path: Path) = - produceState(initialValue = CodecState.Loading, key1 = path) { - val loaded = - withContext(ioDispatcher) { - runCatching { - val bytes = path.toFile().readBytes() - CodecState.Ready(Codec.makeFromData(Data.makeFromBytes(bytes))) - }.onFailure { - logger.warn(it) { "Failed to decode animated image: $path" } - }.getOrElse { CodecState.Failed } - } - value = loaded - awaitDispose { - if (loaded is CodecState.Ready) loaded.codec.close() - } } +} @Composable private fun LoadingIndicator() {