diff --git a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt index 017dbc2..a15b6f6 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -3,8 +3,11 @@ import android.app.Activity import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.ImageFormat import android.graphics.Matrix +import android.graphics.Rect +import android.graphics.YuvImage import android.hardware.camera2.* import android.media.ImageReader import android.media.MediaScannerConnection @@ -31,6 +34,7 @@ when (call.method) { "captureFullResolutionYuv" -> captureFullResolutionYuv(result) "capturePreviewFrame" -> capturePreviewFrame(call, result) + "convertYuvToJpeg" -> convertYuvToJpeg(call, result) "scanFile" -> { val path = call.argument("path") if (path != null) { @@ -96,6 +100,67 @@ }, handler) } + /// YUV_420_888 を NV21 に変換し,YuvImage で JPEG 化して返す. + private fun convertYuvToJpeg(call: MethodCall, result: MethodChannel.Result) { + try { + val width = call.argument("width")!! + val height = call.argument("height")!! + val yPlane = call.argument("yPlane")!! + val uPlane = call.argument("uPlane")!! + val vPlane = call.argument("vPlane")!! + val yRowStride = call.argument("yRowStride")!! + val uvRowStride = call.argument("uvRowStride")!! + val uvPixelStride = call.argument("uvPixelStride")!! + val rotation = call.argument("rotation") ?: 0 + val quality = call.argument("quality") ?: 85 + + // YUV_420_888 → NV21 変換 + val nv21 = ByteArray(width * height * 3 / 2) + + // Y プレーンをコピー + for (row in 0 until height) { + System.arraycopy(yPlane, row * yRowStride, nv21, row * width, width) + } + + // UV プレーンを NV21 形式(VUVU...)にインターリーブ + val uvOffset = width * height + for (row in 0 until height / 2) { + for (col in 0 until width / 2) { + val uvIndex = row * uvRowStride + col * uvPixelStride + nv21[uvOffset + row * width + col * 2] = vPlane[uvIndex] + nv21[uvOffset + row * width + col * 2 + 1] = uPlane[uvIndex] + } + } + + // NV21 → JPEG + val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null) + val jpegStream = ByteArrayOutputStream() + yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, jpegStream) + + // 回転・反転が必要な場合 + val mirror = call.argument("mirror") ?: false + val jpegBytes = if (rotation != 0 || mirror) { + val bitmap = BitmapFactory.decodeByteArray(jpegStream.toByteArray(), 0, jpegStream.size()) + val matrix = Matrix() + if (rotation != 0) matrix.postRotate(rotation.toFloat()) + if (mirror) matrix.postScale(-1f, 1f) + val transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + bitmap.recycle() + + val outStream = ByteArrayOutputStream() + transformed.compress(Bitmap.CompressFormat.JPEG, quality, outStream) + transformed.recycle() + outStream.toByteArray() + } else { + jpegStream.toByteArray() + } + + result.success(jpegBytes) + } catch (e: Exception) { + result.error("CONVERT_ERROR", "YUV to JPEG 変換に失敗: ${e.message}", null) + } + } + private fun captureFullResolutionYuv(result: MethodChannel.Result) { startBackgroundThread() diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 4c5fddb..a24a795 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -18,6 +18,8 @@ bool _permissionPermanentlyDenied = false; String? _lastSavedFileName; bool _isSaving = false; + bool _imageStreamActive = false; + Map? _latestFrame; CameraController? get controller => _controller; bool get isInitialized => _isInitialized; @@ -58,6 +60,8 @@ await _controller!.setFlashMode(FlashMode.off); _isInitialized = true; _errorMessage = null; + + _startImageStream(); } catch (e) { _errorMessage = 'カメラの初期化に失敗しました: $e'; _isInitialized = false; @@ -66,9 +70,61 @@ notifyListeners(); } + /// 画像ストリームを開始して最新フレームを保持する. + void _startImageStream() { + if (_controller == null || _imageStreamActive) return; + + try { + _controller!.startImageStream((CameraImage image) { + _latestFrame = { + 'width': image.width, + 'height': image.height, + 'yPlane': Uint8List.fromList(image.planes[0].bytes), + 'uPlane': Uint8List.fromList(image.planes[1].bytes), + 'vPlane': Uint8List.fromList(image.planes[2].bytes), + 'yRowStride': image.planes[0].bytesPerRow, + 'uvRowStride': image.planes[1].bytesPerRow, + 'uvPixelStride': image.planes[1].bytesPerPixel ?? 1, + }; + }); + _imageStreamActive = true; + } catch (e) { + debugPrint('画像ストリーム開始失敗: $e'); + } + } + + /// 画像ストリームを停止する. + Future _stopImageStream() async { + if (!_imageStreamActive || _controller == null) return; + + try { + await _controller!.stopImageStream(); + } catch (_) { + // 既に停止済みの場合等は無視 + } + _imageStreamActive = false; + } + + /// 最新のプレビューフレームを JPEG として取得する(オーバーレイなし). + Future capturePreviewSnapshot() async { + if (_latestFrame == null) return null; + + final frame = _latestFrame!; + return _rawCaptureService.convertYuvToJpeg( + width: frame['width'] as int, + height: frame['height'] as int, + yPlane: frame['yPlane'] as Uint8List, + uPlane: frame['uPlane'] as Uint8List, + vPlane: frame['vPlane'] as Uint8List, + yRowStride: frame['yRowStride'] as int, + uvRowStride: frame['uvRowStride'] as int, + uvPixelStride: frame['uvPixelStride'] as int, + rotation: 90, + mirror: true, + ); + } + /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. - /// - /// プレビューを一時停止 → Camera2 でキャプチャ → プレビュー再開. Future takePicture() async { if (!_isInitialized || _isSaving) return; @@ -86,7 +142,8 @@ return; } - // プレビューを停止してカメラを解放 + // 画像ストリームを停止してカメラを解放 + await _stopImageStream(); await _controller?.dispose(); _controller = null; @@ -118,6 +175,8 @@ /// カメラリソースを解放する. void disposeCamera() { + _stopImageStream(); + _latestFrame = null; _controller?.dispose(); _controller = null; _isInitialized = false; diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 26d4f2c..c8a7f14 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import 'package:mini_tias/providers/camera_provider.dart'; -import 'package:mini_tias/services/raw_capture_service.dart'; import 'package:mini_tias/widgets/camera_preview.dart'; import 'package:mini_tias/widgets/shutter_button.dart'; @@ -24,8 +23,6 @@ int? _countdown; Timer? _countdownTimer; Uint8List? _snapshotBytes; - bool _hideOverlay = false; - final _rawCaptureService = RawCaptureService(); @override void initState() { @@ -66,39 +63,20 @@ } Future _captureWithSnapshot() async { - // オーバーレイを非表示にしてからキャプチャ - setState(() => _hideOverlay = true); - await Future.delayed(const Duration(milliseconds: 50)); + final cameraProvider = context.read(); + // カメラの生フレームからスナップショットを取得(オーバーレイなし,瞬時) try { - if (!mounted) return; - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox != null) { - final position = renderBox.localToGlobal(Offset.zero); - final size = renderBox.size; - final ratio = View.of(context).devicePixelRatio; - - // localToGlobal は回転後の座標(右下)を返すため,左上を計算する - final topLeftX = position.dx - size.width; - final topLeftY = position.dy - size.height; - - final bytes = await _rawCaptureService.capturePreviewFrame( - x: (topLeftX * ratio).round(), - y: (topLeftY * ratio).round(), - width: (size.width * ratio).round(), - height: (size.height * ratio).round(), - ); - setState(() { - _snapshotBytes = bytes; - _hideOverlay = false; - }); + final bytes = await cameraProvider.capturePreviewSnapshot(); + if (bytes != null && mounted) { + setState(() => _snapshotBytes = bytes); } } catch (_) { - setState(() => _hideOverlay = false); + // 失敗しても撮影は続行 } if (!mounted) return; - context.read().takePicture(); + cameraProvider.takePicture(); } void _startCountdown() { @@ -182,7 +160,7 @@ return Stack( children: [ - // 保存中はスナップショットを表示(Transform なし,Kotlin 側で回転済み) + // 保存中はスナップショットを表示 if (_snapshotBytes != null && (!cameraProvider.isInitialized || cameraProvider.isSaving)) Positioned.fill( @@ -237,7 +215,7 @@ ), ), // タイマー切り替えボタン(左側) - if (!cameraProvider.isSaving && !_hideOverlay) + if (!cameraProvider.isSaving) Positioned( left: 24, bottom: 36, @@ -264,16 +242,14 @@ ), ), // シャッターボタン(中央) - if (!cameraProvider.isSaving && !_hideOverlay) + if (!cameraProvider.isSaving) Positioned( left: 0, right: 0, bottom: 24, child: Center( child: ShutterButton( - onPressed: cameraProvider.isSaving || _countdown != null - ? () {} - : _onShutterPressed, + onPressed: _countdown != null ? () {} : _onShutterPressed, ), ), ), diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index dce0606..e169f32 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -143,6 +143,9 @@ } } - return Uint8List.fromList(img.encodePng(image)); + // センサーの向き(270°)を補正するため 90° 時計回りに回転 + final rotated = img.copyRotate(image, angle: 90); + + return Uint8List.fromList(img.encodePng(rotated)); } } diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 96faec2..35eebb5 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -32,6 +32,39 @@ return result; } + /// YUV_420_888 を JPEG に変換する(Android ネイティブの高速変換). + Future convertYuvToJpeg({ + required int width, + required int height, + required Uint8List yPlane, + required Uint8List uPlane, + required Uint8List vPlane, + required int yRowStride, + required int uvRowStride, + required int uvPixelStride, + int rotation = 0, + bool mirror = false, + int quality = 85, + }) async { + final result = await _channel.invokeMethod('convertYuvToJpeg', { + 'width': width, + 'height': height, + 'yPlane': yPlane, + 'uPlane': uPlane, + 'vPlane': vPlane, + 'yRowStride': yRowStride, + 'uvRowStride': uvRowStride, + 'uvPixelStride': uvPixelStride, + 'rotation': rotation, + 'mirror': mirror, + 'quality': quality, + }); + if (result == null) { + throw Exception('YUV to JPEG 変換に失敗しました'); + } + return result; + } + /// MediaStore にファイルを登録し,PC から MTP で見えるようにする. Future scanFile(String path) async { await _channel.invokeMethod('scanFile', {'path': path});