Skip to main content

priv/native/android/MobVideoBridge.kt

// mob_video plugin — Android bridge (MediaExtractor / MediaMuxer /
// MediaMetadataRetriever). Lives in the plugin's own package; mob_dev copies it
// into the app Kotlin sourceSet and MobPluginBootstrap.registerAll() calls
// register() at startup. No Activity/permission handoff needed — every op works
// on plain file paths.
//
// The native thunks (nativeRegister + the nativeDeliver* hooks) are exported
// directly from the sibling zig NIF mob_video_nif.zig. All work runs on a
// single-thread worker so the BEAM scheduler thread that called the NIF never
// blocks; results are delivered back by pid.
//
// Error codes (nativeDeliverVideoError): 0 not_found, 1 unsupported,
// 2 io_error, 3 bad_range.
package io.mob.video

import android.graphics.Bitmap
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.util.concurrent.Executors

object MobVideoBridge {
    private val worker = Executors.newSingleThreadExecutor()

    private const val ERR_NOT_FOUND = 0
    private const val ERR_UNSUPPORTED = 1
    private const val ERR_IO = 2
    private const val ERR_BAD_RANGE = 3

    @JvmStatic external fun nativeRegister()

    @JvmStatic external fun nativeDeliverVideoInfo(
        pid: Long,
        durationMs: Long,
        width: Int,
        height: Int,
        rotation: Int,
        hasAudio: Int,
        bitrate: Long,
        frameRate: Double,
    )

    @JvmStatic external fun nativeDeliverClipped(pid: Long, path: String, durationMs: Long)

    @JvmStatic external fun nativeDeliverThumbnail(pid: Long, path: String, width: Int, height: Int)

    @JvmStatic external fun nativeDeliverAudio(pid: Long, path: String)

    @JvmStatic external fun nativeDeliverVideoError(pid: Long, code: Int)

    @JvmStatic
    fun register() {
        nativeRegister()
    }

    // ── Probe ────────────────────────────────────────────────────────────
    @JvmStatic
    fun video_probe(pid: Long, src: String) = worker.execute {
        if (!File(src).exists()) {
            nativeDeliverVideoError(pid, ERR_NOT_FOUND); return@execute
        }
        val r = MediaMetadataRetriever()
        try {
            r.setDataSource(src)
            val duration = r.meta(MediaMetadataRetriever.METADATA_KEY_DURATION)
            val width = r.meta(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
            val height = r.meta(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
            if (width == 0L || height == 0L) {
                nativeDeliverVideoError(pid, ERR_UNSUPPORTED); return@execute
            }
            val rotation = r.meta(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
            val bitrate = r.meta(MediaMetadataRetriever.METADATA_KEY_BITRATE)
            val hasAudio = r.metaStr(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes"
            val frameCount = r.meta(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)
            val fps = if (frameCount > 0 && duration > 0) frameCount * 1000.0 / duration else 0.0
            nativeDeliverVideoInfo(
                pid, duration, width.toInt(), height.toInt(), rotation.toInt(),
                if (hasAudio) 1 else 0, bitrate, fps,
            )
        } catch (e: Exception) {
            nativeDeliverVideoError(pid, ERR_UNSUPPORTED)
        } finally {
            r.release()
        }
    }

    // ── Clip (stream copy, no re-encode) ──────────────────────────────────
    @JvmStatic
    fun video_clip(pid: Long, src: String, dst: String, startMs: Long, endMs: Long) = worker.execute {
        if (!File(src).exists()) {
            nativeDeliverVideoError(pid, ERR_NOT_FOUND); return@execute
        }
        // A positive end that isn't after the start is an empty/inverted range.
        if (endMs in 1..startMs) {
            nativeDeliverVideoError(pid, ERR_BAD_RANGE); return@execute
        }
        remux(pid, src, dst, startMs, endMs, audioOnly = false)
    }

    // ── Extract audio (stream copy of the audio track) ────────────────────
    @JvmStatic
    fun video_extract_audio(pid: Long, src: String, dst: String) = worker.execute {
        if (!File(src).exists()) {
            nativeDeliverVideoError(pid, ERR_NOT_FOUND); return@execute
        }
        remux(pid, src, dst, 0, 0, audioOnly = true)
    }

    // ── Thumbnail (single decoded frame -> JPEG) ──────────────────────────
    @JvmStatic
    fun video_thumbnail(pid: Long, src: String, dst: String, atMs: Long, maxWidth: Long) = worker.execute {
        if (!File(src).exists()) {
            nativeDeliverVideoError(pid, ERR_NOT_FOUND); return@execute
        }
        val r = MediaMetadataRetriever()
        try {
            r.setDataSource(src)
            var bmp: Bitmap = r.getFrameAtTime(atMs * 1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
                ?: run { nativeDeliverVideoError(pid, ERR_UNSUPPORTED); return@execute }
            if (maxWidth > 0) bmp = scaleToFit(bmp, maxWidth.toInt())
            FileOutputStream(dst).use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
            nativeDeliverThumbnail(pid, dst, bmp.width, bmp.height)
        } catch (e: Exception) {
            nativeDeliverVideoError(pid, ERR_IO)
        } finally {
            r.release()
        }
    }

    // ── Shared remux: select tracks, optionally bound to [startUs, endUs) ──
    private fun remux(pid: Long, src: String, dst: String, startMs: Long, endMs: Long, audioOnly: Boolean) {
        val extractor = MediaExtractor()
        var muxer: MediaMuxer? = null
        try {
            extractor.setDataSource(src)
            muxer = MediaMuxer(dst, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
            val indexMap = HashMap<Int, Int>()
            var maxBuf = 0
            for (t in 0 until extractor.trackCount) {
                val format = extractor.getTrackFormat(t)
                val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
                val keep = if (audioOnly) mime.startsWith("audio/") else mime.startsWith("video/") || mime.startsWith("audio/")
                if (!keep) continue
                extractor.selectTrack(t)
                indexMap[t] = muxer.addTrack(format)
                if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
                    maxBuf = maxOf(maxBuf, format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
                }
            }
            if (indexMap.isEmpty()) {
                nativeDeliverVideoError(pid, ERR_UNSUPPORTED); return
            }
            if (maxBuf <= 0) maxBuf = 1 shl 20
            applyOrientation(src, muxer)
            muxer.start()

            val startUs = startMs * 1000
            val endUs = if (endMs > 0) endMs * 1000 else Long.MAX_VALUE
            if (startUs > 0) extractor.seekTo(startUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)

            val buffer = ByteBuffer.allocate(maxBuf)
            val info = MediaCodec.BufferInfo()
            var firstUs = -1L
            var lastUs = 0L
            while (true) {
                val sampleTime = extractor.sampleTime
                if (sampleTime < 0) break
                if (sampleTime > endUs) break
                val size = extractor.readSampleData(buffer, 0)
                if (size < 0) break
                if (firstUs < 0) firstUs = sampleTime
                lastUs = sampleTime
                info.offset = 0
                info.size = size
                info.presentationTimeUs = sampleTime
                info.flags = if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
                    MediaCodec.BUFFER_FLAG_KEY_FRAME
                } else {
                    0
                }
                indexMap[extractor.sampleTrackIndex]?.let { muxer.writeSampleData(it, buffer, info) }
                extractor.advance()
            }
            muxer.stop()
            val durationMs = if (firstUs in 0..lastUs) (lastUs - firstUs) / 1000 else 0
            if (audioOnly) nativeDeliverAudio(pid, dst) else nativeDeliverClipped(pid, dst, durationMs)
        } catch (e: IllegalArgumentException) {
            // setDataSource / addTrack reject -> unreadable or unsupported container.
            nativeDeliverVideoError(pid, ERR_UNSUPPORTED)
        } catch (e: Exception) {
            nativeDeliverVideoError(pid, ERR_IO)
        } finally {
            runCatching { muxer?.release() }
            runCatching { extractor.release() }
        }
    }

    private fun applyOrientation(src: String, muxer: MediaMuxer) {
        val r = MediaMetadataRetriever()
        try {
            r.setDataSource(src)
            val rot = r.meta(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION).toInt()
            if (rot != 0) muxer.setOrientationHint(rot)
        } catch (e: Exception) {
            // Orientation is a hint; a missing value is harmless.
        } finally {
            r.release()
        }
    }

    private fun scaleToFit(bmp: Bitmap, maxSide: Int): Bitmap {
        val longest = maxOf(bmp.width, bmp.height)
        if (longest <= maxSide) return bmp
        val scale = maxSide.toFloat() / longest
        val w = (bmp.width * scale).toInt().coerceAtLeast(1)
        val h = (bmp.height * scale).toInt().coerceAtLeast(1)
        return Bitmap.createScaledBitmap(bmp, w, h, true)
    }

    private fun MediaMetadataRetriever.meta(key: Int): Long =
        extractMetadata(key)?.toLongOrNull() ?: 0L

    private fun MediaMetadataRetriever.metaStr(key: Int): String? = extractMetadata(key)
}