From a9978277a39c76497f2a7bffb421703d527e9e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 21:10:07 +0000 Subject: [PATCH 1/5] feat: add GIF to MP4 conversion option in upload screen When a GIF is selected for upload, a new "Convert GIF to MP4" switch appears in the upload settings dialog. This converts animated GIFs to MP4 video using Android's Movie decoder and MediaCodec/MediaMuxer encoder, resulting in smaller file sizes and better playback compatibility. The converted video can optionally be further compressed using the existing video compression pipeline. https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w --- .../service/uploads/GifToMp4Converter.kt | 534 ++++++++++++++++++ .../service/uploads/MediaCompressor.kt | 26 +- .../service/uploads/MultiOrchestrator.kt | 6 + .../service/uploads/UploadOrchestrator.kt | 11 +- .../amethyst/ui/actions/EditPostView.kt | 2 +- .../ui/actions/uploads/SelectFromGallery.kt | 2 + .../creators/uploads/ImageVideoDescription.kt | 19 +- .../nip22Comments/GenericCommentPostScreen.kt | 2 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 2 +- .../nip23LongForm/LongFormPostScreen.kt | 4 +- .../nip23LongForm/LongFormPostViewModel.kt | 5 +- .../nip99Classifieds/NewProductScreen.kt | 2 +- .../loggedIn/home/ShortNotePostScreen.kt | 4 +- .../loggedIn/home/ShortNotePostViewModel.kt | 5 +- .../publicMessages/NewPublicMessageScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 2 + 16 files changed, 609 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt new file mode 100644 index 000000000..439f04083 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt @@ -0,0 +1,534 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Movie +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import android.net.Uri +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLExt +import android.opengl.EGLSurface +import android.opengl.GLES20 +import android.opengl.GLUtils +import android.view.Surface +import androidx.core.net.toUri +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.util.UUID + +object GifToMp4Converter { + private const val LOG_TAG = "GifToMp4Converter" + private const val I_FRAME_INTERVAL = 1 + private const val TIMEOUT_US = 10_000L + private const val DEFAULT_BITRATE_BPS = 2_000_000 + private const val DEFAULT_FRAME_DELAY_MS = 100 + private const val US_PER_MS = 1000L + private const val NS_PER_US = 1000L + + // Fullscreen quad: 4 vertices x (2 position + 2 texcoord) floats + // Texcoord Y is flipped because bitmap origin is top-left, GL is bottom-left + private val QUAD_COORDS = + floatArrayOf( + -1f, -1f, 0f, 1f, + 1f, -1f, 1f, 1f, + -1f, 1f, 0f, 0f, + 1f, 1f, 1f, 0f, + ) + + private const val VERTEX_SHADER = + "attribute vec4 aPosition;\n" + + "attribute vec2 aTexCoord;\n" + + "varying vec2 vTexCoord;\n" + + "void main() {\n" + + " gl_Position = aPosition;\n" + + " vTexCoord = aTexCoord;\n" + + "}\n" + + private const val FRAGMENT_SHADER = + "precision mediump float;\n" + + "varying vec2 vTexCoord;\n" + + "uniform sampler2D uTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(uTexture, vTexCoord);\n" + + "}\n" + + suspend fun convert( + uri: Uri, + context: Context, + ): MediaCompressorResult? = + withContext(Dispatchers.IO) { + try { + convertInternal(uri, context) + } catch (e: Exception) { + Log.e(LOG_TAG, "GIF to MP4 conversion failed", e) + null + } + } + + @Suppress("deprecation") + private fun convertInternal( + uri: Uri, + context: Context, + ): MediaCompressorResult? { + val gifBytes = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: run { + Log.w(LOG_TAG) { "Failed to read GIF bytes" } + return null + } + + val movie = + Movie.decodeByteArray(gifBytes, 0, gifBytes.size) + ?: run { + Log.w(LOG_TAG) { "Failed to decode GIF" } + return null + } + + val gifWidth = movie.width() + val gifHeight = movie.height() + val durationMs = movie.duration() + + if (gifWidth <= 0 || gifHeight <= 0 || durationMs <= 0) { + Log.w(LOG_TAG) { "Invalid GIF dimensions ($gifWidth x $gifHeight) or duration ($durationMs ms)" } + return null + } + + val frameDelays = parseGifFrameDelays(gifBytes) + if (frameDelays.isEmpty()) { + Log.w(LOG_TAG) { "No frames found in GIF" } + return null + } + + val totalDelayMs = frameDelays.sum() + val avgFps = + if (totalDelayMs > 0) { + (frameDelays.size * 1000.0 / totalDelayMs).toInt().coerceIn(1, 50) + } else { + 10 + } + + val width = gifWidth.roundToEven() + val height = gifHeight.roundToEven() + + Log.d(LOG_TAG) { + "Converting GIF: ${gifWidth}x$gifHeight, duration=${durationMs}ms, " + + "frames=${frameDelays.size}, avgFps=$avgFps -> MP4 ${width}x$height" + } + + val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4") + var codec: MediaCodec? = null + var muxer: MediaMuxer? = null + var egl: EglHelper? = null + var codecSurface: Surface? = null + + try { + val mimeType = MediaFormat.MIMETYPE_VIDEO_AVC + val format = + MediaFormat.createVideoFormat(mimeType, width, height).apply { + setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) + setInteger(MediaFormat.KEY_BIT_RATE, calculateBitrate(width, height)) + setInteger(MediaFormat.KEY_FRAME_RATE, avgFps) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL) + } + + codec = MediaCodec.createEncoderByType(mimeType) + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + codecSurface = codec.createInputSurface() + codec.start() + + // Wrap the codec surface with EGL for presentation timestamp control + egl = EglHelper(codecSurface) + egl.makeCurrent() + + // Set up GL program and texture for drawing bitmaps + val program = createGlProgram() + val textureId = createGlTexture() + val vertexBuffer = createVertexBuffer() + + muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + var trackIndex = -1 + var muxerStarted = false + val bufferInfo = MediaCodec.BufferInfo() + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bitmapCanvas = Canvas(bitmap) + + var presentationTimeUs = 0L + var gifTimeMs = 0 + + for (i in frameDelays.indices) { + movie.setTime(gifTimeMs) + + bitmapCanvas.drawColor(Color.WHITE) + movie.draw(bitmapCanvas, 0f, 0f) + + // Draw bitmap to EGL surface via GL texture + drawBitmapFrame(bitmap, textureId, program, vertexBuffer, width, height) + + // Set the precise presentation timestamp and submit + egl.setPresentationTime(presentationTimeUs * NS_PER_US) + egl.swapBuffers() + + // Drain encoder output + val drainResult = drainEncoder(codec, muxer, bufferInfo, trackIndex, muxerStarted, false) + trackIndex = drainResult.first + muxerStarted = drainResult.second + + presentationTimeUs += frameDelays[i] * US_PER_MS + gifTimeMs += frameDelays[i] + } + + // Signal end of stream and drain + codec.signalEndOfInputStream() + drainEncoder(codec, muxer, bufferInfo, trackIndex, muxerStarted, true) + + // Cleanup GL resources + GLES20.glDeleteTextures(1, intArrayOf(textureId), 0) + GLES20.glDeleteProgram(program) + bitmap.recycle() + + Log.d(LOG_TAG) { "GIF to MP4 conversion complete: ${outputFile.length()} bytes" } + + return MediaCompressorResult(outputFile.toUri(), "video/mp4", outputFile.length()) + } catch (e: Exception) { + Log.e(LOG_TAG, "Encoding failed", e) + if (outputFile.exists()) outputFile.delete() + return null + } finally { + try { egl?.release() } catch (_: Exception) { } + try { codec?.stop() } catch (_: Exception) { } + try { codec?.release() } catch (_: Exception) { } + try { codecSurface?.release() } catch (_: Exception) { } + try { muxer?.stop() } catch (_: Exception) { } + try { muxer?.release() } catch (_: Exception) { } + } + } + + // region EGL + + private class EglHelper(surface: Surface) { + val display: EGLDisplay + val context: EGLContext + val eglSurface: EGLSurface + + init { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + check(display != EGL14.EGL_NO_DISPLAY) { "eglGetDisplay failed" } + + val version = IntArray(2) + check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" } + + val configAttribs = + intArrayOf( + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, + EGL14.EGL_NONE, + ) + val configs = arrayOfNulls(1) + val numConfigs = IntArray(1) + check(EGL14.eglChooseConfig(display, configAttribs, 0, configs, 0, 1, numConfigs, 0)) { + "eglChooseConfig failed" + } + + val contextAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE) + context = EGL14.eglCreateContext(display, configs[0]!!, EGL14.EGL_NO_CONTEXT, contextAttribs, 0) + check(context != EGL14.EGL_NO_CONTEXT) { "eglCreateContext failed" } + + val surfaceAttribs = intArrayOf(EGL14.EGL_NONE) + eglSurface = EGL14.eglCreateWindowSurface(display, configs[0]!!, surface, surfaceAttribs, 0) + check(eglSurface != EGL14.EGL_NO_SURFACE) { "eglCreateWindowSurface failed" } + } + + fun makeCurrent() { + check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" } + } + + fun setPresentationTime(nsecs: Long) { + EGLExt.eglPresentationTimeANDROID(display, eglSurface, nsecs) + } + + fun swapBuffers() { + EGL14.eglSwapBuffers(display, eglSurface) + } + + fun release() { + EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT) + EGL14.eglDestroySurface(display, eglSurface) + EGL14.eglDestroyContext(display, context) + EGL14.eglTerminate(display) + } + } + + // endregion + + // region GL helpers + + private fun createGlProgram(): Int { + val vs = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER) + val fs = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER) + val program = GLES20.glCreateProgram() + GLES20.glAttachShader(program, vs) + GLES20.glAttachShader(program, fs) + GLES20.glLinkProgram(program) + GLES20.glDeleteShader(vs) + GLES20.glDeleteShader(fs) + return program + } + + private fun compileShader( + type: Int, + source: String, + ): Int { + val shader = GLES20.glCreateShader(type) + GLES20.glShaderSource(shader, source) + GLES20.glCompileShader(shader) + return shader + } + + private fun createGlTexture(): Int { + val texIds = IntArray(1) + GLES20.glGenTextures(1, texIds, 0) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texIds[0]) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE) + return texIds[0] + } + + private fun createVertexBuffer(): FloatBuffer = + ByteBuffer + .allocateDirect(QUAD_COORDS.size * 4) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + .apply { + put(QUAD_COORDS) + position(0) + } + + private fun drawBitmapFrame( + bitmap: Bitmap, + textureId: Int, + program: Int, + vertexBuffer: FloatBuffer, + width: Int, + height: Int, + ) { + GLES20.glViewport(0, 0, width, height) + GLES20.glClearColor(1f, 1f, 1f, 1f) + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) + + GLES20.glUseProgram(program) + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId) + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0) + + val posHandle = GLES20.glGetAttribLocation(program, "aPosition") + val texHandle = GLES20.glGetAttribLocation(program, "aTexCoord") + + GLES20.glUniform1i(GLES20.glGetUniformLocation(program, "uTexture"), 0) + + // stride = 4 floats per vertex (2 pos + 2 tex) * 4 bytes + val stride = 4 * 4 + vertexBuffer.position(0) + GLES20.glEnableVertexAttribArray(posHandle) + GLES20.glVertexAttribPointer(posHandle, 2, GLES20.GL_FLOAT, false, stride, vertexBuffer) + + vertexBuffer.position(2) + GLES20.glEnableVertexAttribArray(texHandle) + GLES20.glVertexAttribPointer(texHandle, 2, GLES20.GL_FLOAT, false, stride, vertexBuffer) + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + + GLES20.glDisableVertexAttribArray(posHandle) + GLES20.glDisableVertexAttribArray(texHandle) + } + + // endregion + + // region GIF parsing + + /** + * Parses per-frame delays from the GIF binary by reading Graphic Control Extension blocks. + * Returns a list of delays in milliseconds, one per frame. + * + * GIF delay values are in centiseconds (1/100s). Per browser convention, + * delays of 0 or 1 centisecond are treated as 100ms (10fps). + */ + private fun parseGifFrameDelays(bytes: ByteArray): List { + if (bytes.size < 13) return emptyList() + + val delays = mutableListOf() + + var pos = 13 + + // Skip Global Color Table if present + val packed = bytes[10].toInt() and 0xFF + if (packed and 0x80 != 0) { + pos += 3 * (1 shl ((packed and 0x07) + 1)) + } + + while (pos < bytes.size) { + when (bytes[pos].toInt() and 0xFF) { + 0x21 -> { + pos++ + if (pos >= bytes.size) break + val label = bytes[pos].toInt() and 0xFF + pos++ + + if (label == 0xF9 && pos + 5 <= bytes.size) { + val blockSize = bytes[pos].toInt() and 0xFF + if (blockSize == 4) { + val delayLow = bytes[pos + 2].toInt() and 0xFF + val delayHigh = bytes[pos + 3].toInt() and 0xFF + val delayCentiseconds = delayLow or (delayHigh shl 8) + val delayMs = + if (delayCentiseconds <= 1) DEFAULT_FRAME_DELAY_MS else delayCentiseconds * 10 + delays.add(delayMs) + } + pos = skipSubBlocks(bytes, pos) + } else { + pos = skipSubBlocks(bytes, pos) + } + } + 0x2C -> { + pos += 10 + if (pos > bytes.size) break + val imgPacked = bytes[pos - 1].toInt() and 0xFF + if (imgPacked and 0x80 != 0) { + pos += 3 * (1 shl ((imgPacked and 0x07) + 1)) + } + pos++ + pos = skipSubBlocks(bytes, pos) + } + 0x3B -> break + else -> pos++ + } + } + + return delays + } + + private fun skipSubBlocks( + bytes: ByteArray, + startPos: Int, + ): Int { + var pos = startPos + while (pos < bytes.size) { + val blockSize = bytes[pos].toInt() and 0xFF + pos++ + if (blockSize == 0) break + pos += blockSize + } + return pos + } + + // endregion + + // region Encoder helpers + + private fun drainEncoder( + codec: MediaCodec, + muxer: MediaMuxer, + bufferInfo: MediaCodec.BufferInfo, + trackIndex: Int, + muxerStarted: Boolean, + endOfStream: Boolean, + ): Pair { + var currentTrackIndex = trackIndex + var currentMuxerStarted = muxerStarted + + while (true) { + val outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + + when { + outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + if (!endOfStream) return Pair(currentTrackIndex, currentMuxerStarted) + } + + outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + currentTrackIndex = muxer.addTrack(codec.outputFormat) + muxer.start() + currentMuxerStarted = true + } + + outputIndex >= 0 -> { + val outputBuffer = + codec.getOutputBuffer(outputIndex) + ?: throw RuntimeException("Encoder output buffer $outputIndex was null") + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + bufferInfo.size = 0 + } + + if (bufferInfo.size > 0 && currentMuxerStarted) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + muxer.writeSampleData(currentTrackIndex, outputBuffer, bufferInfo) + } + + codec.releaseOutputBuffer(outputIndex, false) + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + return Pair(currentTrackIndex, currentMuxerStarted) + } + } + } + } + } + + private fun calculateBitrate( + width: Int, + height: Int, + ): Int { + val pixels = width * height + return when { + pixels >= 1920 * 1080 -> 4_000_000 + pixels >= 1280 * 720 -> DEFAULT_BITRATE_BPS + pixels >= 640 * 480 -> 1_000_000 + else -> 500_000 + } + } + + private fun Int.roundToEven(): Int = if (this % 2 != 0) this + 1 else this + + // endregion +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 5cc488797..ace758a23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -47,15 +47,37 @@ class MediaCompressor { mediaQuality: CompressorQuality, applicationContext: Context, useH265: Boolean = false, + convertGifToMp4: Boolean = false, ): MediaCompressorResult { + checkNotInMainThread() + + // Convert GIF to MP4 if requested (before quality check, since this is a format conversion) + if (convertGifToMp4 && contentType?.contains("gif", ignoreCase = true) == true) { + Log.d("MediaCompressor") { "Converting GIF to MP4" } + val converted = GifToMp4Converter.convert(uri, applicationContext) + if (converted != null) { + // Optionally compress the resulting video + if (mediaQuality != CompressorQuality.UNCOMPRESSED) { + return VideoCompressionHelper.compressVideo( + converted.uri, + converted.contentType, + applicationContext, + mediaQuality, + useH265, + ) + } + return converted + } + Log.w("MediaCompressor") { "GIF to MP4 conversion failed, uploading as original GIF" } + return MediaCompressorResult(uri, contentType, null) + } + // Skip compression if user selected uncompressed if (mediaQuality == CompressorQuality.UNCOMPRESSED) { Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.") return MediaCompressorResult(uri, contentType, null) } - checkNotInMainThread() - // branch into compression based on content type return when { contentType?.startsWith("video", ignoreCase = true) == true -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 18467a353..b36cba1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -49,6 +49,8 @@ class MultiOrchestrator( fun hasVideo() = list.any { it.media.mimeType?.startsWith("video", ignoreCase = true) == true } + fun hasGif() = list.any { it.media.isGif() } + fun hasNonMedia() = list.any { it.media.isNotMedia() } suspend fun upload( @@ -61,6 +63,7 @@ class MultiOrchestrator( useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): Result { coroutineScope { val jobs = @@ -78,6 +81,7 @@ class MultiOrchestrator( useH265, stripMetadata, onStrippingFailed, + convertGifToMp4 = convertGifToMp4, ) } } @@ -99,6 +103,7 @@ class MultiOrchestrator( useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): Result { coroutineScope { val jobs = @@ -117,6 +122,7 @@ class MultiOrchestrator( useH265, stripMetadata, onStrippingFailed, + convertGifToMp4 = convertGifToMp4, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index e0e5ad0f9..192a7fe8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -287,9 +287,10 @@ class UploadOrchestrator { compressionQuality: CompressorQuality, context: Context, useH265: Boolean = false, - ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) { + convertGifToMp4: Boolean = false, + ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) { updateState(0.02, UploadingState.Compressing) - MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265) + MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4) } else { MediaCompressorResult(uri, mimeType, null) } @@ -358,8 +359,9 @@ class UploadOrchestrator { useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): UploadingFinalState { - val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265) + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) @@ -393,8 +395,9 @@ class UploadOrchestrator { useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): UploadingFinalState { - val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265) + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 29ba7a579..70f1a8065 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -267,7 +267,7 @@ fun EditPostView( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index fcae06fc3..74ebb0741 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -54,6 +54,8 @@ class SelectedMedia( ) { fun isImage() = mimeType?.startsWith("image") + fun isGif() = mimeType?.equals("image/gif", ignoreCase = true) == true + fun isVideo() = mimeType?.startsWith("video") fun isAudio() = mimeType?.startsWith("audio") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 725af41bb..4dc9bf4e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -79,7 +79,7 @@ fun ImageVideoDescription( uris: MultiOrchestrator, defaultServer: ServerName, isUploading: Boolean, - onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit, + onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean, Boolean) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, accountViewModel: AccountViewModel, @@ -110,6 +110,8 @@ fun ImageVideoDescription( var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) } + var convertGifToMp4 by remember { mutableStateOf(false) } + Column( modifier = Modifier @@ -333,6 +335,19 @@ fun ImageVideoDescription( ) } + if (uris.hasGif()) { + SettingSwitchItem( + title = R.string.convert_gif_to_mp4_label, + description = R.string.convert_gif_to_mp4_description, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + checked = convertGifToMp4, + onCheckedChange = { convertGifToMp4 = it }, + ) + } + Button( modifier = Modifier @@ -341,7 +356,7 @@ fun ImageVideoDescription( enabled = !isUploading, onClick = { val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata - onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata) + onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata, convertGifToMp4) }, shape = QuoteBorder, colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index e8d4bb98f..40c3cd99b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -332,7 +332,7 @@ private fun GenericCommentPostBody( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index a7a84109a..716be8b86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -289,7 +289,7 @@ fun GroupDMScreenContent( selectedFiles, accountViewModel.account.settings.defaultFileServer, isUploading = uploading.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, _ -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, _, _ -> postViewModel.uploadAndHold( accountViewModel.toastManager::toast, context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index f72e7a1c4..e82d4c348 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -472,8 +472,8 @@ private fun MarkdownPostScreenBody( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, uploadContext, useH265, stripMetadata) + onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata, convertGifToMp4 -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, uploadContext, useH265, stripMetadata, convertGifToMp4) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 26e2e7918..6e484ace4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -489,8 +489,9 @@ class LongFormPostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) = try { - uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata) + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4) } catch (_: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -507,6 +508,7 @@ class LongFormPostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch @@ -524,6 +526,7 @@ class LongFormPostViewModel : useH265, stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + convertGifToMp4 = convertGifToMp4, ) if (results.allGood) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index e0c13365e..6f4fd20c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -274,7 +274,7 @@ private fun NewProductBody( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index eed62305a..d5ec254e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -430,8 +430,8 @@ private fun NewPostScreenBody( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata) + onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata, convertGifToMp4 -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata, convertGifToMp4) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 6adf94284..4f45ecc84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -958,8 +958,9 @@ open class ShortNotePostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) = try { - uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata) + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4) } catch (_: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -976,6 +977,7 @@ open class ShortNotePostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch @@ -993,6 +995,7 @@ open class ShortNotePostViewModel : useH265, stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + convertGifToMp4 = convertGifToMp4, ) if (results.allGood) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index ada38e5e5..1519ac398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -249,7 +249,7 @@ fun PublicMessageScreenContent( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 55325cc71..a11f96248 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1331,6 +1331,8 @@ Uncompressed Use H.265/HEVC Codec Better quality at smaller file sizes but not all devices support H.265 playback. + Convert GIF to MP4 + Converts animated GIF to MP4 video for smaller file size and better playback compatibility. Remove private metadata Attempts to strip private metadata from supported media files before uploading From 613237bdb989cbce2bb22dc4d2c9b1ba387a4920 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 06:30:50 +0000 Subject: [PATCH 2/5] fix: hide quality slider and skip re-compression for GIF-to-MP4 The GIF converter already produces a well-compressed H.264 MP4, so re-compressing via VideoCompressionHelper is wasteful and can inflate file size. Hide the quality slider in the upload UI when GIF-to-MP4 is enabled and skip the video compression step in MediaCompressor. https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w --- .../amethyst/service/uploads/MediaCompressor.kt | 13 ++----------- .../note/creators/uploads/ImageVideoDescription.kt | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index ace758a23..f899c583c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -51,21 +51,12 @@ class MediaCompressor { ): MediaCompressorResult { checkNotInMainThread() - // Convert GIF to MP4 if requested (before quality check, since this is a format conversion) + // Convert GIF to MP4 if requested. The GIF converter already produces a well-compressed + // H.264 MP4 so no additional video compression step is needed. if (convertGifToMp4 && contentType?.contains("gif", ignoreCase = true) == true) { Log.d("MediaCompressor") { "Converting GIF to MP4" } val converted = GifToMp4Converter.convert(uri, applicationContext) if (converted != null) { - // Optionally compress the resulting video - if (mediaQuality != CompressorQuality.UNCOMPRESSED) { - return VideoCompressionHelper.compressVideo( - converted.uri, - converted.contentType, - applicationContext, - mediaQuality, - useH265, - ) - } return converted } Log.w("MediaCompressor") { "GIF to MP4 conversion failed, uploading as original GIF" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 4dc9bf4e4..5b9960697 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -271,7 +271,7 @@ fun ImageVideoDescription( val firstMedia = uris.first().media - if (firstMedia.isVideo() == true || firstMedia.isImage() == true) { + if ((firstMedia.isVideo() == true || firstMedia.isImage() == true) && !convertGifToMp4) { Row( verticalAlignment = Alignment.CenterVertically, modifier = From e4c12ff5ad2e990ec241207688bafd0f4cf3d941 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 9 Apr 2026 09:00:01 +0200 Subject: [PATCH 3/5] fix: harden GIF-to-MP4 converter after code review Address issues found in Kotlin code review: HIGH - Rethrow CancellationException in convert() to preserve structured concurrency; prior broad catch silently swallowed cancellation. - Cap drainEncoder's post-EOS loop at 500 iterations to prevent an unresponsive hardware encoder from hanging the IO thread forever. MEDIUM - Read GIF with a 20 MB size cap via bounded buffer to avoid OOM on malformed or adversarial inputs. - Check glCompileShader / glLinkProgram status and throw with glGetShaderInfoLog / glGetProgramInfoLog on failure, instead of silently producing blank frames on driver errors. - Verify numConfigs[0] > 0 after eglChooseConfig and use requireNotNull on configs[0] with a clear message (avoids NPE from !!). - Fix Image Descriptor (0x2C) parsing: precheck pos + 10 <= bytes.size and read the packed byte at the correct offset; bounds-check after Local Color Table skip. - Use uris.hasVideo() instead of uris.first().media.isVideo() for the privacy-toggle visibility so mixed-media selections (image + video) hide the toggle correctly. LOW - skipSubBlocks now clamps pos with minOf(pos + blockSize, bytes.size) to preserve the invariant that pos is always a valid index. - Document the function-level @Suppress("deprecation") on convertInternal (android.graphics.Movie has no modern replacement). Co-Authored-By: Claude Opus 4.6 --- .../service/uploads/GifToMp4Converter.kt | 143 ++++++++++++++---- .../creators/uploads/ImageVideoDescription.kt | 5 +- 2 files changed, 117 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt index 439f04083..378f60889 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt @@ -41,6 +41,7 @@ import android.opengl.GLUtils import android.view.Surface import androidx.core.net.toUri import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -57,15 +58,29 @@ object GifToMp4Converter { private const val DEFAULT_FRAME_DELAY_MS = 100 private const val US_PER_MS = 1000L private const val NS_PER_US = 1000L + private const val MAX_GIF_SIZE_BYTES = 20 * 1024 * 1024 + private const val DRAIN_EOS_MAX_ITERATIONS = 500 // Fullscreen quad: 4 vertices x (2 position + 2 texcoord) floats // Texcoord Y is flipped because bitmap origin is top-left, GL is bottom-left private val QUAD_COORDS = floatArrayOf( - -1f, -1f, 0f, 1f, - 1f, -1f, 1f, 1f, - -1f, 1f, 0f, 0f, - 1f, 1f, 1f, 0f, + -1f, + -1f, + 0f, + 1f, + 1f, + -1f, + 1f, + 1f, + -1f, + 1f, + 0f, + 0f, + 1f, + 1f, + 1f, + 0f, ) private const val VERTEX_SHADER = @@ -92,23 +107,37 @@ object GifToMp4Converter { withContext(Dispatchers.IO) { try { convertInternal(uri, context) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(LOG_TAG, "GIF to MP4 conversion failed", e) null } } - @Suppress("deprecation") + @Suppress("deprecation") // android.graphics.Movie is deprecated but still the simplest GIF decoder available private fun convertInternal( uri: Uri, context: Context, ): MediaCompressorResult? { val gifBytes = - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: run { - Log.w(LOG_TAG) { "Failed to read GIF bytes" } + context.contentResolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(MAX_GIF_SIZE_BYTES + 1) + var total = 0 + while (total <= MAX_GIF_SIZE_BYTES) { + val read = input.read(buffer, total, buffer.size - total) + if (read == -1) break + total += read + } + if (total > MAX_GIF_SIZE_BYTES) { + Log.w(LOG_TAG) { "GIF exceeds max size of $MAX_GIF_SIZE_BYTES bytes" } return null } + buffer.copyOf(total) + } ?: run { + Log.w(LOG_TAG) { "Failed to read GIF bytes" } + return null + } val movie = Movie.decodeByteArray(gifBytes, 0, gifBytes.size) @@ -228,18 +257,38 @@ object GifToMp4Converter { if (outputFile.exists()) outputFile.delete() return null } finally { - try { egl?.release() } catch (_: Exception) { } - try { codec?.stop() } catch (_: Exception) { } - try { codec?.release() } catch (_: Exception) { } - try { codecSurface?.release() } catch (_: Exception) { } - try { muxer?.stop() } catch (_: Exception) { } - try { muxer?.release() } catch (_: Exception) { } + try { + egl?.release() + } catch (_: Exception) { + } + try { + codec?.stop() + } catch (_: Exception) { + } + try { + codec?.release() + } catch (_: Exception) { + } + try { + codecSurface?.release() + } catch (_: Exception) { + } + try { + muxer?.stop() + } catch (_: Exception) { + } + try { + muxer?.release() + } catch (_: Exception) { + } } } // region EGL - private class EglHelper(surface: Surface) { + private class EglHelper( + surface: Surface, + ) { val display: EGLDisplay val context: EGLContext val eglSurface: EGLSurface @@ -253,12 +302,18 @@ object GifToMp4Converter { val configAttribs = intArrayOf( - EGL14.EGL_RED_SIZE, 8, - EGL14.EGL_GREEN_SIZE, 8, - EGL14.EGL_BLUE_SIZE, 8, - EGL14.EGL_ALPHA_SIZE, 8, - EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, - EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, + EGL14.EGL_RED_SIZE, + 8, + EGL14.EGL_GREEN_SIZE, + 8, + EGL14.EGL_BLUE_SIZE, + 8, + EGL14.EGL_ALPHA_SIZE, + 8, + EGL14.EGL_RENDERABLE_TYPE, + EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_SURFACE_TYPE, + EGL14.EGL_WINDOW_BIT, EGL14.EGL_NONE, ) val configs = arrayOfNulls(1) @@ -266,13 +321,15 @@ object GifToMp4Converter { check(EGL14.eglChooseConfig(display, configAttribs, 0, configs, 0, 1, numConfigs, 0)) { "eglChooseConfig failed" } + check(numConfigs[0] > 0) { "eglChooseConfig returned no matching configs" } + val config = requireNotNull(configs[0]) { "eglChooseConfig returned null config" } val contextAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE) - context = EGL14.eglCreateContext(display, configs[0]!!, EGL14.EGL_NO_CONTEXT, contextAttribs, 0) + context = EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, contextAttribs, 0) check(context != EGL14.EGL_NO_CONTEXT) { "eglCreateContext failed" } val surfaceAttribs = intArrayOf(EGL14.EGL_NONE) - eglSurface = EGL14.eglCreateWindowSurface(display, configs[0]!!, surface, surfaceAttribs, 0) + eglSurface = EGL14.eglCreateWindowSurface(display, config, surface, surfaceAttribs, 0) check(eglSurface != EGL14.EGL_NO_SURFACE) { "eglCreateWindowSurface failed" } } @@ -304,9 +361,17 @@ object GifToMp4Converter { val vs = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER) val fs = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER) val program = GLES20.glCreateProgram() + check(program != 0) { "glCreateProgram failed" } GLES20.glAttachShader(program, vs) GLES20.glAttachShader(program, fs) GLES20.glLinkProgram(program) + val linkStatus = IntArray(1) + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0) + if (linkStatus[0] != GLES20.GL_TRUE) { + val info = GLES20.glGetProgramInfoLog(program) + GLES20.glDeleteProgram(program) + throw RuntimeException("glLinkProgram failed: $info") + } GLES20.glDeleteShader(vs) GLES20.glDeleteShader(fs) return program @@ -317,8 +382,16 @@ object GifToMp4Converter { source: String, ): Int { val shader = GLES20.glCreateShader(type) + check(shader != 0) { "glCreateShader failed for type $type" } GLES20.glShaderSource(shader, source) GLES20.glCompileShader(shader) + val compileStatus = IntArray(1) + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0) + if (compileStatus[0] != GLES20.GL_TRUE) { + val info = GLES20.glGetShaderInfoLog(shader) + GLES20.glDeleteShader(shader) + throw RuntimeException("glCompileShader failed for type $type: $info") + } return shader } @@ -429,18 +502,28 @@ object GifToMp4Converter { pos = skipSubBlocks(bytes, pos) } } + 0x2C -> { + // Image Descriptor: 1 (separator, current pos) + 8 (L/T/W/H) + 1 (packed) = 10 bytes + if (pos + 10 > bytes.size) break + val imgPacked = bytes[pos + 9].toInt() and 0xFF pos += 10 - if (pos > bytes.size) break - val imgPacked = bytes[pos - 1].toInt() and 0xFF if (imgPacked and 0x80 != 0) { pos += 3 * (1 shl ((imgPacked and 0x07) + 1)) + if (pos >= bytes.size) break } pos++ + if (pos >= bytes.size) break pos = skipSubBlocks(bytes, pos) } - 0x3B -> break - else -> pos++ + + 0x3B -> { + break + } + + else -> { + pos++ + } } } @@ -456,7 +539,7 @@ object GifToMp4Converter { val blockSize = bytes[pos].toInt() and 0xFF pos++ if (blockSize == 0) break - pos += blockSize + pos = minOf(pos + blockSize, bytes.size) } return pos } @@ -475,6 +558,7 @@ object GifToMp4Converter { ): Pair { var currentTrackIndex = trackIndex var currentMuxerStarted = muxerStarted + var eosDrainIterations = 0 while (true) { val outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) @@ -482,6 +566,9 @@ object GifToMp4Converter { when { outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { if (!endOfStream) return Pair(currentTrackIndex, currentMuxerStarted) + if (++eosDrainIterations >= DRAIN_EOS_MAX_ITERATIONS) { + throw RuntimeException("Encoder failed to drain after EOS within ${DRAIN_EOS_MAX_ITERATIONS} iterations") + } } outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 5b9960697..4e92df39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -252,9 +252,8 @@ fun ImageVideoDescription( ) } - // Hide privacy toggle when video compression is selected (compression already strips metadata) - val isVideoWithCompression = - uris.first().media.isVideo() == true && mediaQualitySlider != 3 + // Hide privacy toggle when any selected video will be compressed (compression already strips metadata) + val isVideoWithCompression = uris.hasVideo() && mediaQualitySlider != 3 if (!isVideoWithCompression) { SettingSwitchItem( From 564fedcac8549a3a59a0f4c3c1906a478278cac7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 9 Apr 2026 09:04:37 +0200 Subject: [PATCH 4/5] test: add unit tests for GIF frame-delay parser bounds checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the bounds-checking fixes from the previous commit with 13 pure-JVM unit tests (no Android dependencies, no Robolectric): - Image Descriptor (0x2C) packed-byte offset and length precheck - skipSubBlocks clamp against oversized block lengths - Local and Global Color Table skipping - Delay normalization (0 and 1 centisecond → 100 ms) - Multi-frame parsing with variable delays - Non-GCE extension blocks (Application Extension) skipped safely - Truncated inputs do not crash parseGifFrameDelays is exposed as `internal` with @VisibleForTesting(otherwise = PRIVATE) so production callers still see it as private. Co-Authored-By: Claude Opus 4.6 --- .../service/uploads/GifToMp4Converter.kt | 4 +- .../service/uploads/GifToMp4ConverterTest.kt | 293 ++++++++++++++++++ 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4ConverterTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt index 378f60889..6edbab0b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt @@ -39,6 +39,7 @@ import android.opengl.EGLSurface import android.opengl.GLES20 import android.opengl.GLUtils import android.view.Surface +import androidx.annotation.VisibleForTesting import androidx.core.net.toUri import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -466,7 +467,8 @@ object GifToMp4Converter { * GIF delay values are in centiseconds (1/100s). Per browser convention, * delays of 0 or 1 centisecond are treated as 100ms (10fps). */ - private fun parseGifFrameDelays(bytes: ByteArray): List { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun parseGifFrameDelays(bytes: ByteArray): List { if (bytes.size < 13) return emptyList() val delays = mutableListOf() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4ConverterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4ConverterTest.kt new file mode 100644 index 000000000..886fb0366 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4ConverterTest.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream + +/** + * Unit tests for the pure-Kotlin GIF binary parser in [GifToMp4Converter]. + * + * These tests exercise the bounds-checking fixes from the code review: + * - Image Descriptor (0x2C) block: corrected packed-byte offset and length precheck + * - skipSubBlocks: clamp to bytes.size to keep pos as a valid index + * + * The tests construct minimal synthetic GIF byte streams rather than decoding + * real images, so they run as plain JVM tests with no Android dependencies. + */ +class GifToMp4ConverterTest { + // --- Helpers to build synthetic GIF byte streams --- + + private fun ByteArrayOutputStream.writeHeader( + width: Int = 1, + height: Int = 1, + gctBits: Int = 0, + ) { + // "GIF89a" + write("GIF89a".toByteArray(Charsets.US_ASCII)) + // Logical Screen Descriptor + write(width and 0xFF) + write((width shr 8) and 0xFF) + write(height and 0xFF) + write((height shr 8) and 0xFF) + // packed: bit 7 = has GCT, bits 0-2 = GCT size bits + val packed = if (gctBits > 0) 0x80 or (gctBits - 1) else 0x00 + write(packed) + write(0) // bg color index + write(0) // pixel aspect ratio + if (gctBits > 0) { + repeat(3 * (1 shl gctBits)) { write(0) } + } + } + + private fun ByteArrayOutputStream.writeGce(delayCentiseconds: Int) { + write(0x21) + write(0xF9) + write(0x04) // block size + write(0x00) // packed (disposal/transparent) + write(delayCentiseconds and 0xFF) + write((delayCentiseconds shr 8) and 0xFF) + write(0x00) // transparent color index + write(0x00) // block terminator + } + + private fun ByteArrayOutputStream.writeImageDescriptor( + left: Int = 0, + top: Int = 0, + width: Int = 1, + height: Int = 1, + lctBits: Int = 0, + ) { + write(0x2C) + write(left and 0xFF) + write((left shr 8) and 0xFF) + write(top and 0xFF) + write((top shr 8) and 0xFF) + write(width and 0xFF) + write((width shr 8) and 0xFF) + write(height and 0xFF) + write((height shr 8) and 0xFF) + val packed = if (lctBits > 0) 0x80 or (lctBits - 1) else 0x00 + write(packed) + if (lctBits > 0) { + repeat(3 * (1 shl lctBits)) { write(0) } + } + } + + private fun ByteArrayOutputStream.writeMinimalLzwData() { + write(0x02) // LZW minimum code size + write(0x01) // sub-block size = 1 + write(0x00) // one byte of (meaningless) data + write(0x00) // sub-block terminator + } + + private fun ByteArrayOutputStream.writeTrailer() { + write(0x3B) + } + + private fun buildSingleFrameGif(delayCentiseconds: Int): ByteArray = + ByteArrayOutputStream() + .apply { + writeHeader() + writeGce(delayCentiseconds) + writeImageDescriptor() + writeMinimalLzwData() + writeTrailer() + }.toByteArray() + + // --- Tests --- + + @Test + fun `empty bytes return empty delay list`() { + assertEquals(emptyList(), GifToMp4Converter.parseGifFrameDelays(ByteArray(0))) + } + + @Test + fun `bytes shorter than header return empty delay list`() { + assertEquals(emptyList(), GifToMp4Converter.parseGifFrameDelays(ByteArray(12))) + } + + @Test + fun `single frame with 10 centisecond delay yields 100 ms`() { + val gif = buildSingleFrameGif(delayCentiseconds = 10) + assertEquals(listOf(100), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `single frame with 20 centisecond delay yields 200 ms`() { + val gif = buildSingleFrameGif(delayCentiseconds = 20) + assertEquals(listOf(200), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `delay of 0 centiseconds is normalized to 100 ms`() { + val gif = buildSingleFrameGif(delayCentiseconds = 0) + assertEquals(listOf(100), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `delay of 1 centisecond is normalized to 100 ms`() { + val gif = buildSingleFrameGif(delayCentiseconds = 1) + assertEquals(listOf(100), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `multiple frames with different delays are all parsed`() { + val gif = + ByteArrayOutputStream() + .apply { + writeHeader() + writeGce(5) + writeImageDescriptor() + writeMinimalLzwData() + writeGce(20) + writeImageDescriptor() + writeMinimalLzwData() + writeGce(7) + writeImageDescriptor() + writeMinimalLzwData() + writeTrailer() + }.toByteArray() + + assertEquals(listOf(50, 200, 70), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `Image Descriptor with Local Color Table is parsed correctly`() { + // This exercises Fix #6: the packed byte must be read at offset 9 from + // the 0x2C separator, not from (pos - 1) after pos is already advanced. + val gif = + ByteArrayOutputStream() + .apply { + writeHeader() + writeGce(15) + writeImageDescriptor(lctBits = 3) // 2^(3+1) = 16 entries, 48-byte LCT + writeMinimalLzwData() + writeTrailer() + }.toByteArray() + + assertEquals(listOf(150), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `Global Color Table is skipped correctly`() { + val gif = + ByteArrayOutputStream() + .apply { + writeHeader(gctBits = 2) // 2^(2+1) = 8 entries, 24-byte GCT + writeGce(12) + writeImageDescriptor() + writeMinimalLzwData() + writeTrailer() + }.toByteArray() + + assertEquals(listOf(120), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `truncated GIF at Image Descriptor does not crash`() { + // Fix #6: `if (pos + 10 > bytes.size) break` must prevent the packed-byte read + // from falling off the end. This constructs a GCE followed by a 0x2C separator + // with only a few trailing bytes — less than the 10-byte descriptor. + val gif = + ByteArrayOutputStream() + .apply { + writeHeader() + writeGce(10) + write(0x2C) + // Only 5 bytes of descriptor content, not the full 10 + write(0) + write(0) + write(0) + write(0) + write(0) + }.toByteArray() + + // Should return the GCE delay without crashing; the truncated descriptor is skipped. + val delays = GifToMp4Converter.parseGifFrameDelays(gif) + assertEquals(listOf(100), delays) + } + + @Test + fun `truncated GIF with oversized sub-block length does not crash`() { + // Fix #9: skipSubBlocks clamps pos to bytes.size so an adversarial sub-block + // length that reaches past end-of-buffer doesn't leave pos in an invalid state. + val gif = + ByteArrayOutputStream() + .apply { + writeHeader() + writeGce(10) + writeImageDescriptor() + write(0x02) // LZW min code size + write(0xFF) // sub-block length claiming 255 bytes of data + // ... but we only write 3 bytes, then abruptly end + write(0x00) + write(0x00) + write(0x00) + }.toByteArray() + + // Must not throw ArrayIndexOutOfBoundsException + val delays = GifToMp4Converter.parseGifFrameDelays(gif) + assertEquals(listOf(100), delays) + } + + @Test + fun `GIF with only trailer after header returns empty`() { + val gif = + ByteArrayOutputStream() + .apply { + writeHeader() + writeTrailer() + }.toByteArray() + + assertEquals(emptyList(), GifToMp4Converter.parseGifFrameDelays(gif)) + } + + @Test + fun `non-GCE extension blocks are skipped without adding a delay`() { + // An Application Extension (label 0xFF) should be walked over via skipSubBlocks + // and produce no delay entry. The subsequent GCE's delay should still be read. + val gif = + ByteArrayOutputStream() + .apply { + writeHeader() + // Application Extension + write(0x21) + write(0xFF) + write(0x0B) // block size = 11 for NETSCAPE2.0 + write("NETSCAPE2.0".toByteArray(Charsets.US_ASCII)) + write(0x03) // sub-block size + write(0x01) + write(0x00) + write(0x00) + write(0x00) // sub-block terminator + writeGce(8) + writeImageDescriptor() + writeMinimalLzwData() + writeTrailer() + }.toByteArray() + + val delays = GifToMp4Converter.parseGifFrameDelays(gif) + assertTrue("Expected delay list to contain 80 ms, got $delays", delays.contains(80)) + } +} From 58c30901e84572314513f00a16e1b07befd5ca0b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 9 Apr 2026 09:21:30 +0200 Subject: [PATCH 5/5] perf: run GIF-to-MP4 conversion on Dispatchers.Default The conversion pipeline is overwhelmingly CPU/GPU bound (Movie decode, GL rendering, MediaCodec encode) and can run for several seconds on a large GIF. Running it on Dispatchers.IO occupies a thread from the large IO pool with no kernel wait, which can starve legitimate IO coroutines when multiple uploads run in parallel. Switching to Dispatchers.Default caps concurrent conversions to the CPU count, which is also desirable given the hardware encoder contention that multiple simultaneous MediaCodec instances would cause. convertInternal remains a plain (non-suspending) function, so EGL thread-affinity is still preserved for the lifetime of the call. Co-Authored-By: Claude Opus 4.6 --- .../amethyst/service/uploads/GifToMp4Converter.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt index 6edbab0b7..d523ebea9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt @@ -105,7 +105,13 @@ object GifToMp4Converter { uri: Uri, context: Context, ): MediaCompressorResult? = - withContext(Dispatchers.IO) { + // Dispatchers.Default: the bulk of this work is CPU/GPU bound + // (Movie decode, GL rendering, MediaCodec encode). Running on IO + // would occupy a thread from the large IO pool for several seconds + // with no kernel wait, risking starvation of real IO coroutines. + // The brief file read at the start and muxer writes are acceptable + // on Default — they're short relative to the encoding loop. + withContext(Dispatchers.Default) { try { convertInternal(uri, context) } catch (e: CancellationException) {