feat: compress images and transcode GIFs before Blossom upload
Every upload path (profile picture, compose attachment, DM attachment) now runs through a shared preprocessing pipeline: - image/gif -> transcoded to H.264 MP4 via MediaCodec + MediaMuxer through an EGL input surface (typical 5-20x size reduction) - other image/* -> decoded with ImageDecoder (handles HEIC on API 28+), EXIF-rotated when needed, proportionally scaled, JPEG re-encoded - videos and other types -> passed through unchanged Profile pictures cap at 400px wide / JPEG quality 75. Compose and DM uploads cap at 1920px / quality 82. imeta dim and Kind 15 size/dim tags are recomputed from post-pipeline bytes so clients see accurate metadata. Gallery-mode video/image mixing check treats GIFs as video since they become MP4s.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
@file:Suppress("DEPRECATION") // android.graphics.Movie is the only public GIF-frame API
|
||||
|
||||
package com.wisp.app.ui.util
|
||||
|
||||
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.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.opengl.Matrix
|
||||
import android.view.Surface
|
||||
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 kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Uses the deprecated `android.graphics.Movie` API — it's the only public Android class that exposes
|
||||
// per-frame GIF decoding at arbitrary timestamps. Still functional on all supported API levels.
|
||||
object GifToMp4Converter {
|
||||
|
||||
private const val MIME = "video/avc"
|
||||
private const val MAX_DIM = 1920
|
||||
private const val FPS = 20
|
||||
private const val I_FRAME_INTERVAL_S = 1
|
||||
private const val TIMEOUT_US = 10_000L
|
||||
|
||||
suspend fun convert(gifBytes: ByteArray, context: Context): Triple<ByteArray, String, String> =
|
||||
withContext(Dispatchers.Default) {
|
||||
@Suppress("DEPRECATION")
|
||||
val movie = Movie.decodeByteArray(gifBytes, 0, gifBytes.size)
|
||||
?: throw IllegalArgumentException("Not a valid GIF")
|
||||
|
||||
val srcW = movie.width().coerceAtLeast(1)
|
||||
val srcH = movie.height().coerceAtLeast(1)
|
||||
val durationMs = movie.duration().let { if (it <= 0) 100 else it }
|
||||
|
||||
val (outW, outH) = fitWithin(srcW, srcH, MAX_DIM).toEven()
|
||||
|
||||
val tempFile = File.createTempFile("gif2mp4_", ".mp4", context.cacheDir)
|
||||
try {
|
||||
encode(movie, srcW, srcH, outW, outH, durationMs, tempFile)
|
||||
Triple(tempFile.readBytes(), "video/mp4", "mp4")
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encode(
|
||||
@Suppress("DEPRECATION") movie: Movie,
|
||||
srcW: Int,
|
||||
srcH: Int,
|
||||
outW: Int,
|
||||
outH: Int,
|
||||
durationMs: Int,
|
||||
outFile: File,
|
||||
) {
|
||||
val format = MediaFormat.createVideoFormat(MIME, outW, outH).apply {
|
||||
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, estimateBitrate(outW, outH))
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, FPS)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
||||
}
|
||||
|
||||
val encoder = MediaCodec.createEncoderByType(MIME)
|
||||
try {
|
||||
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val inputSurface = encoder.createInputSurface()
|
||||
encoder.start()
|
||||
|
||||
val muxer = MediaMuxer(outFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
val state = MuxState(muxer = muxer)
|
||||
|
||||
val egl = EglCore(inputSurface)
|
||||
try {
|
||||
val bitmap = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val renderer = TextureRenderer()
|
||||
renderer.init()
|
||||
|
||||
val frameCount = max(1, (durationMs * FPS / 1000))
|
||||
val frameDurationUs = 1_000_000L / FPS
|
||||
|
||||
for (i in 0 until frameCount) {
|
||||
val presentationTimeUs = i * frameDurationUs
|
||||
val movieTimeMs = ((i.toLong() * durationMs) / frameCount).toInt().coerceIn(0, durationMs)
|
||||
movie.setTime(movieTimeMs)
|
||||
|
||||
canvas.drawColor(Color.BLACK)
|
||||
movie.draw(canvas, 0f, 0f)
|
||||
|
||||
renderer.drawBitmap(bitmap, outW, outH)
|
||||
egl.setPresentationTime(presentationTimeUs * 1000L) // ns
|
||||
egl.swap()
|
||||
|
||||
drain(encoder, state, endOfStream = false)
|
||||
}
|
||||
|
||||
encoder.signalEndOfInputStream()
|
||||
drain(encoder, state, endOfStream = true)
|
||||
|
||||
bitmap.recycle()
|
||||
renderer.release()
|
||||
} finally {
|
||||
egl.release()
|
||||
inputSurface.release()
|
||||
try {
|
||||
if (state.muxerStarted) muxer.stop()
|
||||
} catch (_: Exception) { /* best effort */ }
|
||||
muxer.release()
|
||||
}
|
||||
} finally {
|
||||
try { encoder.stop() } catch (_: Exception) {}
|
||||
encoder.release()
|
||||
}
|
||||
}
|
||||
|
||||
private class MuxState(val muxer: MediaMuxer) {
|
||||
var trackIndex = -1
|
||||
var muxerStarted = false
|
||||
}
|
||||
|
||||
private fun drain(encoder: MediaCodec, state: MuxState, endOfStream: Boolean) {
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val outputIndex = encoder.dequeueOutputBuffer(info, if (endOfStream) TIMEOUT_US else 0)
|
||||
when {
|
||||
outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
||||
if (!endOfStream) return
|
||||
}
|
||||
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (state.muxerStarted) throw IllegalStateException("format changed after muxer start")
|
||||
state.trackIndex = state.muxer.addTrack(encoder.outputFormat)
|
||||
state.muxer.start()
|
||||
state.muxerStarted = true
|
||||
}
|
||||
outputIndex >= 0 -> {
|
||||
val buf = encoder.getOutputBuffer(outputIndex)
|
||||
if (buf != null && info.size > 0 && (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0 && state.muxerStarted) {
|
||||
buf.position(info.offset)
|
||||
buf.limit(info.offset + info.size)
|
||||
state.muxer.writeSampleData(state.trackIndex, buf, info)
|
||||
}
|
||||
encoder.releaseOutputBuffer(outputIndex, false)
|
||||
if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun estimateBitrate(w: Int, h: Int): Int {
|
||||
// ~4 bits per pixel per frame at FPS — empirically decent for motion graphics.
|
||||
val bpp = 4
|
||||
return (w.toLong() * h.toLong() * FPS * bpp).toInt().coerceIn(256_000, 8_000_000)
|
||||
}
|
||||
|
||||
private fun fitWithin(w: Int, h: Int, maxDim: Int): Pair<Int, Int> {
|
||||
val longest = max(w, h)
|
||||
if (longest <= maxDim) return w to h
|
||||
val scale = maxDim.toFloat() / longest.toFloat()
|
||||
return ((w * scale).roundToInt().coerceAtLeast(2)) to ((h * scale).roundToInt().coerceAtLeast(2))
|
||||
}
|
||||
|
||||
private fun Pair<Int, Int>.toEven(): Pair<Int, Int> {
|
||||
val a = if (first % 2 == 0) first else first - 1
|
||||
val b = if (second % 2 == 0) second else second - 1
|
||||
return max(a, 2) to max(b, 2)
|
||||
}
|
||||
|
||||
// -- EGL + GL helpers ---------------------------------------------------------------------
|
||||
|
||||
private class EglCore(surface: Surface) {
|
||||
private val display: EGLDisplay
|
||||
private val context: EGLContext
|
||||
private val eglSurface: EGLSurface
|
||||
|
||||
init {
|
||||
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (display === EGL14.EGL_NO_DISPLAY) throw RuntimeException("no EGL display")
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(display, version, 0, version, 1)) throw RuntimeException("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,
|
||||
EGLExt.EGL_RECORDABLE_ANDROID, 1,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val numConfigs = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(display, configAttribs, 0, configs, 0, 1, numConfigs, 0))
|
||||
throw RuntimeException("eglChooseConfig failed")
|
||||
val config = configs[0] ?: throw RuntimeException("no EGL config")
|
||||
|
||||
val ctxAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
|
||||
context = EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, ctxAttribs, 0)
|
||||
|
||||
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
|
||||
eglSurface = EGL14.eglCreateWindowSurface(display, config, surface, surfaceAttribs, 0)
|
||||
if (!EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context))
|
||||
throw RuntimeException("eglMakeCurrent failed")
|
||||
}
|
||||
|
||||
fun setPresentationTime(ns: Long) {
|
||||
EGLExt.eglPresentationTimeANDROID(display, eglSurface, ns)
|
||||
}
|
||||
|
||||
fun swap() {
|
||||
EGL14.eglSwapBuffers(display, eglSurface)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (display !== EGL14.EGL_NO_DISPLAY) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TextureRenderer {
|
||||
private var program = 0
|
||||
private var textureId = 0
|
||||
private var aPositionLoc = 0
|
||||
private var aTexCoordLoc = 0
|
||||
private var uMvpLoc = 0
|
||||
private var uTexLoc = 0
|
||||
private val mvp = FloatArray(16)
|
||||
private val vertexBuf: FloatBuffer
|
||||
private val texCoordBuf: FloatBuffer
|
||||
|
||||
init {
|
||||
val verts = floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
|
||||
vertexBuf = ByteBuffer.allocateDirect(verts.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer().apply {
|
||||
put(verts); position(0)
|
||||
}
|
||||
// Flip V: OpenGL texture origin is bottom-left; Bitmap is top-left.
|
||||
val tex = floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 0f)
|
||||
texCoordBuf = ByteBuffer.allocateDirect(tex.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer().apply {
|
||||
put(tex); position(0)
|
||||
}
|
||||
Matrix.setIdentityM(mvp, 0)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
program = compileProgram(VS, FS)
|
||||
aPositionLoc = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
aTexCoordLoc = GLES20.glGetAttribLocation(program, "aTexCoord")
|
||||
uMvpLoc = GLES20.glGetUniformLocation(program, "uMvp")
|
||||
uTexLoc = GLES20.glGetUniformLocation(program, "uTex")
|
||||
|
||||
val ids = IntArray(1)
|
||||
GLES20.glGenTextures(1, ids, 0)
|
||||
textureId = ids[0]
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
|
||||
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)
|
||||
}
|
||||
|
||||
fun drawBitmap(bitmap: Bitmap, viewportW: Int, viewportH: Int) {
|
||||
GLES20.glViewport(0, 0, viewportW, viewportH)
|
||||
GLES20.glClearColor(0f, 0f, 0f, 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)
|
||||
GLES20.glUniform1i(uTexLoc, 0)
|
||||
GLES20.glUniformMatrix4fv(uMvpLoc, 1, false, mvp, 0)
|
||||
|
||||
GLES20.glEnableVertexAttribArray(aPositionLoc)
|
||||
GLES20.glVertexAttribPointer(aPositionLoc, 2, GLES20.GL_FLOAT, false, 0, vertexBuf)
|
||||
GLES20.glEnableVertexAttribArray(aTexCoordLoc)
|
||||
GLES20.glVertexAttribPointer(aTexCoordLoc, 2, GLES20.GL_FLOAT, false, 0, texCoordBuf)
|
||||
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
|
||||
GLES20.glDisableVertexAttribArray(aPositionLoc)
|
||||
GLES20.glDisableVertexAttribArray(aTexCoordLoc)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (program != 0) GLES20.glDeleteProgram(program)
|
||||
if (textureId != 0) GLES20.glDeleteTextures(1, intArrayOf(textureId), 0)
|
||||
program = 0
|
||||
textureId = 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VS = """
|
||||
uniform mat4 uMvp;
|
||||
attribute vec4 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
gl_Position = uMvp * aPosition;
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
"""
|
||||
private const val FS = """
|
||||
precision mediump float;
|
||||
uniform sampler2D uTex;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTex, vTexCoord);
|
||||
}
|
||||
"""
|
||||
|
||||
private fun compileShader(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0)
|
||||
if (status[0] == 0) {
|
||||
val log = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
throw RuntimeException("shader compile failed: $log")
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
fun compileProgram(vs: String, fs: String): Int {
|
||||
val vsId = compileShader(GLES20.GL_VERTEX_SHADER, vs)
|
||||
val fsId = compileShader(GLES20.GL_FRAGMENT_SHADER, fs)
|
||||
val program = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(program, vsId)
|
||||
GLES20.glAttachShader(program, fsId)
|
||||
GLES20.glLinkProgram(program)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, status, 0)
|
||||
GLES20.glDeleteShader(vsId)
|
||||
GLES20.glDeleteShader(fsId)
|
||||
if (status[0] == 0) {
|
||||
val log = GLES20.glGetProgramInfoLog(program)
|
||||
GLES20.glDeleteProgram(program)
|
||||
throw RuntimeException("program link failed: $log")
|
||||
}
|
||||
return program
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.wisp.app.ui.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object MediaCompressor {
|
||||
|
||||
data class Result(val bytes: ByteArray, val mimeType: String, val ext: String) {
|
||||
fun asTriple(): Triple<ByteArray, String, String> = Triple(bytes, mimeType, ext)
|
||||
}
|
||||
|
||||
private const val PROFILE_MAX_DIM = 400
|
||||
private const val PROFILE_QUALITY = 75
|
||||
private const val CONTENT_MAX_DIM = 1920
|
||||
private const val CONTENT_QUALITY = 82
|
||||
|
||||
suspend fun compressForProfile(bytes: ByteArray, sourceMime: String): Result =
|
||||
compressImage(bytes, sourceMime, PROFILE_MAX_DIM, PROFILE_QUALITY)
|
||||
|
||||
suspend fun compressForContent(bytes: ByteArray, sourceMime: String): Result =
|
||||
compressImage(bytes, sourceMime, CONTENT_MAX_DIM, CONTENT_QUALITY)
|
||||
|
||||
private suspend fun compressImage(
|
||||
bytes: ByteArray,
|
||||
sourceMime: String,
|
||||
maxDim: Int,
|
||||
quality: Int,
|
||||
): Result = withContext(Dispatchers.Default) {
|
||||
if (!sourceMime.startsWith("image/")) {
|
||||
return@withContext Result(bytes, sourceMime, extensionFor(sourceMime))
|
||||
}
|
||||
// GIFs are handled separately by GifToMp4Converter; if one slips through, pass through raw
|
||||
// so animation isn't silently dropped.
|
||||
if (sourceMime == "image/gif") {
|
||||
return@withContext Result(bytes, sourceMime, "gif")
|
||||
}
|
||||
|
||||
val decoded = decode(bytes, maxDim) ?: return@withContext Result(bytes, sourceMime, extensionFor(sourceMime))
|
||||
|
||||
// ImageDecoder (API 28+) already applies EXIF orientation. BitmapFactory does not — rotate manually.
|
||||
val oriented = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
val rotation = readExifRotation(bytes)
|
||||
if (rotation != 0) decoded.rotated(rotation).also { if (it !== decoded) decoded.recycle() } else decoded
|
||||
} else decoded
|
||||
|
||||
val scaled = oriented.scaledDown(maxDim)
|
||||
if (scaled !== oriented) oriented.recycle()
|
||||
|
||||
val out = ByteArrayOutputStream(scaled.byteCount / 4)
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||
scaled.recycle()
|
||||
Result(out.toByteArray(), "image/jpeg", "jpg")
|
||||
}
|
||||
|
||||
private fun decode(bytes: ByteArray, maxDim: Int): Bitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
decodeWithImageDecoder(bytes, maxDim)
|
||||
} else {
|
||||
decodeWithBitmapFactory(bytes, maxDim)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeWithImageDecoder(bytes: ByteArray, maxDim: Int): Bitmap? = try {
|
||||
val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
|
||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.isMutableRequired = true
|
||||
val w = info.size.width
|
||||
val h = info.size.height
|
||||
val longest = max(w, h)
|
||||
if (longest > maxDim) {
|
||||
val scale = maxDim.toFloat() / longest.toFloat()
|
||||
decoder.setTargetSize((w * scale).roundToInt().coerceAtLeast(1), (h * scale).roundToInt().coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun decodeWithBitmapFactory(bytes: ByteArray, maxDim: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val longest = max(bounds.outWidth, bounds.outHeight)
|
||||
var sample = 1
|
||||
while (longest / sample > maxDim * 2) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
}
|
||||
|
||||
private fun Bitmap.scaledDown(maxDim: Int): Bitmap {
|
||||
val longest = max(width, height)
|
||||
if (longest <= maxDim) return this
|
||||
val scale = maxDim.toFloat() / longest.toFloat()
|
||||
val newW = (width * scale).roundToInt().coerceAtLeast(1)
|
||||
val newH = (height * scale).roundToInt().coerceAtLeast(1)
|
||||
return Bitmap.createScaledBitmap(this, newW, newH, true)
|
||||
}
|
||||
|
||||
private fun Bitmap.rotated(degrees: Int): Bitmap {
|
||||
val m = Matrix().apply { postRotate(degrees.toFloat()) }
|
||||
return Bitmap.createBitmap(this, 0, 0, width, height, m, true)
|
||||
}
|
||||
|
||||
private fun readExifRotation(bytes: ByteArray): Int = try {
|
||||
val exif = ExifInterface(ByteArrayInputStream(bytes))
|
||||
when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270
|
||||
else -> 0
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
|
||||
private fun extensionFor(mime: String): String = when (mime) {
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/gif" -> "gif"
|
||||
"image/webp" -> "webp"
|
||||
"image/heic" -> "heic"
|
||||
"image/heif" -> "heif"
|
||||
else -> "bin"
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import com.wisp.app.repo.EventRepository
|
||||
import com.wisp.app.repo.InterfacePreferences
|
||||
import com.wisp.app.repo.ProfileRepository
|
||||
import com.wisp.app.R
|
||||
import com.wisp.app.ui.util.GifToMp4Converter
|
||||
import com.wisp.app.ui.util.MediaCompressor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -232,7 +234,8 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
|
||||
// Gallery mode limits: 1 video max, 21 images max, no mixing
|
||||
if (_galleryMode.value) {
|
||||
val mime = contentResolver.getType(uri) ?: ""
|
||||
val isVideo = mime.startsWith("video/")
|
||||
// GIFs are transcoded to MP4 during upload, so treat them as video for gallery constraints.
|
||||
val isVideo = mime.startsWith("video/") || mime == "image/gif"
|
||||
val currentUrls = _uploadedUrls.value
|
||||
if (isVideo && currentUrls.isNotEmpty()) {
|
||||
_error.value = "Video gallery posts can only contain one video"
|
||||
@@ -253,9 +256,14 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
|
||||
}
|
||||
try {
|
||||
_uploadProgress.value = if (total > 1) "Uploading ${index + 1}/$total..." else "Uploading..."
|
||||
// Extract dimensions before upload for dim tags and video orientation
|
||||
val dims = extractMediaDimensions(uri, contentResolver)
|
||||
val (bytes, mime, ext) = readFileFromUri(contentResolver, uri)
|
||||
val (rawBytes, rawMime, rawExt) = readFileFromUri(contentResolver, uri)
|
||||
val (bytes, mime, ext) = when {
|
||||
rawMime == "image/gif" -> GifToMp4Converter.convert(rawBytes, getApplication())
|
||||
rawMime.startsWith("image/") -> MediaCompressor.compressForContent(rawBytes, rawMime).asTriple()
|
||||
else -> Triple(rawBytes, rawMime, rawExt)
|
||||
}
|
||||
// Re-extract dimensions from post-pipeline bytes so dim tags match what's uploaded.
|
||||
val dims = extractDimensionsFromBytes(bytes, mime)
|
||||
val url = blossomRepo.uploadMedia(bytes, mime, ext, signer)
|
||||
_uploadedUrls.value = _uploadedUrls.value + url
|
||||
if (dims != null) _mediaDimensions[url] = dims
|
||||
@@ -746,25 +754,28 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
|
||||
return Triple(bytes, mimeType, ext)
|
||||
}
|
||||
|
||||
private fun extractMediaDimensions(uri: Uri, contentResolver: ContentResolver): Pair<Int, Int>? {
|
||||
private fun extractDimensionsFromBytes(bytes: ByteArray, mime: String): Pair<Int, Int>? {
|
||||
return try {
|
||||
val mime = contentResolver.getType(uri) ?: ""
|
||||
if (mime.startsWith("video/")) {
|
||||
val retriever = android.media.MediaMetadataRetriever()
|
||||
val tmp = java.io.File.createTempFile("dims_", ".mp4", getApplication<Application>().cacheDir)
|
||||
try {
|
||||
retriever.setDataSource(getApplication<Application>(), uri)
|
||||
val w = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
||||
val h = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
if (w != null && h != null && w > 0 && h > 0) w to h else null
|
||||
tmp.writeBytes(bytes)
|
||||
val retriever = android.media.MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(tmp.absolutePath)
|
||||
val w = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
||||
val h = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
if (w != null && h != null && w > 0 && h > 0) w to h else null
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
} finally {
|
||||
retriever.release()
|
||||
tmp.delete()
|
||||
}
|
||||
} else if (mime.startsWith("image/")) {
|
||||
val opts = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
contentResolver.openInputStream(uri)?.use { android.graphics.BitmapFactory.decodeStream(it, null, opts) }
|
||||
val w = opts.outWidth
|
||||
val h = opts.outHeight
|
||||
if (w > 0 && h > 0) w to h else null
|
||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
if (opts.outWidth > 0 && opts.outHeight > 0) opts.outWidth to opts.outHeight else null
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import com.wisp.app.repo.BlossomRepository
|
||||
import com.wisp.app.repo.KeyRepository
|
||||
import com.wisp.app.repo.PowPreferences
|
||||
import com.wisp.app.repo.RelayListRepository
|
||||
import com.wisp.app.ui.util.GifToMp4Converter
|
||||
import com.wisp.app.ui.util.MediaCompressor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -307,16 +309,17 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
|
||||
for ((index, uri) in uris.withIndex()) {
|
||||
try {
|
||||
_uploadProgress.value = if (total > 1) "Encrypting & uploading ${index + 1}/$total..." else "Encrypting & uploading..."
|
||||
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
val rawBytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: throw Exception("Cannot read file")
|
||||
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val srcMime = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
|
||||
// Compute image dimensions if applicable
|
||||
val dimensions = if (mimeType.startsWith("image/")) {
|
||||
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
if (opts.outWidth > 0 && opts.outHeight > 0) "${opts.outWidth}x${opts.outHeight}" else null
|
||||
} else null
|
||||
val (bytes, mimeType, _) = when {
|
||||
srcMime == "image/gif" -> GifToMp4Converter.convert(rawBytes, getApplication())
|
||||
srcMime.startsWith("image/") -> MediaCompressor.compressForContent(rawBytes, srcMime).asTriple()
|
||||
else -> Triple(rawBytes, srcMime, "")
|
||||
}
|
||||
|
||||
val dimensions = dimsTagFor(bytes, mimeType)
|
||||
|
||||
val result = blossomRepo.uploadEncryptedMedia(bytes, mimeType, signer)
|
||||
sendFileMessage(result, mimeType, dimensions, bytes.size.toLong(), relayPool, signer)
|
||||
@@ -329,6 +332,32 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun dimsTagFor(bytes: ByteArray, mime: String): String? {
|
||||
return try {
|
||||
if (mime.startsWith("image/")) {
|
||||
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
if (opts.outWidth > 0 && opts.outHeight > 0) "${opts.outWidth}x${opts.outHeight}" else null
|
||||
} else if (mime.startsWith("video/")) {
|
||||
val tmp = java.io.File.createTempFile("dmdims_", ".mp4", getApplication<Application>().cacheDir)
|
||||
try {
|
||||
tmp.writeBytes(bytes)
|
||||
val retriever = android.media.MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(tmp.absolutePath)
|
||||
val w = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
||||
val h = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
if (w != null && h != null && w > 0 && h > 0) "${w}x${h}" else null
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Kind 15 encrypted file message to all conversation participants + self.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.wisp.app.repo.BlossomRepository
|
||||
import com.wisp.app.repo.EventRepository
|
||||
import com.wisp.app.nostr.toHex
|
||||
import com.wisp.app.repo.KeyRepository
|
||||
import com.wisp.app.ui.util.GifToMp4Converter
|
||||
import com.wisp.app.ui.util.MediaCompressor
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -70,11 +72,15 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
|
||||
ImageTarget.PICTURE -> "Uploading avatar..."
|
||||
ImageTarget.BANNER -> "Uploading banner..."
|
||||
}
|
||||
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
val raw = contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: throw Exception("Cannot read file")
|
||||
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "bin"
|
||||
val url = blossomRepo.uploadMedia(bytes, mimeType, ext, signer)
|
||||
val srcMime = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val (bytes, mime, ext) = when {
|
||||
srcMime == "image/gif" -> GifToMp4Converter.convert(raw, getApplication())
|
||||
srcMime.startsWith("image/") -> MediaCompressor.compressForProfile(raw, srcMime).asTriple()
|
||||
else -> Triple(raw, srcMime, MimeTypeMap.getSingleton().getExtensionFromMimeType(srcMime) ?: "bin")
|
||||
}
|
||||
val url = blossomRepo.uploadMedia(bytes, mime, ext, signer)
|
||||
when (target) {
|
||||
ImageTarget.PICTURE -> _picture.value = url
|
||||
ImageTarget.BANNER -> _banner.value = url
|
||||
|
||||
Reference in New Issue
Block a user