From a9978277a39c76497f2a7bffb421703d527e9e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 21:10:07 +0000 Subject: [PATCH] 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 --- .../service/uploads/GifToMp4Converter.kt | 534 ++++++++++++++++++ .../service/uploads/MediaCompressor.kt | 26 +- .../service/uploads/MultiOrchestrator.kt | 6 + .../service/uploads/UploadOrchestrator.kt | 11 +- .../amethyst/ui/actions/EditPostView.kt | 2 +- .../ui/actions/uploads/SelectFromGallery.kt | 2 + .../creators/uploads/ImageVideoDescription.kt | 19 +- .../nip22Comments/GenericCommentPostScreen.kt | 2 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 2 +- .../nip23LongForm/LongFormPostScreen.kt | 4 +- .../nip23LongForm/LongFormPostViewModel.kt | 5 +- .../nip99Classifieds/NewProductScreen.kt | 2 +- .../loggedIn/home/ShortNotePostScreen.kt | 4 +- .../loggedIn/home/ShortNotePostViewModel.kt | 5 +- .../publicMessages/NewPublicMessageScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 2 + 16 files changed, 609 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt new file mode 100644 index 000000000..439f04083 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/GifToMp4Converter.kt @@ -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(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 { + if (bytes.size < 13) return emptyList() + + val delays = mutableListOf() + + var pos = 13 + + // Skip Global Color Table if present + val packed = bytes[10].toInt() and 0xFF + if (packed and 0x80 != 0) { + pos += 3 * (1 shl ((packed and 0x07) + 1)) + } + + while (pos < bytes.size) { + when (bytes[pos].toInt() and 0xFF) { + 0x21 -> { + pos++ + if (pos >= bytes.size) break + val label = bytes[pos].toInt() and 0xFF + pos++ + + if (label == 0xF9 && pos + 5 <= bytes.size) { + val blockSize = bytes[pos].toInt() and 0xFF + if (blockSize == 4) { + val delayLow = bytes[pos + 2].toInt() and 0xFF + val delayHigh = bytes[pos + 3].toInt() and 0xFF + val delayCentiseconds = delayLow or (delayHigh shl 8) + val delayMs = + if (delayCentiseconds <= 1) DEFAULT_FRAME_DELAY_MS else delayCentiseconds * 10 + delays.add(delayMs) + } + pos = skipSubBlocks(bytes, pos) + } else { + pos = skipSubBlocks(bytes, pos) + } + } + 0x2C -> { + 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 { + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 5cc488797..ace758a23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -47,15 +47,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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 18467a353..b36cba1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -49,6 +49,8 @@ class MultiOrchestrator( fun hasVideo() = list.any { it.media.mimeType?.startsWith("video", ignoreCase = true) == true } + fun hasGif() = list.any { it.media.isGif() } + fun hasNonMedia() = list.any { it.media.isNotMedia() } suspend fun upload( @@ -61,6 +63,7 @@ class MultiOrchestrator( useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): Result { coroutineScope { val jobs = @@ -78,6 +81,7 @@ class MultiOrchestrator( useH265, stripMetadata, onStrippingFailed, + convertGifToMp4 = convertGifToMp4, ) } } @@ -99,6 +103,7 @@ class MultiOrchestrator( useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): Result { coroutineScope { val jobs = @@ -117,6 +122,7 @@ class MultiOrchestrator( useH265, stripMetadata, onStrippingFailed, + convertGifToMp4 = convertGifToMp4, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index e0e5ad0f9..192a7fe8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -287,9 +287,10 @@ class UploadOrchestrator { compressionQuality: CompressorQuality, context: Context, useH265: Boolean = false, - ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) { + convertGifToMp4: Boolean = false, + ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) { updateState(0.02, UploadingState.Compressing) - MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265) + MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4) } else { MediaCompressorResult(uri, mimeType, null) } @@ -358,8 +359,9 @@ class UploadOrchestrator { useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): UploadingFinalState { - val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265) + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) @@ -393,8 +395,9 @@ class UploadOrchestrator { useH265: Boolean = false, stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, + convertGifToMp4: Boolean = false, ): UploadingFinalState { - val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265) + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 29ba7a579..70f1a8065 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -267,7 +267,7 @@ fun EditPostView( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index fcae06fc3..74ebb0741 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -54,6 +54,8 @@ class SelectedMedia( ) { fun isImage() = mimeType?.startsWith("image") + fun isGif() = mimeType?.equals("image/gif", ignoreCase = true) == true + fun isVideo() = mimeType?.startsWith("video") fun isAudio() = mimeType?.startsWith("audio") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 725af41bb..4dc9bf4e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -79,7 +79,7 @@ fun ImageVideoDescription( uris: MultiOrchestrator, defaultServer: ServerName, isUploading: Boolean, - onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit, + onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean, Boolean) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, accountViewModel: AccountViewModel, @@ -110,6 +110,8 @@ fun ImageVideoDescription( var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) } + var convertGifToMp4 by remember { mutableStateOf(false) } + Column( modifier = Modifier @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index e8d4bb98f..40c3cd99b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -332,7 +332,7 @@ private fun GenericCommentPostBody( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index a7a84109a..716be8b86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -289,7 +289,7 @@ fun GroupDMScreenContent( selectedFiles, accountViewModel.account.settings.defaultFileServer, isUploading = uploading.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, _ -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, _, _ -> postViewModel.uploadAndHold( accountViewModel.toastManager::toast, context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index f72e7a1c4..e82d4c348 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -472,8 +472,8 @@ private fun MarkdownPostScreenBody( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, uploadContext, useH265, stripMetadata) + onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata, convertGifToMp4 -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, uploadContext, useH265, stripMetadata, convertGifToMp4) accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 26e2e7918..6e484ace4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -489,8 +489,9 @@ class LongFormPostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) = try { - uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata) + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4) } catch (_: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -507,6 +508,7 @@ class LongFormPostViewModel : context: Context, useH265: Boolean, stripMetadata: Boolean = true, + convertGifToMp4: Boolean = false, ) { viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch @@ -524,6 +526,7 @@ class LongFormPostViewModel : useH265, stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + convertGifToMp4 = convertGifToMp4, ) if (results.allGood) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index e0c13365e..6f4fd20c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -274,7 +274,7 @@ private fun NewProductBody( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index eed62305a..d5ec254e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 6adf94284..4f45ecc84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index ada38e5e5..1519ac398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -249,7 +249,7 @@ fun PublicMessageScreenContent( it, accountViewModel.account.settings.defaultFileServer, isUploading = postViewModel.mediaUploadTracker.isUploading, - onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata -> + onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata) accountViewModel.account.settings.changeDefaultFileServer(server) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 55325cc71..a11f96248 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1331,6 +1331,8 @@ Uncompressed Use H.265/HEVC Codec Better quality at smaller file sizes but not all devices support H.265 playback. + Convert GIF to MP4 + Converts animated GIF to MP4 video for smaller file size and better playback compatibility. Remove private metadata Attempts to strip private metadata from supported media files before uploading