feat: add GIF to MP4 conversion option in upload screen

When a GIF is selected for upload, a new "Convert GIF to MP4" switch
appears in the upload settings dialog. This converts animated GIFs to
MP4 video using Android's Movie decoder and MediaCodec/MediaMuxer
encoder, resulting in smaller file sizes and better playback
compatibility. The converted video can optionally be further compressed
using the existing video compression pipeline.

https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w
This commit is contained in:
Claude
2026-04-08 21:10:07 +00:00
committed by davotoula
parent a1c7d956ff
commit a9978277a3
16 changed files with 609 additions and 19 deletions
@@ -0,0 +1,534 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Movie
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES20
import android.opengl.GLUtils
import android.view.Surface
import androidx.core.net.toUri
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import java.util.UUID
object GifToMp4Converter {
private const val LOG_TAG = "GifToMp4Converter"
private const val I_FRAME_INTERVAL = 1
private const val TIMEOUT_US = 10_000L
private const val DEFAULT_BITRATE_BPS = 2_000_000
private const val DEFAULT_FRAME_DELAY_MS = 100
private const val US_PER_MS = 1000L
private const val NS_PER_US = 1000L
// Fullscreen quad: 4 vertices x (2 position + 2 texcoord) floats
// Texcoord Y is flipped because bitmap origin is top-left, GL is bottom-left
private val QUAD_COORDS =
floatArrayOf(
-1f, -1f, 0f, 1f,
1f, -1f, 1f, 1f,
-1f, 1f, 0f, 0f,
1f, 1f, 1f, 0f,
)
private const val VERTEX_SHADER =
"attribute vec4 aPosition;\n" +
"attribute vec2 aTexCoord;\n" +
"varying vec2 vTexCoord;\n" +
"void main() {\n" +
" gl_Position = aPosition;\n" +
" vTexCoord = aTexCoord;\n" +
"}\n"
private const val FRAGMENT_SHADER =
"precision mediump float;\n" +
"varying vec2 vTexCoord;\n" +
"uniform sampler2D uTexture;\n" +
"void main() {\n" +
" gl_FragColor = texture2D(uTexture, vTexCoord);\n" +
"}\n"
suspend fun convert(
uri: Uri,
context: Context,
): MediaCompressorResult? =
withContext(Dispatchers.IO) {
try {
convertInternal(uri, context)
} catch (e: Exception) {
Log.e(LOG_TAG, "GIF to MP4 conversion failed", e)
null
}
}
@Suppress("deprecation")
private fun convertInternal(
uri: Uri,
context: Context,
): MediaCompressorResult? {
val gifBytes =
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: run {
Log.w(LOG_TAG) { "Failed to read GIF bytes" }
return null
}
val movie =
Movie.decodeByteArray(gifBytes, 0, gifBytes.size)
?: run {
Log.w(LOG_TAG) { "Failed to decode GIF" }
return null
}
val gifWidth = movie.width()
val gifHeight = movie.height()
val durationMs = movie.duration()
if (gifWidth <= 0 || gifHeight <= 0 || durationMs <= 0) {
Log.w(LOG_TAG) { "Invalid GIF dimensions ($gifWidth x $gifHeight) or duration ($durationMs ms)" }
return null
}
val frameDelays = parseGifFrameDelays(gifBytes)
if (frameDelays.isEmpty()) {
Log.w(LOG_TAG) { "No frames found in GIF" }
return null
}
val totalDelayMs = frameDelays.sum()
val avgFps =
if (totalDelayMs > 0) {
(frameDelays.size * 1000.0 / totalDelayMs).toInt().coerceIn(1, 50)
} else {
10
}
val width = gifWidth.roundToEven()
val height = gifHeight.roundToEven()
Log.d(LOG_TAG) {
"Converting GIF: ${gifWidth}x$gifHeight, duration=${durationMs}ms, " +
"frames=${frameDelays.size}, avgFps=$avgFps -> MP4 ${width}x$height"
}
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
var codec: MediaCodec? = null
var muxer: MediaMuxer? = null
var egl: EglHelper? = null
var codecSurface: Surface? = null
try {
val mimeType = MediaFormat.MIMETYPE_VIDEO_AVC
val format =
MediaFormat.createVideoFormat(mimeType, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, calculateBitrate(width, height))
setInteger(MediaFormat.KEY_FRAME_RATE, avgFps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
}
codec = MediaCodec.createEncoderByType(mimeType)
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codecSurface = codec.createInputSurface()
codec.start()
// Wrap the codec surface with EGL for presentation timestamp control
egl = EglHelper(codecSurface)
egl.makeCurrent()
// Set up GL program and texture for drawing bitmaps
val program = createGlProgram()
val textureId = createGlTexture()
val vertexBuffer = createVertexBuffer()
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var trackIndex = -1
var muxerStarted = false
val bufferInfo = MediaCodec.BufferInfo()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmapCanvas = Canvas(bitmap)
var presentationTimeUs = 0L
var gifTimeMs = 0
for (i in frameDelays.indices) {
movie.setTime(gifTimeMs)
bitmapCanvas.drawColor(Color.WHITE)
movie.draw(bitmapCanvas, 0f, 0f)
// Draw bitmap to EGL surface via GL texture
drawBitmapFrame(bitmap, textureId, program, vertexBuffer, width, height)
// Set the precise presentation timestamp and submit
egl.setPresentationTime(presentationTimeUs * NS_PER_US)
egl.swapBuffers()
// Drain encoder output
val drainResult = drainEncoder(codec, muxer, bufferInfo, trackIndex, muxerStarted, false)
trackIndex = drainResult.first
muxerStarted = drainResult.second
presentationTimeUs += frameDelays[i] * US_PER_MS
gifTimeMs += frameDelays[i]
}
// Signal end of stream and drain
codec.signalEndOfInputStream()
drainEncoder(codec, muxer, bufferInfo, trackIndex, muxerStarted, true)
// Cleanup GL resources
GLES20.glDeleteTextures(1, intArrayOf(textureId), 0)
GLES20.glDeleteProgram(program)
bitmap.recycle()
Log.d(LOG_TAG) { "GIF to MP4 conversion complete: ${outputFile.length()} bytes" }
return MediaCompressorResult(outputFile.toUri(), "video/mp4", outputFile.length())
} catch (e: Exception) {
Log.e(LOG_TAG, "Encoding failed", e)
if (outputFile.exists()) outputFile.delete()
return null
} finally {
try { egl?.release() } catch (_: Exception) { }
try { codec?.stop() } catch (_: Exception) { }
try { codec?.release() } catch (_: Exception) { }
try { codecSurface?.release() } catch (_: Exception) { }
try { muxer?.stop() } catch (_: Exception) { }
try { muxer?.release() } catch (_: Exception) { }
}
}
// region EGL
private class EglHelper(surface: Surface) {
val display: EGLDisplay
val context: EGLContext
val eglSurface: EGLSurface
init {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
check(display != EGL14.EGL_NO_DISPLAY) { "eglGetDisplay failed" }
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val configAttribs =
intArrayOf(
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
EGL14.EGL_NONE,
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
check(EGL14.eglChooseConfig(display, configAttribs, 0, configs, 0, 1, numConfigs, 0)) {
"eglChooseConfig failed"
}
val contextAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
context = EGL14.eglCreateContext(display, configs[0]!!, EGL14.EGL_NO_CONTEXT, contextAttribs, 0)
check(context != EGL14.EGL_NO_CONTEXT) { "eglCreateContext failed" }
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0]!!, surface, surfaceAttribs, 0)
check(eglSurface != EGL14.EGL_NO_SURFACE) { "eglCreateWindowSurface failed" }
}
fun makeCurrent() {
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
}
fun setPresentationTime(nsecs: Long) {
EGLExt.eglPresentationTimeANDROID(display, eglSurface, nsecs)
}
fun swapBuffers() {
EGL14.eglSwapBuffers(display, eglSurface)
}
fun release() {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
EGL14.eglDestroySurface(display, eglSurface)
EGL14.eglDestroyContext(display, context)
EGL14.eglTerminate(display)
}
}
// endregion
// region GL helpers
private fun createGlProgram(): Int {
val vs = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER)
val fs = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER)
val program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vs)
GLES20.glAttachShader(program, fs)
GLES20.glLinkProgram(program)
GLES20.glDeleteShader(vs)
GLES20.glDeleteShader(fs)
return program
}
private fun compileShader(
type: Int,
source: String,
): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
return shader
}
private fun createGlTexture(): Int {
val texIds = IntArray(1)
GLES20.glGenTextures(1, texIds, 0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texIds[0])
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
return texIds[0]
}
private fun createVertexBuffer(): FloatBuffer =
ByteBuffer
.allocateDirect(QUAD_COORDS.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(QUAD_COORDS)
position(0)
}
private fun drawBitmapFrame(
bitmap: Bitmap,
textureId: Int,
program: Int,
vertexBuffer: FloatBuffer,
width: Int,
height: Int,
) {
GLES20.glViewport(0, 0, width, height)
GLES20.glClearColor(1f, 1f, 1f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
val posHandle = GLES20.glGetAttribLocation(program, "aPosition")
val texHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
GLES20.glUniform1i(GLES20.glGetUniformLocation(program, "uTexture"), 0)
// stride = 4 floats per vertex (2 pos + 2 tex) * 4 bytes
val stride = 4 * 4
vertexBuffer.position(0)
GLES20.glEnableVertexAttribArray(posHandle)
GLES20.glVertexAttribPointer(posHandle, 2, GLES20.GL_FLOAT, false, stride, vertexBuffer)
vertexBuffer.position(2)
GLES20.glEnableVertexAttribArray(texHandle)
GLES20.glVertexAttribPointer(texHandle, 2, GLES20.GL_FLOAT, false, stride, vertexBuffer)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(posHandle)
GLES20.glDisableVertexAttribArray(texHandle)
}
// endregion
// region GIF parsing
/**
* Parses per-frame delays from the GIF binary by reading Graphic Control Extension blocks.
* Returns a list of delays in milliseconds, one per frame.
*
* GIF delay values are in centiseconds (1/100s). Per browser convention,
* delays of 0 or 1 centisecond are treated as 100ms (10fps).
*/
private fun parseGifFrameDelays(bytes: ByteArray): List<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 -> {
pos += 10
if (pos > bytes.size) break
val imgPacked = bytes[pos - 1].toInt() and 0xFF
if (imgPacked and 0x80 != 0) {
pos += 3 * (1 shl ((imgPacked and 0x07) + 1))
}
pos++
pos = skipSubBlocks(bytes, pos)
}
0x3B -> break
else -> pos++
}
}
return delays
}
private fun skipSubBlocks(
bytes: ByteArray,
startPos: Int,
): Int {
var pos = startPos
while (pos < bytes.size) {
val blockSize = bytes[pos].toInt() and 0xFF
pos++
if (blockSize == 0) break
pos += blockSize
}
return pos
}
// endregion
// region Encoder helpers
private fun drainEncoder(
codec: MediaCodec,
muxer: MediaMuxer,
bufferInfo: MediaCodec.BufferInfo,
trackIndex: Int,
muxerStarted: Boolean,
endOfStream: Boolean,
): Pair<Int, Boolean> {
var currentTrackIndex = trackIndex
var currentMuxerStarted = muxerStarted
while (true) {
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
when {
outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
if (!endOfStream) return Pair(currentTrackIndex, currentMuxerStarted)
}
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
currentTrackIndex = muxer.addTrack(codec.outputFormat)
muxer.start()
currentMuxerStarted = true
}
outputIndex >= 0 -> {
val outputBuffer =
codec.getOutputBuffer(outputIndex)
?: throw RuntimeException("Encoder output buffer $outputIndex was null")
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (bufferInfo.size > 0 && currentMuxerStarted) {
outputBuffer.position(bufferInfo.offset)
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
muxer.writeSampleData(currentTrackIndex, outputBuffer, bufferInfo)
}
codec.releaseOutputBuffer(outputIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
return Pair(currentTrackIndex, currentMuxerStarted)
}
}
}
}
}
private fun calculateBitrate(
width: Int,
height: Int,
): Int {
val pixels = width * height
return when {
pixels >= 1920 * 1080 -> 4_000_000
pixels >= 1280 * 720 -> DEFAULT_BITRATE_BPS
pixels >= 640 * 480 -> 1_000_000
else -> 500_000
}
}
private fun Int.roundToEven(): Int = if (this % 2 != 0) this + 1 else this
// endregion
}
@@ -47,15 +47,37 @@ class MediaCompressor {
mediaQuality: CompressorQuality,
applicationContext: Context,
useH265: Boolean = false,
convertGifToMp4: Boolean = false,
): MediaCompressorResult {
checkNotInMainThread()
// Convert GIF to MP4 if requested (before quality check, since this is a format conversion)
if (convertGifToMp4 && contentType?.contains("gif", ignoreCase = true) == true) {
Log.d("MediaCompressor") { "Converting GIF to MP4" }
val converted = GifToMp4Converter.convert(uri, applicationContext)
if (converted != null) {
// Optionally compress the resulting video
if (mediaQuality != CompressorQuality.UNCOMPRESSED) {
return VideoCompressionHelper.compressVideo(
converted.uri,
converted.contentType,
applicationContext,
mediaQuality,
useH265,
)
}
return converted
}
Log.w("MediaCompressor") { "GIF to MP4 conversion failed, uploading as original GIF" }
return MediaCompressorResult(uri, contentType, null)
}
// Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
return MediaCompressorResult(uri, contentType, null)
}
checkNotInMainThread()
// branch into compression based on content type
return when {
contentType?.startsWith("video", ignoreCase = true) == true -> {
@@ -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
@@ -333,6 +335,19 @@ fun ImageVideoDescription(
)
}
if (uris.hasGif()) {
SettingSwitchItem(
title = R.string.convert_gif_to_mp4_label,
description = R.string.convert_gif_to_mp4_description,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
checked = convertGifToMp4,
onCheckedChange = { convertGifToMp4 = it },
)
}
Button(
modifier =
Modifier
@@ -341,7 +356,7 @@ fun ImageVideoDescription(
enabled = !isUploading,
onClick = {
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata)
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata, convertGifToMp4)
},
shape = QuoteBorder,
colors =
@@ -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)
},
@@ -430,8 +430,8 @@ private fun NewPostScreenBody(
it,
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata)
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata, convertGifToMp4 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata, convertGifToMp4)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -958,8 +958,9 @@ open class ShortNotePostViewModel :
context: Context,
useH265: Boolean,
stripMetadata: Boolean = true,
convertGifToMp4: Boolean = false,
) = try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4)
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -976,6 +977,7 @@ open class ShortNotePostViewModel :
context: Context,
useH265: Boolean,
stripMetadata: Boolean = true,
convertGifToMp4: Boolean = false,
) {
viewModelScope.launch(Dispatchers.IO) {
val myMultiOrchestrator = multiOrchestrator ?: return@launch
@@ -993,6 +995,7 @@ open class ShortNotePostViewModel :
useH265,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
convertGifToMp4 = convertGifToMp4,
)
if (results.allGood) {
@@ -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
@@ -1331,6 +1331,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>