android: 拍照MDT容器保存(MediaStore DCIM/MAG160C)+MP4录像(Surface编码) 阶段3b
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MDT thermal file container, mirroring the vendor ThermoScope layout
|
||||||
|
* (docs/android_app/reverse_apk_features.md §4, all little-endian):
|
||||||
|
*
|
||||||
|
* [JPEG section: raw jpg bytes at offset 0, padded to 4]
|
||||||
|
* [DDT section: 136B header (code 0x5BB5B55B + size + reserved 128) + body]
|
||||||
|
* [Tail: 152B — code 0x5BB5B57B + ddtOffset + reserved]
|
||||||
|
*
|
||||||
|
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
|
||||||
|
* 0x5BB5B55B camera info (0x38B from command 66b)
|
||||||
|
* 0x5BB5B55C second info block (0x38B from 66c, optional)
|
||||||
|
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
|
||||||
|
* 0x5BB5B55E text note (UTF-8, optional)
|
||||||
|
*/
|
||||||
|
object Mdt {
|
||||||
|
const val SECTION_DDT = 0x5BB5B55B
|
||||||
|
const val SECTION_TAIL = 0x5BB5B57B
|
||||||
|
|
||||||
|
const val BLOCK_INFO0 = 0x5BB5B55B
|
||||||
|
const val BLOCK_INFO1 = 0x5BB5B55C
|
||||||
|
const val BLOCK_FRAME = 0x5BB5B55D
|
||||||
|
const val BLOCK_TXT = 0x5BB5B55E
|
||||||
|
|
||||||
|
private fun align4(n: Int): Int = (n + 3) / 4 * 4
|
||||||
|
|
||||||
|
fun u32(b: ByteArray, off: Int): Int =
|
||||||
|
(b[off].toInt() and 0xFF) or
|
||||||
|
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||||
|
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||||
|
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||||
|
|
||||||
|
fun put32(dst: ByteArray, off: Int, v: Int) {
|
||||||
|
dst[off] = (v and 0xFF).toByte()
|
||||||
|
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
|
||||||
|
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
|
||||||
|
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose an MDT file.
|
||||||
|
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
|
||||||
|
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
|
||||||
|
* @param info1 second cached info block (0x38B) or null
|
||||||
|
* @param rawFrame latest raw USB frame (0x38-byte header + 38400B pixels)
|
||||||
|
* @param text UTF-8 note bytes or null
|
||||||
|
*/
|
||||||
|
fun compose(
|
||||||
|
jpg: ByteArray,
|
||||||
|
info0: ByteArray?,
|
||||||
|
info1: ByteArray?,
|
||||||
|
rawFrame: ByteArray?,
|
||||||
|
text: ByteArray? = null,
|
||||||
|
): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
|
||||||
|
out.write(jpg, 0, jpg.size)
|
||||||
|
repeat(align4(jpg.size) - jpg.size) { out.write(0) }
|
||||||
|
|
||||||
|
// --- DDT section ---
|
||||||
|
val ddtOffset = out.size()
|
||||||
|
val body = ByteArrayOutputStream()
|
||||||
|
fun emit(magic: Int, data: ByteArray) {
|
||||||
|
val n = align4(data.size)
|
||||||
|
val b = ByteArray(8 + n)
|
||||||
|
put32(b, 0, magic)
|
||||||
|
put32(b, 4, n)
|
||||||
|
System.arraycopy(data, 0, b, 8, data.size)
|
||||||
|
body.write(b, 0, b.size)
|
||||||
|
}
|
||||||
|
info0?.let { emit(BLOCK_INFO0, it) }
|
||||||
|
info1?.let { emit(BLOCK_INFO1, it) }
|
||||||
|
val raw = rawFrame
|
||||||
|
if (raw != null && raw.size >= 0x1C + 38400) {
|
||||||
|
emit(BLOCK_FRAME, raw.copyOfRange(0x1C, 0x1C + 38400))
|
||||||
|
}
|
||||||
|
text?.let { emit(BLOCK_TXT, it) }
|
||||||
|
|
||||||
|
val bodyBytes = body.toByteArray()
|
||||||
|
val header = ByteArray(0x88)
|
||||||
|
put32(header, 0, SECTION_DDT)
|
||||||
|
put32(header, 4, bodyBytes.size)
|
||||||
|
out.write(header, 0, 0x88)
|
||||||
|
out.write(bodyBytes, 0, bodyBytes.size)
|
||||||
|
|
||||||
|
// --- Tail ---
|
||||||
|
val tail = ByteArray(152)
|
||||||
|
put32(tail, 0, SECTION_TAIL)
|
||||||
|
put32(tail, 4, ddtOffset)
|
||||||
|
out.write(tail, 0, 152)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an MDT file produced by [compose] (or any file whose last 152
|
||||||
|
* bytes carry a valid tail). Returns the sections or null. */
|
||||||
|
fun parse(bytes: ByteArray): Parsed? {
|
||||||
|
if (bytes.size < 152) return null
|
||||||
|
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
|
||||||
|
if (u32(tail, 0) != SECTION_TAIL) return null
|
||||||
|
val ddtOffset = u32(tail, 4)
|
||||||
|
if (ddtOffset <= 0 || ddtOffset + 0x88 > bytes.size - 152) return null
|
||||||
|
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
|
||||||
|
val bodySize = u32(bytes, ddtOffset + 4)
|
||||||
|
val bodyStart = ddtOffset + 0x88
|
||||||
|
if (bodyStart + bodySize > bytes.size - 152) return null
|
||||||
|
val blocks = HashMap<Int, ByteArray>()
|
||||||
|
var p = bodyStart
|
||||||
|
val end = bodyStart + bodySize
|
||||||
|
while (p + 8 <= end) {
|
||||||
|
val magic = u32(bytes, p)
|
||||||
|
val len = u32(bytes, p + 4)
|
||||||
|
if (len < 0 || p + 8 + len > end) break
|
||||||
|
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
|
||||||
|
p += 8 + len
|
||||||
|
}
|
||||||
|
return Parsed(
|
||||||
|
jpg = bytes.copyOfRange(0, ddtOffset),
|
||||||
|
info0 = blocks[BLOCK_INFO0],
|
||||||
|
info1 = blocks[BLOCK_INFO1],
|
||||||
|
frame = blocks[BLOCK_FRAME],
|
||||||
|
text = blocks[BLOCK_TXT],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parsed(
|
||||||
|
val jpg: ByteArray,
|
||||||
|
val info0: ByteArray?,
|
||||||
|
val info1: ByteArray?,
|
||||||
|
val frame: ByteArray?,
|
||||||
|
val text: ByteArray?,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMuxer
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
|
||||||
|
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
|
||||||
|
* handles color conversion; frames arrive as ARGB bitmaps.
|
||||||
|
*/
|
||||||
|
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
||||||
|
private val fps = 15
|
||||||
|
private val bitRate = 2_000_000
|
||||||
|
|
||||||
|
private var encoder: MediaCodec? = null
|
||||||
|
private var inputSurface: android.view.Surface? = null
|
||||||
|
private var muxer: MediaMuxer? = null
|
||||||
|
private var trackIndex = -1
|
||||||
|
private var muxerStarted = false
|
||||||
|
private val active = AtomicBoolean(false)
|
||||||
|
private val canvas = Canvas()
|
||||||
|
private val paint = Paint()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var outPath: File? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (active.get()) return true
|
||||||
|
return try {
|
||||||
|
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
|
||||||
|
)
|
||||||
|
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||||
|
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
||||||
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
|
||||||
|
}
|
||||||
|
val enc = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||||
|
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
val surface = enc.createInputSurface()
|
||||||
|
enc.start()
|
||||||
|
encoder = enc
|
||||||
|
inputSurface = surface
|
||||||
|
val tmp = File.createTempFile("mag160c", ".mp4")
|
||||||
|
muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||||
|
outPath = tmp
|
||||||
|
active.set(true)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
releaseAll()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRecording(): Boolean = active.get()
|
||||||
|
|
||||||
|
/** Push one frame bitmap (called from the frame callback thread). */
|
||||||
|
fun offerFrame(bmp: Bitmap) {
|
||||||
|
val surface = inputSurface ?: return
|
||||||
|
if (!active.get()) return
|
||||||
|
val c = surface.lockCanvas(null) ?: return
|
||||||
|
try {
|
||||||
|
c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
|
||||||
|
} finally {
|
||||||
|
surface.unlockCanvasAndPost(c)
|
||||||
|
}
|
||||||
|
drain(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop recording and finalize. Returns the output file. */
|
||||||
|
fun stop(): File? {
|
||||||
|
if (!active.getAndSet(false)) return null
|
||||||
|
val enc = encoder ?: return null
|
||||||
|
// drain with EOS
|
||||||
|
val idx = enc.dequeueInputBuffer(10_000)
|
||||||
|
if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||||
|
drain(true)
|
||||||
|
val f = outPath
|
||||||
|
runCatching { muxer?.stop() }
|
||||||
|
runCatching { muxer?.release() }
|
||||||
|
muxer = null
|
||||||
|
runCatching { enc.stop() }
|
||||||
|
runCatching { enc.release() }
|
||||||
|
encoder = null
|
||||||
|
inputSurface?.release()
|
||||||
|
inputSurface = null
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseAll() {
|
||||||
|
runCatching { encoder?.stop() }
|
||||||
|
runCatching { encoder?.release() }
|
||||||
|
encoder = null
|
||||||
|
runCatching { inputSurface?.release() }
|
||||||
|
inputSurface = null
|
||||||
|
runCatching { muxer?.stop() }
|
||||||
|
runCatching { muxer?.release() }
|
||||||
|
muxer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drain(end: Boolean) {
|
||||||
|
val enc = encoder ?: return
|
||||||
|
val mux = muxer ?: return
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
while (true) {
|
||||||
|
val outIdx = enc.dequeueOutputBuffer(info, if (end) 10_000 else 0)
|
||||||
|
when {
|
||||||
|
outIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!end) return
|
||||||
|
outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
|
trackIndex = mux.addTrack(enc.outputFormat)
|
||||||
|
mux.start()
|
||||||
|
muxerStarted = true
|
||||||
|
}
|
||||||
|
outIdx >= 0 -> {
|
||||||
|
if (info.size > 0 && muxerStarted &&
|
||||||
|
info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0
|
||||||
|
) {
|
||||||
|
val ob = enc.getOutputBuffer(outIdx)
|
||||||
|
if (ob != null) mux.writeSampleData(trackIndex, ob, info)
|
||||||
|
}
|
||||||
|
enc.releaseOutputBuffer(outIdx, false)
|
||||||
|
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
|
||||||
|
}
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
|
||||||
|
* visible, no rogue folders). The stored file is a self-contained MDT
|
||||||
|
* container (JPG + temperature frame + note) named by capture time.
|
||||||
|
*/
|
||||||
|
object PhotoSaver {
|
||||||
|
private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
|
||||||
|
|
||||||
|
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
|
||||||
|
|
||||||
|
/** Encode a rendered ARGB frame to JPEG bytes. */
|
||||||
|
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
|
||||||
|
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.setPixels(frame, 0, w, 0, 0, w, h)
|
||||||
|
val out = ByteArrayOutputStream(w * h / 4)
|
||||||
|
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||||
|
bmp.recycle()
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save an MDT container into MediaStore. Returns the media uri string. */
|
||||||
|
fun saveMdt(
|
||||||
|
context: Context,
|
||||||
|
mdt: ByteArray,
|
||||||
|
displayName: String,
|
||||||
|
): String? {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
|
||||||
|
) ?: return null
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)?.use { it.write(mdt) }
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return uri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save a plain JPEG (no MDT wrapper). */
|
||||||
|
fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uri = context.contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
|
||||||
|
) ?: return null
|
||||||
|
try {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { it.write(jpg) }
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
|
||||||
|
context.contentResolver.update(uri, done, null, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
context.contentResolver.delete(uri, null, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return uri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
|
private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
|
||||||
var showPalette by remember { mutableStateOf(false) }
|
var showPalette by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
Row(
|
Row(
|
||||||
@@ -104,9 +105,18 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
|
|||||||
IconButton(onClick = { showPalette = true }) {
|
IconButton(onClick = { showPalette = true }) {
|
||||||
Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板")
|
Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板")
|
||||||
}
|
}
|
||||||
IconButton(onClick = { /* capture: phase 3b */ }) {
|
IconButton(onClick = {
|
||||||
|
val ctx = context
|
||||||
|
vm.capturePhoto(ctx)
|
||||||
|
}) {
|
||||||
Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照")
|
Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照")
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
val ctx = context
|
||||||
|
vm.toggleRecording(ctx)
|
||||||
|
}) {
|
||||||
|
Icon(painterResource(R.drawable.ic_record), contentDescription = "录像")
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = Palettes.NAMES[state.paletteIndex],
|
text = Palettes.NAMES[state.paletteIndex],
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
session.setListener(sessionListener)
|
session.setListener(sessionListener)
|
||||||
|
// push frames into the MP4 recorder while recording
|
||||||
|
session.recorderHook = { argb ->
|
||||||
|
val rec = recorder
|
||||||
|
if (rec != null && rec.isRecording()) {
|
||||||
|
val bmp = android.graphics.Bitmap.createBitmap(320, 240, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.setPixels(argb, 0, 320, 0, 0, 320, 240)
|
||||||
|
rec.offerFrame(bmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Begin USB permission flow, then start streaming. */
|
/** Begin USB permission flow, then start streaming. */
|
||||||
@@ -86,6 +95,44 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
|
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */
|
||||||
|
fun capturePhoto(context: android.content.Context) {
|
||||||
|
val frame = latestFrame ?: return
|
||||||
|
val s = session
|
||||||
|
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
|
||||||
|
val mdt = com.mag160c.thermal.media.Mdt.compose(
|
||||||
|
jpg = jpg,
|
||||||
|
info0 = s.lastInfo0,
|
||||||
|
info1 = s.lastInfo1,
|
||||||
|
rawFrame = s.lastRawFrame,
|
||||||
|
)
|
||||||
|
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
|
||||||
|
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
|
||||||
|
)
|
||||||
|
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
|
||||||
|
|
||||||
|
/** Toggle MP4 recording of the live stream. */
|
||||||
|
fun toggleRecording(context: android.content.Context) {
|
||||||
|
val rec = recorder
|
||||||
|
if (rec == null) {
|
||||||
|
val r = com.mag160c.thermal.media.Mp4Recorder()
|
||||||
|
if (r.start()) {
|
||||||
|
recorder = r
|
||||||
|
_state.value = _state.value.copy(status = "recording")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val file = rec.stop()
|
||||||
|
recorder = null
|
||||||
|
if (file != null) {
|
||||||
|
val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}"
|
||||||
|
_state.value = _state.value.copy(status = "rec_done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Update per-frame temperature stats (called on a slow timer). */
|
/** Update per-frame temperature stats (called on a slow timer). */
|
||||||
fun refreshTemps() {
|
fun refreshTemps() {
|
||||||
if (!session.isStreaming()) return
|
if (!session.isStreaming()) return
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class IrSession(context: Context) {
|
|||||||
listener = l
|
listener = l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional per-frame hook (MP4 recording), runs on the reader thread. */
|
||||||
|
@Volatile
|
||||||
|
var recorderHook: ((IntArray) -> Unit)? = null
|
||||||
|
|
||||||
fun isStreaming(): Boolean = running.get()
|
fun isStreaming(): Boolean = running.get()
|
||||||
|
|
||||||
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
|
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
|
||||||
@@ -58,6 +62,15 @@ class IrSession(context: Context) {
|
|||||||
var lastRawFrame: ByteArray? = null
|
var lastRawFrame: ByteArray? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Cached 66b/66c camera info blocks (0x38B each) for the MDT DDT section. */
|
||||||
|
@Volatile
|
||||||
|
var lastInfo0: ByteArray? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastInfo1: ByteArray? = null
|
||||||
|
private set
|
||||||
|
|
||||||
fun identitySnapshot(): CameraIdentity = identity.copy()
|
fun identitySnapshot(): CameraIdentity = identity.copy()
|
||||||
|
|
||||||
/** Connect + start the live stream. Must be called after USB permission. */
|
/** Connect + start the live stream. Must be called after USB permission. */
|
||||||
@@ -122,8 +135,12 @@ class IrSession(context: Context) {
|
|||||||
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
|
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
|
||||||
if (n <= 3) return
|
if (n <= 3) return
|
||||||
val magic = MagProtocol.u32(buf, 0)
|
val magic = MagProtocol.u32(buf, 0)
|
||||||
|
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
||||||
|
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
||||||
|
}
|
||||||
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
|
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
|
||||||
val payload = buf.copyOfRange(4, n)
|
val payload = buf.copyOfRange(4, n)
|
||||||
|
lastInfo0 = payload.copyOf(0x38)
|
||||||
val newIdentity = CameraIdentity(
|
val newIdentity = CameraIdentity(
|
||||||
pid = MagProtocol.u32(payload, 0),
|
pid = MagProtocol.u32(payload, 0),
|
||||||
serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or
|
serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or
|
||||||
@@ -155,7 +172,10 @@ class IrSession(context: Context) {
|
|||||||
while (len > 0 && running.get()) {
|
while (len > 0 && running.get()) {
|
||||||
lastRawFrame = frameBuf.copyOf()
|
lastRawFrame = frameBuf.copyOf()
|
||||||
val rendered = pipe.frame(frameBuf, true, out)
|
val rendered = pipe.frame(frameBuf, true, out)
|
||||||
if (rendered) listener?.onFrameReady(out)
|
if (rendered) {
|
||||||
|
listener?.onFrameReady(out)
|
||||||
|
recorderHook?.invoke(out)
|
||||||
|
}
|
||||||
len = stream.push(noop, 0, frameBuf)
|
len = stream.push(noop, 0, frameBuf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M12,4C7.31,4 3.5,7.81 3.5,12.5S7.31,21 12,21s8.5,-3.81 8.5,-8.5S16.69,4 12,4zM12,18c-3.04,0 -5.5,-2.46 -5.5,-5.5S8.96,7 12,7s5.5,2.46 5.5,5.5S15.04,18 12,18zM12,17c2.49,0 4.5,-2.01 4.5,-4.5S14.49,8 12,8s-4.5,2.01 -4.5,4.5S9.51,17 12,17z" /></vector>
|
||||||
Reference in New Issue
Block a user