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 9581a0e..ba799cc 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 @@ -1,7 +1,12 @@ package com.example.mini_tias 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 @@ -9,6 +14,7 @@ import android.os.HandlerThread import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream /** * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで @@ -23,6 +29,7 @@ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "captureFullResolutionYuv" -> captureFullResolutionYuv(result) + "convertYuvToJpeg" -> convertYuvToJpeg(call, result) "scanFile" -> { val path = call.argument("path") if (path != null) { @@ -37,6 +44,67 @@ } } + /// 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/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" index 951f432..4e1d4b9 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" @@ -25,14 +25,14 @@ │ サムネイルタップ ▼ ┌──────────┐ - │ 拡大表示 │ - │ (ダイアログ) │ + │ 詳細表示 │ + │(インライン) │ └──────────┘ ``` - アプリ起動時のデフォルト画面は **撮影画面(S-01)** とする - 画面切り替えには `BottomNavigationBar` を使用する(タブ 2 つ: 撮影 / 一覧) -- 画像の拡大表示はダイアログ(`showDialog`)で実装し,独立画面としない +- 画像の詳細表示は一覧画面内の `Stack` オーバーレイで実装し,独立画面・ダイアログとしない ## 撮影画面 (S-01: Capture Screen) @@ -62,15 +62,18 @@ | 要素 | 仕様 | | --- | --- | | カメラプレビュー | インカメラのライブ映像.鏡像(左右反転)+上下反転で表示.画面いっぱいに拡大し中央をクロップ | -| シャッターボタン | 丸型ボタン.タップで撮影実行.連続タップ可(連写対応).※ Step 3 で実装 | +| シャッターボタン | 丸型ボタン(画面下部中央).タップで撮影実行.連続撮影可 | +| タイマーボタン | 画面左下に配置.ON(黄色)/ OFF(グレー)を切り替える.ON 時はシャッター押下後 3 秒のカウントダウン表示の後に撮影 | | 明るさスライダー | ナビバーの直下に配置.画面の輝度を手動で調整する(デフォルト: 0.8) | -| BottomNavigationBar | 撮影タブ(アクティブ)/ 一覧タブ.背景色は黒系で統一 | +| BottomNavigationBar | 撮影タブ(アクティブ)/ 一覧タブ.背景色は黒系で統一.保存中はタップ無効 | ### 動作仕様 - **起動時**: カメラの初期化を行い,プレビューを開始する -- **撮影**: シャッターボタンタップで静止画をキャプチャし,PNG 形式で保存する -- **連続撮影**: 前回の保存完了を待たずに次の撮影が可能.撮影中はシャッターボタンの操作を受け付ける +- **即時撮影**: タイマー OFF 時,シャッターボタンタップで即座に撮影する +- **タイマー撮影**: タイマー ON 時,シャッターボタンタップで 3, 2, 1 のカウントダウン後に撮影する(手ブレ・振動防止用) +- **連続撮影**: 前回の保存完了を待ってから次の撮影が可能 +- **保存中表示**: カメラの画像ストリーム(`startImageStream`)から最新フレームを保持し,撮影開始時に Android ネイティブの `YuvImage.compressToJpeg()` で JPEG 変換して表示する(オーバーレイなし・瞬時) - **フィードバック**: 撮影成功時にスナックバーで「保存しました: [ファイル名]」を表示する - **エラー時**: カメラ初期化失敗やストレージ書き込み失敗時は,エラーメッセージをスナックバーで表示する @@ -109,20 +112,21 @@ | ファイル名ラベル | 各サムネイル下部にファイル名を表示 | | BottomNavigationBar | 撮影タブ / 一覧タブ(アクティブ) | -### 拡大表示ダイアログ +### 詳細表示(インラインオーバーレイ) | 要素 | 仕様 | | --- | --- | -| 画像 | 元画像をフル解像度で表示.ピンチ操作でズーム可能 | -| ファイル名 | ダイアログ上部にファイル名を表示 | -| 削除ボタン | ダイアログ内に配置.タップで削除確認ダイアログを表示 | -| 閉じるボタン | ダイアログ外タップまたは閉じるボタンで閉じる | +| 画像 | 一覧画面エリアを全面に覆うオーバーレイで表示.カメラプレビューと同様に上下をクロップして全面表示(`BoxFit.cover`).ピンチ操作でズーム可能 | +| ファイル名 | オーバーレイ上部に半透明背景とともに表示 | +| 削除ボタン | オーバーレイ下部に半透明背景とともに配置.タップで削除確認ダイアログを表示 | +| 閉じるボタン | ヘッダー右端の × ボタンで閉じる | ### 動作仕様 - **画面表示時**: `Pictures/MiniTIAS/` ディレクトリ内の PNG ファイルを走査し,一覧を構築する - **リフレッシュ**: 撮影画面から戻った際に一覧を再取得する -- **削除**: 削除確認ダイアログで「削除」を選択すると,ファイルをストレージから削除し,一覧から除去する +- **詳細表示**: サムネイルタップで詳細表示オーバーレイを開く.× ボタンで閉じる +- **削除**: 詳細表示の削除ボタンから確認ダイアログで「削除」を選択すると,ファイルをストレージから削除し,一覧から除去する - **空状態**: 画像が 0 件の場合は「撮影した画像がありません」のメッセージを表示する ## カメラ制御仕様 (Camera Control) @@ -167,6 +171,9 @@ YUV → RGB 変換(BT.601 係数,isolate で実行) │ ▼ +90° 時計回り回転(センサー向き補正) + │ + ▼ PNG エンコード(image パッケージ,ロスレス) │ ▼ @@ -223,7 +230,7 @@ ### 削除 -- 一覧画面の拡大表示ダイアログから削除を実行する +- 一覧画面の詳細表示オーバーレイから削除を実行する - 削除前に確認ダイアログを表示する(「この画像を削除しますか?」) - ファイルシステムから物理削除する(ゴミ箱機能なし) - 削除後,MediaStore からも当該エントリを除去する @@ -291,8 +298,7 @@ ├── widgets/ │ ├── camera_preview.dart # カメラプレビューウィジェット │ ├── shutter_button.dart # シャッターボタン -│ ├── image_grid.dart # サムネイルグリッド -│ └── image_detail_dialog.dart # 拡大表示ダイアログ +│ └── image_grid.dart # サムネイルグリッド └── services/ ├── file_service.dart # ファイル保存・命名・削除 └── permission_service.dart # パーミッション管理 diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 4c5fddb..8646309 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; @@ -117,7 +174,9 @@ } /// カメラリソースを解放する. - void disposeCamera() { + Future disposeCamera() async { + await _stopImageStream(); + _latestFrame = null; _controller?.dispose(); _controller = null; _isInitialized = false; diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 965caeb..c8a7f14 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -16,6 +19,10 @@ class _CaptureScreenState extends State with WidgetsBindingObserver { String? _previousFileName; + bool _timerEnabled = false; + int? _countdown; + Timer? _countdownTimer; + Uint8List? _snapshotBytes; @override void initState() { @@ -28,6 +35,7 @@ @override void dispose() { + _countdownTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -36,16 +44,71 @@ void didChangeAppLifecycleState(AppLifecycleState state) { final cameraProvider = context.read(); if (state == AppLifecycleState.paused) { + _cancelCountdown(); cameraProvider.disposeCamera(); } else if (state == AppLifecycleState.resumed) { cameraProvider.initialize(); } } + void _onShutterPressed() { + final cameraProvider = context.read(); + if (cameraProvider.isSaving || _countdown != null) return; + + if (_timerEnabled) { + _startCountdown(); + } else { + _captureWithSnapshot(); + } + } + + Future _captureWithSnapshot() async { + final cameraProvider = context.read(); + + // カメラの生フレームからスナップショットを取得(オーバーレイなし,瞬時) + try { + final bytes = await cameraProvider.capturePreviewSnapshot(); + if (bytes != null && mounted) { + setState(() => _snapshotBytes = bytes); + } + } catch (_) { + // 失敗しても撮影は続行 + } + + if (!mounted) return; + cameraProvider.takePicture(); + } + + void _startCountdown() { + setState(() => _countdown = 3); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown == null) { + timer.cancel(); + return; + } + if (_countdown! <= 1) { + timer.cancel(); + setState(() => _countdown = null); + _captureWithSnapshot(); + } else { + setState(() => _countdown = _countdown! - 1); + } + }); + } + + void _cancelCountdown() { + _countdownTimer?.cancel(); + _countdownTimer = null; + if (mounted) { + setState(() => _countdown = null); + } + } + void _showSaveResult(CameraProvider cameraProvider) { final fileName = cameraProvider.lastSavedFileName; if (fileName != null && fileName != _previousFileName) { _previousFileName = fileName; + _snapshotBytes = null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -89,15 +152,26 @@ return Center(child: Text(cameraProvider.errorMessage!)); } - if (!cameraProvider.isInitialized) { + if (!cameraProvider.isInitialized && + !cameraProvider.isSaving && + _snapshotBytes == null) { return const Center(child: CircularProgressIndicator()); } return Stack( children: [ - Positioned.fill( - child: CameraPreviewWidget(controller: cameraProvider.controller!), - ), + // 保存中はスナップショットを表示 + if (_snapshotBytes != null && + (!cameraProvider.isInitialized || cameraProvider.isSaving)) + Positioned.fill( + child: Image.memory(_snapshotBytes!, fit: BoxFit.cover), + ) + // カメラプレビュー + else if (cameraProvider.isInitialized && + cameraProvider.controller != null) + Positioned.fill( + child: CameraPreviewWidget(controller: cameraProvider.controller!), + ), // 保存中インジケーター if (cameraProvider.isSaving) const Center( @@ -113,19 +187,72 @@ ], ), ), - // シャッターボタン(画面下部中央) - Positioned( - left: 0, - right: 0, - bottom: 24, - child: Center( - child: ShutterButton( - onPressed: cameraProvider.isSaving - ? () {} - : () => cameraProvider.takePicture(), + // カウントダウン表示 + 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 (!cameraProvider.isSaving) + Positioned( + left: 24, + bottom: 36, + child: GestureDetector( + onTap: () { + _cancelCountdown(); + setState(() => _timerEnabled = !_timerEnabled); + }, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + _timerEnabled ? Icons.timer : Icons.timer_off, + color: Colors.black, + size: 40, + ), + Icon( + _timerEnabled ? Icons.timer : Icons.timer_off, + color: _timerEnabled ? Colors.yellow : Colors.grey, + size: 32, + ), + ], + ), + ), + ), + // シャッターボタン(中央) + if (!cameraProvider.isSaving) + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Center( + child: ShutterButton( + onPressed: _countdown != null ? () {} : _onShutterPressed, + ), + ), + ), ], ); } diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index 5e08a72..1f43965 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -1,8 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:mini_tias/providers/gallery_provider.dart'; -import 'package:mini_tias/widgets/image_detail_dialog.dart'; import 'package:mini_tias/widgets/image_grid.dart'; /// 一覧画面.撮影済み画像のサムネイルグリッドを表示する. @@ -14,6 +15,8 @@ } class _GalleryScreenState extends State { + File? _selectedFile; + @override void initState() { super.initState(); @@ -34,19 +37,114 @@ return const Center(child: Text('撮影した画像がありません')); } - return ImageGrid( - images: galleryProvider.images, - onImageTap: (file) { - showDialog( - context: context, - builder: (_) => ImageDetailDialog( - file: file, - onDelete: () { + return Stack( + children: [ + ImageGrid( + images: galleryProvider.images, + onImageTap: (file) => setState(() => _selectedFile = file), + ), + // 画像詳細表示(この領域内に収まる) + if (_selectedFile != null) + Positioned.fill( + child: Stack( + children: [ + // 画像(全面・上下クロップ) + InteractiveViewer( + minScale: 1.0, + maxScale: 5.0, + child: SizedBox.expand( + child: Image.file( + _selectedFile!, + fit: BoxFit.cover, + ), + ), + ), + // ヘッダー(ファイル名 + 閉じるボタン) + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + Expanded( + child: Text( + _selectedFile!.path.split('/').last, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon( + Icons.close, + color: Colors.white, + ), + onPressed: () => + setState(() => _selectedFile = null), + ), + ], + ), + ), + ), + // 削除ボタン + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.all(8), + child: Center( + child: TextButton.icon( + onPressed: () => _confirmDelete(galleryProvider), + icon: const Icon(Icons.delete, color: Colors.red), + label: const Text( + '削除', + style: TextStyle(color: Colors.red), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } + + void _confirmDelete(GalleryProvider galleryProvider) { + final file = _selectedFile; + if (file == null) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('画像を削除'), + content: const Text('この画像を削除しますか?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('キャンセル'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + setState(() => _selectedFile = null); galleryProvider.deleteImage(file); }, + child: const Text('削除', style: TextStyle(color: Colors.red)), ), - ); - }, + ], + ), ); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index fdce83d..7cb181d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ import 'package:provider/provider.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'package:mini_tias/providers/camera_provider.dart'; import 'package:mini_tias/providers/gallery_provider.dart'; import 'package:mini_tias/screens/capture_screen.dart'; import 'package:mini_tias/screens/gallery_screen.dart'; @@ -41,33 +42,37 @@ Widget build(BuildContext context) { final screenHeight = MediaQuery.of(context).size.height; final attachmentPadding = screenHeight / 3; + final cameraProvider = context.watch(); return Scaffold( backgroundColor: Colors.black, body: Column( children: [ - // ナビゲーションバー - BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (index) { - setState(() => _currentIndex = index); - if (index == 1) { - context.read().loadImages(); - } - }, - backgroundColor: Colors.black87, - selectedItemColor: Colors.white, - unselectedItemColor: Colors.white60, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.camera_alt), - label: '撮影', - ), - BottomNavigationBarItem( - icon: Icon(Icons.photo_library), - label: '一覧', - ), - ], + // ナビゲーションバー(保存中はタップ無効) + AbsorbPointer( + absorbing: cameraProvider.isSaving, + child: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() => _currentIndex = index); + if (index == 1) { + context.read().loadImages(); + } + }, + backgroundColor: Colors.black87, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.white60, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.camera_alt), + label: '撮影', + ), + BottomNavigationBarItem( + icon: Icon(Icons.photo_library), + label: '一覧', + ), + ], + ), ), // 明るさスライダー diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index dce0606..ab4b257 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -20,23 +20,9 @@ await directory.create(recursive: true); } - final yRaw = yuvData['yPlane']; - final uRaw = yuvData['uPlane']; - final vRaw = yuvData['vPlane']; - - final Uint8List yPlane; - final Uint8List uPlane; - final Uint8List vPlane; - - if (yRaw is Uint8List) { - yPlane = yRaw; - uPlane = uRaw as Uint8List; - vPlane = vRaw as Uint8List; - } else { - yPlane = Uint8List.fromList((yRaw as List).cast()); - uPlane = Uint8List.fromList((uRaw as List).cast()); - vPlane = Uint8List.fromList((vRaw as List).cast()); - } + final yPlane = _toUint8List(yuvData['yPlane']); + final uPlane = _toUint8List(yuvData['uPlane']); + final vPlane = _toUint8List(yuvData['vPlane']); if (yPlane.isEmpty) { throw Exception('YUV データが空です'); @@ -111,6 +97,11 @@ return '${baseName}_$suffix.png'; } + /// `dynamic` な YUV プレーンを `Uint8List` に変換する. + Uint8List _toUint8List(dynamic raw) => raw is Uint8List + ? raw + : Uint8List.fromList((raw as List).cast()); + /// YUV420 を RGB に変換し PNG エンコードする(isolate 内で実行). static Uint8List _convertYuvToPng(Map params) { final width = params['width'] as int; @@ -143,6 +134,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 8a92d39..5bf13e6 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -5,11 +5,6 @@ static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); /// フロントカメラからフル解像度の YUV データをキャプチャする. - /// - /// 返り値は YUV データを含む Map: - /// - width, height: 画像サイズ - /// - yPlane, uPlane, vPlane: YUV プレーンのバイトデータ - /// - yRowStride, uvRowStride, uvPixelStride: ストライド情報 Future> captureFullResolutionYuv() async { final result = await _channel .invokeMethod('captureFullResolutionYuv') @@ -20,6 +15,39 @@ return Map.from(result as Map); } + /// 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}); diff --git a/lib/widgets/image_detail_dialog.dart b/lib/widgets/image_detail_dialog.dart deleted file mode 100644 index c014875..0000000 --- a/lib/widgets/image_detail_dialog.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; - -/// 画像の拡大表示ダイアログ.ピンチズームと削除ボタンを備える. -class ImageDetailDialog extends StatelessWidget { - const ImageDetailDialog({ - super.key, - required this.file, - required this.onDelete, - }); - - final File file; - final VoidCallback onDelete; - - @override - Widget build(BuildContext context) { - final fileName = file.path.split('/').last; - - return Dialog( - backgroundColor: Colors.black, - insetPadding: const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ヘッダー(ファイル名 + 閉じるボタン) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - children: [ - Expanded( - child: Text( - fileName, - style: const TextStyle(color: Colors.white, fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ), - // 画像(ピンチズーム対応) - Flexible( - child: InteractiveViewer( - minScale: 1.0, - maxScale: 5.0, - child: Image.file(file), - ), - ), - // 削除ボタン - Padding( - padding: const EdgeInsets.all(8), - child: TextButton.icon( - onPressed: () => _confirmDelete(context), - icon: const Icon(Icons.delete, color: Colors.red), - label: const Text('削除', style: TextStyle(color: Colors.red)), - ), - ), - ], - ), - ); - } - - void _confirmDelete(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('画像を削除'), - content: const Text('この画像を削除しますか?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('キャンセル'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); // 確認ダイアログを閉じる - Navigator.of(context).pop(); // 詳細ダイアログを閉じる - onDelete(); - }, - child: const Text('削除', style: TextStyle(color: Colors.red)), - ), - ], - ), - ); - } -}