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:
+534
@@ -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
|
||||||
|
}
|
||||||
+24
-2
@@ -47,15 +47,37 @@ class MediaCompressor {
|
|||||||
mediaQuality: CompressorQuality,
|
mediaQuality: CompressorQuality,
|
||||||
applicationContext: Context,
|
applicationContext: Context,
|
||||||
useH265: Boolean = false,
|
useH265: Boolean = false,
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
): MediaCompressorResult {
|
): 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
|
// Skip compression if user selected uncompressed
|
||||||
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
|
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
|
||||||
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
|
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
|
||||||
return MediaCompressorResult(uri, contentType, null)
|
return MediaCompressorResult(uri, contentType, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
checkNotInMainThread()
|
|
||||||
|
|
||||||
// branch into compression based on content type
|
// branch into compression based on content type
|
||||||
return when {
|
return when {
|
||||||
contentType?.startsWith("video", ignoreCase = true) == true -> {
|
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 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() }
|
fun hasNonMedia() = list.any { it.media.isNotMedia() }
|
||||||
|
|
||||||
suspend fun upload(
|
suspend fun upload(
|
||||||
@@ -61,6 +63,7 @@ class MultiOrchestrator(
|
|||||||
useH265: Boolean = false,
|
useH265: Boolean = false,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
onStrippingFailed: suspend () -> Boolean = { true },
|
onStrippingFailed: suspend () -> Boolean = { true },
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
): Result {
|
): Result {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val jobs =
|
val jobs =
|
||||||
@@ -78,6 +81,7 @@ class MultiOrchestrator(
|
|||||||
useH265,
|
useH265,
|
||||||
stripMetadata,
|
stripMetadata,
|
||||||
onStrippingFailed,
|
onStrippingFailed,
|
||||||
|
convertGifToMp4 = convertGifToMp4,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,7 @@ class MultiOrchestrator(
|
|||||||
useH265: Boolean = false,
|
useH265: Boolean = false,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
onStrippingFailed: suspend () -> Boolean = { true },
|
onStrippingFailed: suspend () -> Boolean = { true },
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
): Result {
|
): Result {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val jobs =
|
val jobs =
|
||||||
@@ -117,6 +122,7 @@ class MultiOrchestrator(
|
|||||||
useH265,
|
useH265,
|
||||||
stripMetadata,
|
stripMetadata,
|
||||||
onStrippingFailed,
|
onStrippingFailed,
|
||||||
|
convertGifToMp4 = convertGifToMp4,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -287,9 +287,10 @@ class UploadOrchestrator {
|
|||||||
compressionQuality: CompressorQuality,
|
compressionQuality: CompressorQuality,
|
||||||
context: Context,
|
context: Context,
|
||||||
useH265: Boolean = false,
|
useH265: Boolean = false,
|
||||||
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
|
convertGifToMp4: Boolean = false,
|
||||||
|
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) {
|
||||||
updateState(0.02, UploadingState.Compressing)
|
updateState(0.02, UploadingState.Compressing)
|
||||||
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265)
|
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4)
|
||||||
} else {
|
} else {
|
||||||
MediaCompressorResult(uri, mimeType, null)
|
MediaCompressorResult(uri, mimeType, null)
|
||||||
}
|
}
|
||||||
@@ -358,8 +359,9 @@ class UploadOrchestrator {
|
|||||||
useH265: Boolean = false,
|
useH265: Boolean = false,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
onStrippingFailed: suspend () -> Boolean = { true },
|
onStrippingFailed: suspend () -> Boolean = { true },
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
|
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
|
||||||
|
|
||||||
val finalUri =
|
val finalUri =
|
||||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
||||||
@@ -393,8 +395,9 @@ class UploadOrchestrator {
|
|||||||
useH265: Boolean = false,
|
useH265: Boolean = false,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
onStrippingFailed: suspend () -> Boolean = { true },
|
onStrippingFailed: suspend () -> Boolean = { true },
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
|
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
|
||||||
|
|
||||||
val finalUri =
|
val finalUri =
|
||||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ fun EditPostView(
|
|||||||
it,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
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)
|
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata)
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
},
|
},
|
||||||
|
|||||||
+2
@@ -54,6 +54,8 @@ class SelectedMedia(
|
|||||||
) {
|
) {
|
||||||
fun isImage() = mimeType?.startsWith("image")
|
fun isImage() = mimeType?.startsWith("image")
|
||||||
|
|
||||||
|
fun isGif() = mimeType?.equals("image/gif", ignoreCase = true) == true
|
||||||
|
|
||||||
fun isVideo() = mimeType?.startsWith("video")
|
fun isVideo() = mimeType?.startsWith("video")
|
||||||
|
|
||||||
fun isAudio() = mimeType?.startsWith("audio")
|
fun isAudio() = mimeType?.startsWith("audio")
|
||||||
|
|||||||
+17
-2
@@ -79,7 +79,7 @@ fun ImageVideoDescription(
|
|||||||
uris: MultiOrchestrator,
|
uris: MultiOrchestrator,
|
||||||
defaultServer: ServerName,
|
defaultServer: ServerName,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean) -> Unit,
|
onAdd: (String, ServerName, Boolean, Int, Boolean, Boolean, Boolean) -> Unit,
|
||||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
@@ -110,6 +110,8 @@ fun ImageVideoDescription(
|
|||||||
|
|
||||||
var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) }
|
var stripMetadata by remember { mutableStateOf(accountViewModel.account.settings.stripLocationOnUpload) }
|
||||||
|
|
||||||
|
var convertGifToMp4 by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
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(
|
Button(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -341,7 +356,7 @@ fun ImageVideoDescription(
|
|||||||
enabled = !isUploading,
|
enabled = !isUploading,
|
||||||
onClick = {
|
onClick = {
|
||||||
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
|
val effectiveStripMetadata = if (isVideoWithCompression) false else stripMetadata
|
||||||
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata)
|
onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec, effectiveStripMetadata, convertGifToMp4)
|
||||||
},
|
},
|
||||||
shape = QuoteBorder,
|
shape = QuoteBorder,
|
||||||
colors =
|
colors =
|
||||||
|
|||||||
+1
-1
@@ -332,7 +332,7 @@ private fun GenericCommentPostBody(
|
|||||||
it,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
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)
|
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -289,7 +289,7 @@ fun GroupDMScreenContent(
|
|||||||
selectedFiles,
|
selectedFiles,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = uploading.mediaUploadTracker.isUploading,
|
isUploading = uploading.mediaUploadTracker.isUploading,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality, _, _ ->
|
onAdd = { alt, server, sensitiveContent, mediaQuality, _, _, _ ->
|
||||||
postViewModel.uploadAndHold(
|
postViewModel.uploadAndHold(
|
||||||
accountViewModel.toastManager::toast,
|
accountViewModel.toastManager::toast,
|
||||||
context,
|
context,
|
||||||
|
|||||||
+2
-2
@@ -472,8 +472,8 @@ private fun MarkdownPostScreenBody(
|
|||||||
it,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality, 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)
|
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, uploadContext, useH265, stripMetadata, convertGifToMp4)
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
},
|
},
|
||||||
onDelete = postViewModel::deleteMediaToUpload,
|
onDelete = postViewModel::deleteMediaToUpload,
|
||||||
|
|||||||
+4
-1
@@ -489,8 +489,9 @@ class LongFormPostViewModel :
|
|||||||
context: Context,
|
context: Context,
|
||||||
useH265: Boolean,
|
useH265: Boolean,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
) = try {
|
) = try {
|
||||||
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata)
|
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4)
|
||||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||||
onError(
|
onError(
|
||||||
stringRes(context, R.string.read_only_user),
|
stringRes(context, R.string.read_only_user),
|
||||||
@@ -507,6 +508,7 @@ class LongFormPostViewModel :
|
|||||||
context: Context,
|
context: Context,
|
||||||
useH265: Boolean,
|
useH265: Boolean,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||||
@@ -524,6 +526,7 @@ class LongFormPostViewModel :
|
|||||||
useH265,
|
useH265,
|
||||||
stripMetadata,
|
stripMetadata,
|
||||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||||
|
convertGifToMp4 = convertGifToMp4,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (results.allGood) {
|
if (results.allGood) {
|
||||||
|
|||||||
+1
-1
@@ -274,7 +274,7 @@ private fun NewProductBody(
|
|||||||
uris = it,
|
uris = it,
|
||||||
defaultServer = accountViewModel.account.settings.defaultFileServer,
|
defaultServer = accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
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)
|
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -430,8 +430,8 @@ private fun NewPostScreenBody(
|
|||||||
it,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality, 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)
|
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata, convertGifToMp4)
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
},
|
},
|
||||||
onDelete = postViewModel::deleteMediaToUpload,
|
onDelete = postViewModel::deleteMediaToUpload,
|
||||||
|
|||||||
+4
-1
@@ -958,8 +958,9 @@ open class ShortNotePostViewModel :
|
|||||||
context: Context,
|
context: Context,
|
||||||
useH265: Boolean,
|
useH265: Boolean,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
) = try {
|
) = try {
|
||||||
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata)
|
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4)
|
||||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||||
onError(
|
onError(
|
||||||
stringRes(context, R.string.read_only_user),
|
stringRes(context, R.string.read_only_user),
|
||||||
@@ -976,6 +977,7 @@ open class ShortNotePostViewModel :
|
|||||||
context: Context,
|
context: Context,
|
||||||
useH265: Boolean,
|
useH265: Boolean,
|
||||||
stripMetadata: Boolean = true,
|
stripMetadata: Boolean = true,
|
||||||
|
convertGifToMp4: Boolean = false,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||||
@@ -993,6 +995,7 @@ open class ShortNotePostViewModel :
|
|||||||
useH265,
|
useH265,
|
||||||
stripMetadata,
|
stripMetadata,
|
||||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||||
|
convertGifToMp4 = convertGifToMp4,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (results.allGood) {
|
if (results.allGood) {
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ fun PublicMessageScreenContent(
|
|||||||
it,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
isUploading = postViewModel.mediaUploadTracker.isUploading,
|
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)
|
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1331,6 +1331,8 @@
|
|||||||
<string name="media_compression_quality_uncompressed">Uncompressed</string>
|
<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_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="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_label">Remove private metadata</string>
|
||||||
<string name="strip_metadata_description">Attempts to strip private metadata from supported media files before uploading</string>
|
<string name="strip_metadata_description">Attempts to strip private metadata from supported media files before uploading</string>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user