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..d523ebea9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt @@ -0,0 +1,629 @@ +/* + * 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.annotation.VisibleForTesting +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 +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 + 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, + ) + + 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? = + // 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) { + throw e + } catch (e: Exception) { + Log.e(LOG_TAG, "GIF to MP4 conversion failed", e) + null + } + } + + @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 { 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) + ?: 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" + } + 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, 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, config, 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() + 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 + } + + private fun compileShader( + type: Int, + 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 + } + + 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). + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal 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 -> { + // 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 (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++ + } + } + } + + 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 = minOf(pos + blockSize, bytes.size) + } + 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 + var eosDrainIterations = 0 + + while (true) { + val outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + + 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 -> { + 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..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 @@ -47,15 +47,28 @@ class MediaCompressor { mediaQuality: CompressorQuality, applicationContext: Context, useH265: Boolean = false, + convertGifToMp4: Boolean = false, ): MediaCompressorResult { + checkNotInMainThread() + + // 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) { + 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..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 @@ -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 @@ -250,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( @@ -269,7 +270,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 = @@ -333,6 +334,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 +355,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 4816efd86..58aa3fd82 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 @@ -435,8 +435,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 d3c960e10..8bfb0e0af 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 @@ -1061,8 +1061,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), @@ -1079,6 +1080,7 @@ open class ShortNotePostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch @@ -1096,6 +1098,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 d081a5c84..5f0ddb56e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1332,6 +1332,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 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)) + } +}