import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:image/image.dart' as img; /// 撮影画像の保存・命名・削除を行うサービス. class FileService { static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; /// Platform Channel から受け取った YUV データを PNG に変換して保存する. /// /// Camera2 API でフル解像度キャプチャした生データを直接変換するため, /// JPEG 圧縮を経由せず画質劣化がない. Future saveImageFromYuvData(Map yuvData) async { final directory = Directory(_basePath); if (!await directory.exists()) { await directory.create(recursive: true); } final yPlane = _toUint8List(yuvData['yPlane']); final uPlane = _toUint8List(yuvData['uPlane']); final vPlane = _toUint8List(yuvData['vPlane']); if (yPlane.isEmpty) { throw Exception('YUV データが空です'); } final params = { 'width': yuvData['width'] as int, 'height': yuvData['height'] as int, 'yPlane': yPlane, 'uPlane': uPlane, 'vPlane': vPlane, 'yRowStride': yuvData['yRowStride'] as int, 'uvRowStride': yuvData['uvRowStride'] as int, 'uvPixelStride': yuvData['uvPixelStride'] as int, }; final pngBytes = await compute(_convertYuvToPng, params); final fileName = await generateFileName(); final filePath = '$_basePath/$fileName'; final file = File(filePath); await file.writeAsBytes(pngBytes); return filePath; } /// ファイル名を生成する. /// /// 形式: MiniTIAS_YYYYMMDD_HHmmss.png /// 同秒の重複がある場合は _1, _2, ... と連番を付与する. Future generateFileName() async { final now = DateTime.now(); final timestamp = '${now.year}' '${now.month.toString().padLeft(2, '0')}' '${now.day.toString().padLeft(2, '0')}' '_' '${now.hour.toString().padLeft(2, '0')}' '${now.minute.toString().padLeft(2, '0')}' '${now.second.toString().padLeft(2, '0')}'; final baseName = 'MiniTIAS_$timestamp'; final candidate = '$baseName.png'; if (!await File('$_basePath/$candidate').exists()) { return candidate; } var suffix = 1; while (await File('$_basePath/${baseName}_$suffix.png').exists()) { suffix++; } return '${baseName}_$suffix.png'; } /// テスト用: タイムスタンプとファイル名一覧からファイル名を生成する. static String generateFileNameSync( String timestamp, List existingFiles, ) { final baseName = 'MiniTIAS_$timestamp'; final candidate = '$baseName.png'; if (!existingFiles.contains(candidate)) { return candidate; } var suffix = 1; while (existingFiles.contains('${baseName}_$suffix.png')) { suffix++; } 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; final height = params['height'] as int; final yPlane = params['yPlane'] as Uint8List; final uPlane = params['uPlane'] as Uint8List; final vPlane = params['vPlane'] as Uint8List; final yRowStride = params['yRowStride'] as int; final uvRowStride = params['uvRowStride'] as int; final uvPixelStride = params['uvPixelStride'] as int; final image = img.Image(width: width, height: height); for (var y = 0; y < height; y++) { for (var x = 0; x < width; x++) { final yIndex = y * yRowStride + x; final uvIndex = (y ~/ 2) * uvRowStride + (x ~/ 2) * uvPixelStride; final yValue = yPlane[yIndex]; final uValue = uPlane[uvIndex]; final vValue = vPlane[uvIndex]; final r = (yValue + 1.402 * (vValue - 128)).round().clamp(0, 255); final g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)) .round() .clamp(0, 255); final b = (yValue + 1.772 * (uValue - 128)).round().clamp(0, 255); image.setPixelRgb(x, y, r, g, b); } } // センサーの向き(270°)を補正するため 90° 時計回りに回転 final rotated = img.copyRotate(image, angle: 90); return Uint8List.fromList(img.encodePng(rotated)); } }