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 deleted file mode 100644 index d523ebea9d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt +++ /dev/null @@ -1,629 +0,0 @@ -/* - * 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 f899c583ce..1a00ad9282 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 @@ -25,6 +25,7 @@ import android.graphics.Bitmap import android.net.Uri import androidx.core.net.toUri import androidx.media3.common.MimeTypes +import com.abedelazizshe.lightcompressorlibrary.video.GifToMp4Converter import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils import com.vitorpamplona.quartz.utils.Log @@ -57,7 +58,7 @@ class MediaCompressor { Log.d("MediaCompressor") { "Converting GIF to MP4" } val converted = GifToMp4Converter.convert(uri, applicationContext) if (converted != null) { - return converted + return MediaCompressorResult(converted.file.toUri(), converted.mimeType, converted.size) } Log.w("MediaCompressor") { "GIF to MP4 conversion failed, uploading as original GIF" } return MediaCompressorResult(uri, contentType, null) 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 deleted file mode 100644 index 886fb03666..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4ConverterTest.kt +++ /dev/null @@ -1,293 +0,0 @@ -/* - * 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)) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a1da868aec..3a08ed6796 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" -lightcompressor-enhanced = "1.8.3" +lightcompressor-enhanced = "1.9.0" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3"