Merge pull request #2189 from davotoula/claude/gif-to-mp4-conversion-g1cve

Animated gif to mp4 conversion
This commit is contained in:
Vitor Pamplona
2026-04-09 08:54:39 -04:00
committed by GitHub
17 changed files with 991 additions and 23 deletions
@@ -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<EGLConfig>(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<Int> {
if (bytes.size < 13) return emptyList()
val delays = mutableListOf<Int>()
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<Int, Boolean> {
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
}
@@ -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 -> {
@@ -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,
)
}
}
@@ -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)
@@ -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)
},
@@ -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")
@@ -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 =
@@ -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)
},
@@ -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,
@@ -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,
@@ -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) {
@@ -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)
},
@@ -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,
@@ -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) {
@@ -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)
},
+2
View File
@@ -1332,6 +1332,8 @@
<string name="media_compression_quality_uncompressed">Uncompressed</string>
<string name="video_codec_h265_label">Use H.265/HEVC Codec</string>
<string name="video_codec_h265_description">Better quality at smaller file sizes but not all devices support H.265 playback.</string>
<string name="convert_gif_to_mp4_label">Convert GIF to MP4</string>
<string name="convert_gif_to_mp4_description">Converts animated GIF to MP4 video for smaller file size and better playback compatibility.</string>
<string name="strip_metadata_label">Remove private metadata</string>
<string name="strip_metadata_description">Attempts to strip private metadata from supported media files before uploading</string>
@@ -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<Int>(), GifToMp4Converter.parseGifFrameDelays(ByteArray(0)))
}
@Test
fun `bytes shorter than header return empty delay list`() {
assertEquals(emptyList<Int>(), 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<Int>(), 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))
}
}