diff --git a/lib/models/preview_frame.dart b/lib/models/preview_frame.dart new file mode 100644 index 0000000..c66a7f4 --- /dev/null +++ b/lib/models/preview_frame.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +/// カメラのプレビューストリームから取得した YUV_420_888 フレームを保持する不変クラス. +/// +/// [_startImageStream] で生成し,[capturePreviewSnapshot] で消費する. +class PreviewFrame { + /// 画像の幅(ピクセル). + final int width; + + /// 画像の高さ(ピクセル). + final int height; + + /// Y プレーンのバイト列. + final Uint8List yPlane; + + /// U プレーンのバイト列. + final Uint8List uPlane; + + /// V プレーンのバイト列. + final Uint8List vPlane; + + /// Y プレーンの行ストライド(バイト). + final int yRowStride; + + /// UV プレーンの行ストライド(バイト). + final int uvRowStride; + + /// UV プレーンのピクセルストライド(バイト). + final int uvPixelStride; + + const PreviewFrame({ + required this.width, + required this.height, + required this.yPlane, + required this.uPlane, + required this.vPlane, + required this.yRowStride, + required this.uvRowStride, + required this.uvPixelStride, + }); +} diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 6801ad2..5d18d5e 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -1,6 +1,7 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; +import 'package:mini_tias/models/preview_frame.dart'; import 'package:mini_tias/services/file_service.dart'; import 'package:mini_tias/services/permission_service.dart'; import 'package:mini_tias/services/raw_capture_service.dart'; @@ -21,7 +22,7 @@ String? _lastSavedFileName; bool _isSaving = false; bool _imageStreamActive = false; - Map? _latestFrame; + PreviewFrame? _latestFrame; CameraController? get controller => _controller; bool get isInitialized => _isInitialized; @@ -92,16 +93,16 @@ 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, - }; + _latestFrame = PreviewFrame( + 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) { @@ -127,20 +128,20 @@ 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, + width: frame.width, + height: frame.height, + yPlane: frame.yPlane, + uPlane: frame.uPlane, + vPlane: frame.vPlane, + yRowStride: frame.yRowStride, + uvRowStride: frame.uvRowStride, + uvPixelStride: frame.uvPixelStride, rotation: 90, mirror: true, ); } - /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. + /// Camera2 API で定量モード撮影を行い,DNG ファイルとメタデータ JSON を保存する. /// /// [previewBytes] が指定された場合,`.preview.jpg` として保存する. /// 指定しない場合はプレビュー JPEG の保存をスキップする. diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index ef74d81..16499a0 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -177,47 +177,9 @@ child: CameraPreviewWidget(controller: cameraProvider.controller!), ), // 保存中インジケーター - if (cameraProvider.isSaving) - const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(color: Colors.white), - SizedBox(height: 12), - Text( - '保存中...', - style: TextStyle(color: Colors.white, fontSize: 16), - ), - ], - ), - ), + if (cameraProvider.isSaving) _buildSavingIndicator(), // カウントダウン表示 - if (_countdown != null) - Center( - child: Stack( - children: [ - Text( - '$_countdown', - style: TextStyle( - fontSize: 96, - fontWeight: FontWeight.bold, - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 6 - ..color = Colors.black, - ), - ), - Text( - '$_countdown', - style: const TextStyle( - color: Colors.white, - fontSize: 96, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), + if (_countdown != null) _buildCountdownOverlay(_countdown!), // タイマー切り替えボタン(左側) if (!cameraProvider.isSaving) Positioned( @@ -267,6 +229,51 @@ ); } + /// 保存処理中を示すローディングインジケーター(中央配置). + Widget _buildSavingIndicator() { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 12), + Text('保存中...', style: TextStyle(color: Colors.white, fontSize: 16)), + ], + ), + ); + } + + /// セルフタイマーのカウントダウン数字を中央に表示するオーバーレイ. + /// + /// 黒いアウトラインと白い塗りを重ねることで視認性を確保する. + Widget _buildCountdownOverlay(int countdown) { + return Center( + child: Stack( + children: [ + Text( + '$countdown', + style: TextStyle( + fontSize: 96, + fontWeight: FontWeight.bold, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 6 + ..color = Colors.black, + ), + ), + Text( + '$countdown', + style: const TextStyle( + color: Colors.white, + fontSize: 96, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + /// アウトライン(黒・40px)とカラー(有効:黄/無効:灰・32px)を重ねたトグルアイコン. Widget _buildToggleIcon({required IconData icon, required bool enabled}) { return Stack(