diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt index c6706ba4..eb113c3d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt @@ -1,6 +1,8 @@ package com.utun.chat.data import android.media.MediaMetadataRetriever +import android.graphics.Bitmap +import android.os.Build import android.os.Handler import android.os.Looper import java.util.concurrent.ConcurrentHashMap @@ -11,6 +13,7 @@ internal data class VideoInfo(val width: Int, val height: Int, val rotation: Int val displayHeight: Int get() = if (rotation == 90 || rotation == 270) width else height val aspect: Float get() = if (displayHeight > 0) displayWidth.toFloat() / displayHeight else 0f } +internal data class VideoFilePreview(val bitmap: Bitmap?, val durationMs: Int) /** Один IO caller читает metadata внутри use; после завершения чтения допускается только повтор close. * Неудавшийся release удерживает retriever до SDK ACK, retry не обращается к UI/native core. */ @@ -35,6 +38,23 @@ internal class VideoFileMetadata : AutoCloseable { require(width > 0 && height > 0 && rotation in listOf(0, 90, 180, 270)) { "Invalid video geometry ${width}x$height rotation=$rotation" } return VideoInfo(width, height, rotation) } + /** IO: thumbnail не больше 640 по каждой стороне; Bitmap принадлежит caller, duration в мс. + * API 26 сначала извлекает полный кадр и уменьшает до передачи UI; null bitmap не запрещает воспроизведение. */ + @Synchronized fun preview(path: String): VideoFilePreview { + check(!closing) { "Metadata reader closing" } + val current = checkNotNull(retriever) { "Metadata reader closed" } + current.setDataSource(path) + val duration = current.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val raw = if (Build.VERSION.SDK_INT >= 27) current.getScaledFrameAtTime(1_000_000, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, 640, 640) else current.getFrameAtTime(1_000_000) + val image = if (raw != null && maxOf(raw.width, raw.height) > 640) { + val scale = 640.0 / maxOf(raw.width, raw.height) + Bitmap.createScaledBitmap(raw, (raw.width * scale).toInt().coerceAtLeast(1), + (raw.height * scale).toInt().coerceAtLeast(1), true).also { if (it !== raw) raw.recycle() } + } else raw + if (image == null) LogManager.addLog("WARN", "VideoPlayer", "Video preview has no frame owner=${System.identityHashCode(this)}") + return VideoFilePreview(image, duration) + } @Synchronized override fun close() { closing = true val current = retriever ?: return diff --git a/tools/chatgui-android/video-tests/src/main/kotlin/android/graphics/Bitmap.kt b/tools/chatgui-android/video-tests/src/main/kotlin/android/graphics/Bitmap.kt new file mode 100644 index 00000000..8295aa55 --- /dev/null +++ b/tools/chatgui-android/video-tests/src/main/kotlin/android/graphics/Bitmap.kt @@ -0,0 +1,10 @@ +package android.graphics + +/** Только размеры и владение промежуточным кадром, без пикселей/драйвера. */ +class Bitmap(val width: Int, val height: Int) { + var recycled = false + fun recycle() { recycled = true } + companion object { + fun createScaledBitmap(source: Bitmap, width: Int, height: Int, filter: Boolean) = Bitmap(width, height) + } +} diff --git a/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt b/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt index fbda6a83..cd08b264 100644 --- a/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt +++ b/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt @@ -48,6 +48,7 @@ class MediaPlayer { class MediaMetadataRetriever { companion object { const val METADATA_KEY_VIDEO_WIDTH = 18; const val METADATA_KEY_VIDEO_HEIGHT = 19; const val METADATA_KEY_VIDEO_ROTATION = 24 + const val METADATA_KEY_DURATION = 9; const val OPTION_CLOSEST_SYNC = 2 val created = mutableListOf() var nextFailures = emptySet() } @@ -55,10 +56,22 @@ class MediaMetadataRetriever { var released = false var releases = 0 var width = "1280"; var height = "720"; var rotation = "90" + var lastFrame: android.graphics.Bitmap? = null + var scaledReads = 0 init { created.add(this) } fun setDataSource(path: String) { check("source" !in failures) { "Metadata source failed" } } fun extractMetadata(key: Int): String? = when (key) { - METADATA_KEY_VIDEO_WIDTH -> width; METADATA_KEY_VIDEO_HEIGHT -> height; METADATA_KEY_VIDEO_ROTATION -> rotation; else -> null + METADATA_KEY_VIDEO_WIDTH -> width; METADATA_KEY_VIDEO_HEIGHT -> height; METADATA_KEY_VIDEO_ROTATION -> rotation + METADATA_KEY_DURATION -> "10000"; else -> null + } + fun getFrameAtTime(time: Long): android.graphics.Bitmap? { + check("frame" !in failures) + return android.graphics.Bitmap(width.toInt(), height.toInt()).also { lastFrame = it } + } + fun getScaledFrameAtTime(time: Long, option: Int, maxWidth: Int, maxHeight: Int): android.graphics.Bitmap? { + scaledReads++; check("frame" !in failures) + val scale = minOf(maxWidth.toDouble() / width.toInt(), maxHeight.toDouble() / height.toInt(), 1.0) + return android.graphics.Bitmap((width.toInt() * scale).toInt(), (height.toInt() * scale).toInt()).also { lastFrame = it } } fun release() { releases++; check("release" !in failures) { "Metadata release failed" }; released = true