Newer
Older
SmTIAS-Capture / android / app / src / main / kotlin / com / example / smtias_capture / RawCapturePlugin.kt
package com.example.smtias_capture

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.ImageFormat
import android.hardware.camera2.*
import android.hardware.camera2.params.ColorSpaceTransform
import android.hardware.camera2.params.RggbChannelVector
import android.hardware.camera2.params.TonemapCurve
import android.media.ImageReader
import android.media.MediaActionSound
import android.media.MediaScannerConnection
import android.media.RingtoneManager
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt

/** フレーム取得後に処理するコールバックの型エイリアス.*/
private typealias FrameProcessor = (
    yBytes: ByteArray, uBytes: ByteArray, vBytes: ByteArray,
    width: Int, height: Int,
    yRowStride: Int, uvRowStride: Int, uvPixelStride: Int,
    reader: ImageReader, camera: CameraDevice,
    result: MethodChannel.Result
) -> Unit

/**
 * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで
 * フル解像度の画像を 1 フレームキャプチャする.
 */
class RawCapturePlugin(private val context: Context) : MethodChannel.MethodCallHandler {

    private var backgroundThread: HandlerThread? = null
    private var backgroundHandler: Handler? = null
    private var cameraDevice: CameraDevice? = null
    private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() }

    private companion object {
        const val DEFAULT_EXPOSURE_TIME_NS = 8_333_333L  // 1/120s
        const val DEFAULT_SENSITIVITY = 40
        const val DEFAULT_FRAME_DURATION_NS = 33_333_333L
        const val RAW_WIDTH = 3264
        const val RAW_HEIGHT = 2448
        const val FRAMES_TO_DISCARD = 2
    }

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        when (call.method) {
            "captureFullResolutionPng" -> captureFullResolutionPng(result)
            "captureQuantitativeDng" -> {
                val baseName = call.argument<String>("baseName")
                val directoryPath = call.argument<String>("directoryPath")
                if (baseName == null || directoryPath == null) {
                    result.error("INVALID_ARG", "baseName と directoryPath が必要です", null)
                } else {
                    captureQuantitativeDng(baseName, directoryPath, result)
                }
            }
            "convertYuvToJpeg" -> convertYuvToJpeg(call, result)
            "scanFile" -> {
                val path = call.argument<String>("path")
                if (path != null) {
                    MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf("image/png")) { _, _ ->
                        result.success(null)
                    }
                } else {
                    result.error("INVALID_PATH", "パスが指定されていません", null)
                }
            }
            "playShutterSound" -> {
                try {
                    mediaActionSound.play(MediaActionSound.SHUTTER_CLICK)
                    result.success(null)
                } catch (e: Exception) {
                    result.error("SOUND_ERROR", "シャッター音の再生に失敗: ${e.message}", null)
                }
            }
            "playSaveCompleteSound" -> {
                try {
                    val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
                    val ringtone = RingtoneManager.getRingtone(context, notification)
                    ringtone?.play()
                    result.success(null)
                } catch (e: Exception) {
                    result.error("SOUND_ERROR", "保存完了音の再生に失敗: ${e.message}", null)
                }
            }
            "getCameraDiagnostics" -> getCameraDiagnostics(result)
            else -> result.notImplemented()
        }
    }

    /// YUV_420_888 を NV21 経由で JPEG 化して返す(実処理は [YuvJpegConverter]).
    private fun convertYuvToJpeg(call: MethodCall, result: MethodChannel.Result) {
        try {
            val jpegBytes = YuvJpegConverter.convert(
                width = call.argument<Int>("width")!!,
                height = call.argument<Int>("height")!!,
                yPlane = call.argument<ByteArray>("yPlane")!!,
                uPlane = call.argument<ByteArray>("uPlane")!!,
                vPlane = call.argument<ByteArray>("vPlane")!!,
                yRowStride = call.argument<Int>("yRowStride")!!,
                uvRowStride = call.argument<Int>("uvRowStride")!!,
                uvPixelStride = call.argument<Int>("uvPixelStride")!!,
                rotation = call.argument<Int>("rotation") ?: 0,
                mirror = call.argument<Boolean>("mirror") ?: false,
                quality = call.argument<Int>("quality") ?: 85,
            )
            result.success(jpegBytes)
        } catch (e: Exception) {
            result.error("CONVERT_ERROR", "YUV to JPEG 変換に失敗: ${e.message}", null)
        }
    }

    private fun captureFullResolutionPng(result: MethodChannel.Result) {
        openCameraAndCapture(result) { yBytes, uBytes, vBytes, width, height, yRowStride, uvRowStride, uvPixelStride, reader, camera, res ->
            // YUV → RGB 変換 + 90° 時計回り回転を統合
            // 元の(x,y) → 回転後の(height-1-y, x)
            // 出力サイズ: width_out=height, height_out=width
            val rotatedPixels = IntArray(width * height)
            for (y in 0 until height) {
                for (x in 0 until width) {
                    val yIndex = y * yRowStride + x
                    val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride

                    val yValue = (yBytes[yIndex].toInt() and 0xFF).toDouble()
                    val uValue = (uBytes[uvIndex].toInt() and 0xFF).toDouble()
                    val vValue = (vBytes[uvIndex].toInt() and 0xFF).toDouble()

                    val r = (yValue + 1.402 * (vValue - 128)).roundToInt().coerceIn(0, 255)
                    val g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)).roundToInt().coerceIn(0, 255)
                    val b = (yValue + 1.772 * (uValue - 128)).roundToInt().coerceIn(0, 255)

                    rotatedPixels[x * height + (height - 1 - y)] = Color.rgb(r, g, b)
                }
            }

            val rotatedWidth = height
            val rotatedHeight = width
            val bitmap = Bitmap.createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888)
            bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight)

            // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化)
            val pngBytes = PngEncoder.encodeUncompressed(bitmap)
            bitmap.recycle()

            reader.close()
            camera.close()
            cameraDevice = null

            Handler(context.mainLooper).post {
                res.success(pngBytes)
                stopBackgroundThread()
            }
        }
    }

    /**
     * 定量モード撮影.RAW_SENSOR (DNG) で保存し,メタデータと LSC マップを返す.
     *
     * CONTROL_MODE=OFF など完全マニュアル制御で連続 3 フレームを投げ,
     * 最初の 2 フレーム(設定反映待ち)を捨てて 3 枚目を DNG 化する.
     * DNG ファイルは Kotlin 側で [directoryPath]/[baseName].dng に直接書き込む.
     */
    private fun captureQuantitativeDng(
        baseName: String,
        directoryPath: String,
        result: MethodChannel.Result,
    ) {
        startBackgroundThread()

        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager

        val cameraId = findFrontCameraId(cameraManager)

        if (cameraId == null) {
            returnError(result, "NO_CAMERA", "フロントカメラが見つかりません")
            return
        }

        val characteristics = cameraManager.getCameraCharacteristics(cameraId)

        // RAW_SENSOR サポート確認
        val capabilities =
            characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
        val supportsRaw =
            capabilities?.contains(
                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW
            ) == true
        if (!supportsRaw) {
            returnError(result, "NOT_SUPPORTED", "このカメラは RAW_SENSOR をサポートしていません")
            return
        }

        val imageReader = ImageReader.newInstance(
            RAW_WIDTH, RAW_HEIGHT, ImageFormat.RAW_SENSOR, 3
        )

        var resultSent = false

        try {
            cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
                override fun onOpened(camera: CameraDevice) {
                    cameraDevice = camera
                    try {
                        camera.createCaptureSession(
                            listOf(imageReader.surface),
                            object : CameraCaptureSession.StateCallback() {
                                override fun onConfigured(session: CameraCaptureSession) {
                                    try {
                                        openCameraAndCaptureRaw(
                                            camera, session, imageReader, characteristics,
                                            cameraId, baseName, directoryPath,
                                            result, { sent -> resultSent = sent }
                                        )
                                    } catch (e: Exception) {
                                        if (!resultSent) {
                                            resultSent = true
                                            cleanup()
                                            imageReader.close()
                                            Handler(context.mainLooper).post {
                                                result.error(
                                                    "CAPTURE_ERROR",
                                                    "定量キャプチャに失敗: ${e.message}",
                                                    null
                                                )
                                            }
                                        }
                                    }
                                }

                                override fun onConfigureFailed(session: CameraCaptureSession) {
                                    if (!resultSent) {
                                        resultSent = true
                                        cleanup()
                                        imageReader.close()
                                        Handler(context.mainLooper).post {
                                            result.error(
                                                "SESSION_ERROR",
                                                "カメラセッションの設定に失敗",
                                                null
                                            )
                                        }
                                    }
                                }
                            },
                            backgroundHandler
                        )
                    } catch (e: Exception) {
                        if (!resultSent) {
                            resultSent = true
                            cleanup()
                            imageReader.close()
                            Handler(context.mainLooper).post {
                                result.error(
                                    "CAPTURE_ERROR",
                                    "定量キャプチャに失敗: ${e.message}",
                                    null
                                )
                            }
                        }
                    }
                }

                override fun onDisconnected(camera: CameraDevice) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    stopBackgroundThread()
                }

                override fun onError(camera: CameraDevice, error: Int) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    if (!resultSent) {
                        resultSent = true
                        Handler(context.mainLooper).post {
                            result.error("CAMERA_ERROR", "カメラエラー: $error", null)
                            stopBackgroundThread()
                        }
                    }
                }
            }, backgroundHandler)
        } catch (e: SecurityException) {
            imageReader.close()
            returnError(result, "PERMISSION_ERROR", "カメラの権限がありません")
        }
    }

    /**
     * 定量モード RAW キャプチャの実処理.
     *
     * session が onConfigured 後に呼ばれる.FRAMES_TO_DISCARD 枚を捨てた後,
     * 3 枚目の Image と TotalCaptureResult を DngCreator に渡して DNG バイト列を生成する.
     */
    private fun openCameraAndCaptureRaw(
        camera: CameraDevice,
        session: CameraCaptureSession,
        imageReader: ImageReader,
        characteristics: CameraCharacteristics,
        cameraId: String,
        baseName: String,
        directoryPath: String,
        result: MethodChannel.Result,
        setResultSent: (Boolean) -> Unit,
    ) {
        var capturedImage: android.media.Image? = null
        var captureResult: TotalCaptureResult? = null
        // 画像到着カウント(ImageAvailableListener 側)
        var imageArrivalCount = 0
        // CaptureResult 到着カウント(CaptureCallback 側)
        var resultArrivalCount = 0
        var resultSentFlag = false

        fun sendResultSafe(action: () -> Unit) {
            if (!resultSentFlag) {
                resultSentFlag = true
                setResultSent(true)
                action()
            }
        }

        // 完全マニュアル制御の CaptureRequest を構築
        val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
            addTarget(imageReader.surface)

            set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_OFF)
            set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF)
            set(CaptureRequest.SENSOR_EXPOSURE_TIME, DEFAULT_EXPOSURE_TIME_NS)
            set(CaptureRequest.SENSOR_SENSITIVITY, DEFAULT_SENSITIVITY)
            set(CaptureRequest.SENSOR_FRAME_DURATION, DEFAULT_FRAME_DURATION_NS)
            set(CaptureRequest.BLACK_LEVEL_LOCK, true)
            set(CaptureRequest.CONTROL_AWB_MODE, CaptureRequest.CONTROL_AWB_MODE_OFF)
            set(CaptureRequest.COLOR_CORRECTION_MODE,
                CaptureRequest.COLOR_CORRECTION_MODE_TRANSFORM_MATRIX)
            set(CaptureRequest.COLOR_CORRECTION_GAINS, RggbChannelVector(1.0f, 1.0f, 1.0f, 1.0f))
            // 単位行列(3×3,Rational 型)
            set(
                CaptureRequest.COLOR_CORRECTION_TRANSFORM,
                ColorSpaceTransform(
                    intArrayOf(
                        1, 1, 0, 1, 0, 1,
                        0, 1, 1, 1, 0, 1,
                        0, 1, 0, 1, 1, 1,
                    )
                )
            )
            set(CaptureRequest.NOISE_REDUCTION_MODE, CaptureRequest.NOISE_REDUCTION_MODE_OFF)
            set(CaptureRequest.EDGE_MODE, CaptureRequest.EDGE_MODE_OFF)
            set(CaptureRequest.TONEMAP_MODE, CaptureRequest.TONEMAP_MODE_CONTRAST_CURVE)
            // 線形トーンマップカーブ(各チャネル: (0,0)→(1,1))
            set(
                CaptureRequest.TONEMAP_CURVE,
                TonemapCurve(
                    floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f),
                    floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f),
                    floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f),
                )
            )
            set(CaptureRequest.SHADING_MODE, CaptureRequest.SHADING_MODE_HIGH_QUALITY)
            set(CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE,
                CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE_ON)
            // Hot pixel map を有効化(STATISTICS_HOT_PIXEL_MAP_MODE は Boolean 型)
            set(CaptureRequest.STATISTICS_HOT_PIXEL_MAP_MODE, true)
            set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF)
            set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF)
            set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
                CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF)
        }.build()

        val captureCallback = object : CameraCaptureSession.CaptureCallback() {
            override fun onCaptureCompleted(
                session: CameraCaptureSession,
                request: CaptureRequest,
                totalResult: TotalCaptureResult,
            ) {
                resultArrivalCount++
                // FRAMES_TO_DISCARD + 1 枚目(最後)だけ CaptureResult を保持
                if (resultArrivalCount == FRAMES_TO_DISCARD + 1) {
                    captureResult = totalResult
                    tryProcessRaw(
                        capturedImage, captureResult, imageReader, camera,
                        characteristics, cameraId, baseName, directoryPath,
                        result, ::sendResultSafe
                    )
                }
            }

            override fun onCaptureFailed(
                session: CameraCaptureSession,
                request: CaptureRequest,
                failure: CaptureFailure,
            ) {
                sendResultSafe {
                    capturedImage?.close()
                    imageReader.close()
                    cleanup()
                    Handler(context.mainLooper).post {
                        result.error("CAPTURE_FAILED", "キャプチャに失敗しました: ${failure.reason}", null)
                    }
                }
            }
        }

        imageReader.setOnImageAvailableListener({ reader ->
            val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
            imageArrivalCount++
            // 最初の FRAMES_TO_DISCARD 枚は捨てる(設定反映待ち)
            if (imageArrivalCount <= FRAMES_TO_DISCARD) {
                image.close()
                return@setOnImageAvailableListener
            }
            // FRAMES_TO_DISCARD + 1 枚目だけ保持
            if (capturedImage == null) {
                capturedImage = image
                tryProcessRaw(
                    capturedImage, captureResult, imageReader, camera,
                    characteristics, cameraId, baseName, directoryPath,
                    result, ::sendResultSafe
                )
            } else {
                image.close()
            }
        }, backgroundHandler)

        // 連続 3 回キャプチャを投げる
        repeat(FRAMES_TO_DISCARD + 1) {
            session.capture(captureRequest, captureCallback, backgroundHandler)
        }
    }

    /**
     * 画像と CaptureResult の両方が揃ったら DNG を生成して result に返す.
     *
     * 片方がまだない場合は何もしない(もう一方の到着を待つ).
     */
    private fun tryProcessRaw(
        image: android.media.Image?,
        captureResult: TotalCaptureResult?,
        imageReader: ImageReader,
        camera: CameraDevice,
        characteristics: CameraCharacteristics,
        cameraId: String,
        baseName: String,
        directoryPath: String,
        result: MethodChannel.Result,
        sendResultSafe: (() -> Unit) -> Unit,
    ) {
        if (image == null || captureResult == null) return

        sendResultSafe {
            try {
                // DNG をファイルに直接書き込む(MethodChannel 越しの巨大バイト転送を回避)
                val dir = File(directoryPath)
                if (!dir.exists()) dir.mkdirs()
                val dngFile = File(dir, "$baseName.dng")
                FileOutputStream(dngFile).use { fos ->
                    DngCreator(characteristics, captureResult).use { dngCreator ->
                        dngCreator.writeImage(fos, image)
                    }
                }
                Log.d("RawCapturePlugin", "DNG written: ${dngFile.length()} bytes to ${dngFile.absolutePath}")

                // 画素統計を計算(DNG 書き込み後・image.close() 前)
                val imageStatistics: Map<String, Any?>? = try {
                    ImageStatistics.compute(image)
                } catch (e: Exception) {
                    Log.w("RawCapturePlugin", "Image statistics computation failed", e)
                    null
                }

                // Hot pixel map を CaptureResult から取得
                val hotPixelMapData: Map<String, Any?> = try {
                    val hotPixelArray = captureResult.get(CaptureResult.STATISTICS_HOT_PIXEL_MAP)
                    mapOf(
                        "mode" to "ON",
                        "count" to (hotPixelArray?.size ?: 0),
                        "coordinates" to (hotPixelArray?.map { listOf(it.x, it.y) }
                            ?: emptyList<List<Int>>()),
                    )
                } catch (e: Exception) {
                    Log.w("RawCapturePlugin", "Hot pixel map retrieval failed", e)
                    mapOf("mode" to "ON", "count" to 0,
                        "coordinates" to emptyList<List<Int>>())
                }

                Log.d("RawCapturePlugin",
                    "Image stats: $imageStatistics, hot pixels: ${hotPixelMapData["count"]}")

                // LSC マップを取得
                val lscMap =
                    captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP)
                val lscData: List<List<Float>>? = lscMap?.let { map ->
                    val rows = map.rowCount
                    val cols = map.columnCount
                    val gainFactors = FloatArray(rows * cols * 4)
                    map.copyGainFactors(gainFactors, 0)
                    (0 until rows).map { row ->
                        gainFactors.slice(row * cols * 4 until (row + 1) * cols * 4)
                    }
                }

                // メタデータを構築
                val metadataMap = CaptureMetadata.build(
                    cameraId = cameraId,
                    characteristics = characteristics,
                    captureResult = captureResult,
                    image = image,
                    exposureTimeNs = DEFAULT_EXPOSURE_TIME_NS,
                    sensitivity = DEFAULT_SENSITIVITY,
                    frameDurationNs = DEFAULT_FRAME_DURATION_NS,
                    imageStatistics = imageStatistics,
                    hotPixelMap = hotPixelMapData,
                )

                image.close()
                imageReader.close()
                camera.close()
                cameraDevice = null

                val resultMap = mapOf<String, Any?>(
                    "dngPath" to dngFile.absolutePath,
                    "dngFileSize" to dngFile.length(),
                    "metadata" to metadataMap,
                    "lscMap" to lscData,
                    "lscMapRowCount" to lscMap?.rowCount,
                    "lscMapColumnCount" to lscMap?.columnCount,
                )

                Handler(context.mainLooper).post {
                    result.success(resultMap)
                    stopBackgroundThread()
                }
            } catch (e: Exception) {
                image.close()
                imageReader.close()
                cleanup()
                Handler(context.mainLooper).post {
                    result.error("DNG_ERROR", "DNG 生成に失敗: ${e.message}", null)
                    stopBackgroundThread()
                }
            }
        }
    }

    /** フロントカメラの ID を返す.見つからない場合は null を返す.*/
    private fun findFrontCameraId(cameraManager: CameraManager): String? =
        cameraManager.cameraIdList.firstOrNull { id ->
            val characteristics = cameraManager.getCameraCharacteristics(id)
            characteristics.get(CameraCharacteristics.LENS_FACING) ==
                CameraCharacteristics.LENS_FACING_FRONT
        }

    /**
     * フロントカメラを開いてフル解像度の YUV フレームを 1 枚取得し,[onFrame] を呼び出す.
     *
     * カメラのオープン・セッション作成・AE/AF 安定待機(1 秒)・本番キャプチャの
     * 共通フローを担う.フレーム取得後の処理は [onFrame] で差異化する.
     * [onFrame] 内で reader と camera のクローズおよび result への応答を行うこと.
     */
    private fun openCameraAndCapture(
        result: MethodChannel.Result,
        onFrame: FrameProcessor,
    ) {
        startBackgroundThread()

        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager

        val cameraId = findFrontCameraId(cameraManager)

        if (cameraId == null) {
            returnError(result, "NO_CAMERA", "フロントカメラが見つかりません")
            return
        }

        val characteristics = cameraManager.getCameraCharacteristics(cameraId)
        val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)

        if (streamConfigMap == null) {
            returnError(result, "NO_CONFIG", "カメラの設定を取得できません")
            return
        }

        val yuvSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888)
        val maxSize = yuvSizes.maxByOrNull { it.width * it.height }

        if (maxSize == null) {
            returnError(result, "NO_SIZE", "YUV の解像度を取得できません")
            return
        }

        val imageReader = ImageReader.newInstance(
            maxSize.width, maxSize.height, ImageFormat.YUV_420_888, 2
        )

        var resultSent = false

        try {
            cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
                override fun onOpened(camera: CameraDevice) {
                    cameraDevice = camera
                    try {
                        camera.createCaptureSession(
                            listOf(imageReader.surface),
                            object : CameraCaptureSession.StateCallback() {
                                override fun onConfigured(session: CameraCaptureSession) {
                                    try {
                                        // Phase 1: プレビューを流して AE/AF を安定させる
                                        val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
                                            addTarget(imageReader.surface)
                                            set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                                            set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF)
                                        }
                                        session.setRepeatingRequest(previewRequest.build(), null, backgroundHandler)

                                        // Phase 2: 1 秒後にリスナーを設定してから本番キャプチャ
                                        backgroundHandler?.postDelayed({
                                            try {
                                                session.stopRepeating()

                                                // プレビュー中のバッファを捨てる
                                                while (true) {
                                                    val stale = imageReader.acquireLatestImage()
                                                    if (stale != null) {
                                                        stale.close()
                                                    } else {
                                                        break
                                                    }
                                                }

                                                // ここでリスナーを設定(本番フレームのみ受信)
                                                imageReader.setOnImageAvailableListener({ reader ->
                                                    if (resultSent) return@setOnImageAvailableListener
                                                    val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
                                                    resultSent = true

                                                    try {
                                                        val yPlane = image.planes[0]
                                                        val uPlane = image.planes[1]
                                                        val vPlane = image.planes[2]

                                                        val width = image.width
                                                        val height = image.height

                                                        val yBytes = ByteArray(yPlane.buffer.remaining())
                                                        val uBytes = ByteArray(uPlane.buffer.remaining())
                                                        val vBytes = ByteArray(vPlane.buffer.remaining())

                                                        yPlane.buffer.get(yBytes)
                                                        uPlane.buffer.get(uBytes)
                                                        vPlane.buffer.get(vBytes)

                                                        val yRowStride = yPlane.rowStride
                                                        val uvRowStride = uPlane.rowStride
                                                        val uvPixelStride = uPlane.pixelStride

                                                        image.close()

                                                        onFrame(
                                                            yBytes, uBytes, vBytes,
                                                            width, height,
                                                            yRowStride, uvRowStride, uvPixelStride,
                                                            reader, camera, result
                                                        )
                                                    } catch (e: Exception) {
                                                        image.close()
                                                        reader.close()
                                                        cleanup()
                                                        Handler(context.mainLooper).post {
                                                            result.error("PROCESS_ERROR", "画像データの処理に失敗: ${e.message}", null)
                                                        }
                                                    }
                                                }, backgroundHandler)

                                                // 本番キャプチャ実行
                                                val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
                                                    addTarget(imageReader.surface)
                                                    set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                                                    set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF)
                                                }
                                                session.capture(captureRequest.build(), null, backgroundHandler)
                                            } catch (e: Exception) {
                                                cleanup()
                                                imageReader.close()
                                                Handler(context.mainLooper).post {
                                                    result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null)
                                                }
                                            }
                                        }, 1000)
                                    } catch (e: Exception) {
                                        cleanup()
                                        imageReader.close()
                                        Handler(context.mainLooper).post {
                                            result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null)
                                        }
                                    }
                                }

                                override fun onConfigureFailed(session: CameraCaptureSession) {
                                    cleanup()
                                    imageReader.close()
                                    Handler(context.mainLooper).post {
                                        result.error("SESSION_ERROR", "カメラセッションの設定に失敗", null)
                                    }
                                }
                            },
                            backgroundHandler
                        )
                    } catch (e: Exception) {
                        cleanup()
                        imageReader.close()
                        Handler(context.mainLooper).post {
                            result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null)
                        }
                    }
                }

                override fun onDisconnected(camera: CameraDevice) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    stopBackgroundThread()
                }

                override fun onError(camera: CameraDevice, error: Int) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    Handler(context.mainLooper).post {
                        result.error("CAMERA_ERROR", "カメラエラー: $error", null)
                        stopBackgroundThread()
                    }
                }
            }, backgroundHandler)
        } catch (e: SecurityException) {
            imageReader.close()
            returnError(result, "PERMISSION_ERROR", "カメラの権限がありません")
        }
    }

    /**
     * フロントカメラの Camera2 API 能力を読み出して Map で返す.
     *
     * CameraCharacteristics から各種パラメータを取得し,null セーフに処理する.
     * フロントカメラが見つからない場合は error を返す.
     */
    private fun getCameraDiagnostics(result: MethodChannel.Result) {
        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager

        val cameraId = findFrontCameraId(cameraManager)

        if (cameraId == null) {
            result.error("NO_CAMERA", "フロントカメラが見つかりません", null)
            return
        }

        val c = cameraManager.getCameraCharacteristics(cameraId)
        val streamConfigMap = c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)

        // rawOutputSizes
        val rawOutputSizes = streamConfigMap
            ?.getOutputSizes(ImageFormat.RAW_SENSOR)
            ?.map { "${it.width}x${it.height}" }
            ?: emptyList<String>()

        // yuvOutputSizes
        val yuvOutputSizes = streamConfigMap
            ?.getOutputSizes(ImageFormat.YUV_420_888)
            ?.map { "${it.width}x${it.height}" }
            ?: emptyList<String>()

        // jpegOutputSizes
        val jpegOutputSizes = streamConfigMap
            ?.getOutputSizes(ImageFormat.JPEG)
            ?.map { "${it.width}x${it.height}" }
            ?: emptyList<String>()

        val diagnostics = mapOf<String, Any?>(
            "cameraId" to cameraId,
            "hardwareLevel" to c.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL),
            "capabilities" to c.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)?.toList(),
            "exposureTimeRangeNs" to c.get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE)
                ?.let { listOf(it.lower, it.upper) },
            "sensitivityRange" to c.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE)
                ?.let { listOf(it.lower, it.upper) },
            "lscMapSize" to null,  // 実キャプチャ後に CaptureResult から取得する必要があるため診断段階では null
            "availableLensShadingMapModes" to c.get(
                CameraCharacteristics.STATISTICS_INFO_AVAILABLE_LENS_SHADING_MAP_MODES
            )?.map { it },
            "availableNoiseReductionModes" to c.get(
                CameraCharacteristics.NOISE_REDUCTION_AVAILABLE_NOISE_REDUCTION_MODES
            )?.toList(),
            "availableEdgeModes" to c.get(CameraCharacteristics.EDGE_AVAILABLE_EDGE_MODES)?.toList(),
            "availableTonemapModes" to c.get(
                CameraCharacteristics.TONEMAP_AVAILABLE_TONE_MAP_MODES
            )?.toList(),
            "availableShadingModes" to c.get(CameraCharacteristics.SHADING_AVAILABLE_MODES)?.toList(),
            "availableAwbModes" to c.get(
                CameraCharacteristics.CONTROL_AWB_AVAILABLE_MODES
            )?.toList(),
            "availableAfModes" to c.get(
                CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES
            )?.toList(),
            "minFocusDistance" to c.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE),
            "hyperfocalDistance" to c.get(CameraCharacteristics.LENS_INFO_HYPERFOCAL_DISTANCE),
            "rawOutputSizes" to rawOutputSizes,
            "yuvOutputSizes" to yuvOutputSizes,
            "jpegOutputSizes" to jpegOutputSizes,
            "colorFilterArrangement" to c.get(
                CameraCharacteristics.SENSOR_INFO_COLOR_FILTER_ARRANGEMENT
            ),
            "blackLevelPattern" to c.get(CameraCharacteristics.SENSOR_BLACK_LEVEL_PATTERN)?.toString(),
            "whiteLevel" to c.get(CameraCharacteristics.SENSOR_INFO_WHITE_LEVEL),
            "physicalSize" to c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
                ?.let { listOf(it.width, it.height) },
            "pixelArraySize" to c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)
                ?.let { listOf(it.width, it.height) },
            "activeArraySize" to c.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)?.toString(),
        )

        result.success(diagnostics)
    }

    private fun cleanup() {
        cameraDevice?.close()
        cameraDevice = null
        stopBackgroundThread()
    }

    private fun returnError(result: MethodChannel.Result, code: String, message: String) {
        result.error(code, message, null)
        stopBackgroundThread()
    }

    private fun startBackgroundThread() {
        backgroundThread = HandlerThread("RawCaptureThread").also { it.start() }
        backgroundHandler = Handler(backgroundThread!!.looper)
    }

    private fun stopBackgroundThread() {
        backgroundThread?.quitSafely()
        try {
            backgroundThread?.join(3000)
        } catch (_: InterruptedException) {
        }
        backgroundThread = null
        backgroundHandler = null
    }
}