Merge pull request #2189 from davotoula/claude/gif-to-mp4-conversion-g1cve
Animated gif to mp4 conversion
This commit is contained in:
+629
@@ -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
|
||||
}
|
||||
+15
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -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)
|
||||
},
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+20
-6
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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)
|
||||
},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
},
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+293
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user