diff --git a/CLAUDE.md b/CLAUDE.md index 9091def..f722a1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: DNG プレビュー JPEG 機能を追加 +最新: プレビュー JPEG をライブビュー(自動 ISP)スナップショットに切替 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) 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 6bab8e7..04c9e13 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 @@ -26,12 +26,10 @@ import java.io.File import java.io.FileOutputStream import java.nio.ByteBuffer -import java.nio.ByteOrder import java.time.Instant import java.util.zip.CRC32 import java.util.zip.Deflater import java.util.zip.DeflaterOutputStream -import kotlin.math.pow import kotlin.math.roundToInt /** フレーム取得後に処理するコールバックの型エイリアス.*/ @@ -61,10 +59,6 @@ const val RAW_WIDTH = 3264 const val RAW_HEIGHT = 2448 const val FRAMES_TO_DISCARD = 2 - const val PREVIEW_DOWNSCALE = 2 - const val PREVIEW_JPEG_QUALITY = 85 - const val BLACK_LEVEL = 64 - const val WHITE_LEVEL = 1023 } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { @@ -522,22 +516,6 @@ } Log.d("RawCapturePlugin", "DNG written: ${dngFile.length()} bytes to ${dngFile.absolutePath}") - // プレビュー JPEG 生成(DNG 書き込み後,image.close() 前に実行) - // 生成失敗時は null になり,撮影全体は失敗扱いにしない - val previewFile: File? = try { - val previewBytes = generatePreviewJpeg(image, characteristics) - val pf = File(dir, "$baseName.preview.jpg") - FileOutputStream(pf).use { it.write(previewBytes) } - Log.d( - "RawCapturePlugin", - "Preview JPEG written: ${pf.length()} bytes to ${pf.absolutePath}" - ) - pf - } catch (e: Exception) { - Log.w("RawCapturePlugin", "Preview generation failed", e) - null - } - // LSC マップを取得 val lscMap = captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) @@ -564,8 +542,6 @@ val resultMap = mapOf( "dngPath" to dngFile.absolutePath, "dngFileSize" to dngFile.length(), - "previewPath" to previewFile?.absolutePath, - "previewFileSize" to previewFile?.length(), "metadata" to metadataMap, "lscMap" to lscData, "lscMapRowCount" to lscMap?.rowCount, @@ -588,99 +564,7 @@ } } - /** - * RAW Bayer (10-bit BGGR) から縮小プレビュー JPEG バイト列を生成する. - * - * センサ解像度の 1/[PREVIEW_DOWNSCALE] サイズに縮小し,簡易 demosaic を行う. - * 2×2 ブロック単位で 1 RGB ピクセルを生成し,ガンマ 2.2 を適用して 90° 回転する. - * [image] の planes[0] バッファは DngCreator が読み取り済みのため rewind して再利用する. - */ - private fun generatePreviewJpeg( - image: android.media.Image, - characteristics: CameraCharacteristics, - ): ByteArray { - val plane = image.planes[0] - val srcWidth = image.width - val srcHeight = image.height - val rowStride = plane.rowStride // バイト単位(パディングあり) - - // リトルエンディアン ShortBuffer で 10-bit RAW を読み取る - val shortBuf = plane.buffer.apply { rewind() } - .order(ByteOrder.LITTLE_ENDIAN) - .asShortBuffer() - - val outW = srcWidth / PREVIEW_DOWNSCALE - val outH = srcHeight / PREVIEW_DOWNSCALE - val pixels = IntArray(outW * outH) - - // rowStride はバイト単位,ShortBuffer 上のストライドは rowStride / 2 - val shortRowStride = rowStride / 2 - - // 行バッファを 2 行分用意(2×2 ブロック処理のため) - val row1 = ShortArray(srcWidth) - val row2 = ShortArray(srcWidth) - - for (outY in 0 until outH) { - val srcY = outY * PREVIEW_DOWNSCALE // 2×2 ブロックの上辺 y - - // 行 srcY を読み取る - shortBuf.position(srcY * shortRowStride) - shortBuf.get(row1, 0, srcWidth) - - // 行 srcY+1 を読み取る(センサ下端ガード) - val srcY1 = (srcY + 1).coerceAtMost(srcHeight - 1) - shortBuf.position(srcY1 * shortRowStride) - shortBuf.get(row2, 0, srcWidth) - - for (outX in 0 until outW) { - val srcX = outX * PREVIEW_DOWNSCALE // 2×2 ブロックの左辺 x - - // BGGR パターン: - // (srcY , srcX ) = B - // (srcY , srcX+1) = Gr - // (srcY+1, srcX ) = Gb - // (srcY+1, srcX+1) = R - val srcX1 = (srcX + 1).coerceAtMost(srcWidth - 1) - - val bRaw = (row1[srcX].toInt() and 0xFFFF) - val grRaw = (row1[srcX1].toInt() and 0xFFFF) - val gbRaw = (row2[srcX].toInt() and 0xFFFF) - val rRaw = (row2[srcX1].toInt() and 0xFFFF) - - // black / white level で正規化 → linear 0〜1 - val range = (WHITE_LEVEL - BLACK_LEVEL).toDouble() - fun normalize(v: Int): Double = - ((v - BLACK_LEVEL).toDouble() / range).coerceIn(0.0, 1.0) - - val rLin = normalize(rRaw) - val gLin = (normalize(grRaw) + normalize(gbRaw)) / 2.0 - val bLin = normalize(bRaw) - - // ガンマ 2.2 適用(linear → sRGB 近似) - val gamma = 1.0 / 2.2 - val r = (rLin.pow(gamma) * 255.0).roundToInt().coerceIn(0, 255) - val g = (gLin.pow(gamma) * 255.0).roundToInt().coerceIn(0, 255) - val b = (bLin.pow(gamma) * 255.0).roundToInt().coerceIn(0, 255) - - pixels[outY * outW + outX] = Color.rgb(r, g, b) - } - } - - val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888) - bitmap.setPixels(pixels, 0, outW, 0, 0, outW, outH) - - // 90° 時計回り回転(既存 PNG 経路と同じ向きに揃える) - val matrix = Matrix().apply { postRotate(90f) } - val rotated = Bitmap.createBitmap(bitmap, 0, 0, outW, outH, matrix, true) - bitmap.recycle() - - val out = ByteArrayOutputStream() - rotated.compress(Bitmap.CompressFormat.JPEG, PREVIEW_JPEG_QUALITY, out) - rotated.recycle() - return out.toByteArray() - } - - /** 撮影メタデータ Map を構築する.*/ + /// 撮影メタデータ Map を構築する. private fun buildMetadataMap( cameraId: String, characteristics: CameraCharacteristics, diff --git "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" index 8b76362..2ce1550 100644 --- "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" +++ "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" @@ -99,7 +99,7 @@ | 保存先 | `Pictures/MiniTIAS/` (共有ストレージ) | | RAW 画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(解析用本体,約 16 MB) | | メタデータファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json`(撮影設定・実適用値・LSC マップ・センサ特性を内包) | -| プレビュー画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg`(アプリ内表示・確認用,1632×1224,約 100〜300 KB) | +| プレビュー画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg`(シャッター時のライブプレビュー JPEG をそのまま保存.アプリ内表示・確認用,解像度はプレビューストリーム依存) | | 同秒重複時 | `MiniTIAS_QM_YYYYMMDD_HHmmss_1.dng`/`.meta.json`/`.preview.jpg`,`_2.*` ... と連番を付与 | | データ転送 | USB 接続で PC からフォルダを直接参照・コピー.大きい DNG は `adb pull` を推奨(MTP は不安定なことあり) | | 削除 | `.preview.jpg` 削除時は同 baseName の `.dng` / `.meta.json` も連動削除 | 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 6beda55..6613cbb 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" @@ -207,11 +207,6 @@ → Pictures/MiniTIAS/{baseName}.dng(約 16 MB) │ ▼ -プレビュー JPEG 生成(Bayer 簡易 demosaic→1632×1224→90°回転→JPEG 85%) - → Pictures/MiniTIAS/{baseName}.preview.jpg(100〜300KB) - ※ 生成失敗時は null で続行.撮影全体は失敗扱いにしない - │ - ▼ LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) │ ▼ @@ -222,7 +217,12 @@ → Pictures/MiniTIAS/{baseName}.meta.json │ ▼ -MediaStore 通知(DNG / preview.jpg / meta.json すべて scanFile) +[並行] シャッター押下時に取得済みのライブプレビュースナップショット JPEG を保存 + → Pictures/MiniTIAS/{baseName}.preview.jpg + ※ スナップショット取得失敗時は preview.jpg を保存しない(撮影全体は失敗扱いにしない) + │ + ▼ +MediaStore 通知(DNG / meta.json / preview.jpg すべて scanFile) │ ▼ Camera2 を閉じ,プレビューを再開 @@ -254,7 +254,7 @@ | --- | --- | --- | | RAW 画像 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng` | 解析の本体(10-bit BGGR Bayer,約 16 MB) | | メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | 撮影設定 + 実適用値 + LSC マップ + センサ特性(約 24 KB) | -| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示用の縮小プレビュー(1632×1224,100〜300 KB) | +| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示用の縮小プレビュー.シャッター瞬間のライブプレビュー(自動 ISP 処理済み YUV→JPEG)をそのまま保存.解像度はプレビューストリーム依存(おそらく 1280×720 程度) | - タイムスタンプはデバイスのローカル時刻を使用する - 旧通常モードの `MiniTIAS_YYYYMMDD_HHmmss.png` は引き続き一覧に表示可能(後方互換).新規撮影は QM 形式で行われる diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 88f4c30..82615ad 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -51,3 +51,11 @@ - 失敗パターン: なし(実機で意図通り動作) - 既知の課題: プレビューが緑がかる.原因はおそらく WB 未適用(COLOR_CORRECTION_GAINS=identity)と BGGR の G チャネルが 2 サンプル平均で他より重み付けされる効果の組み合わせ.プレビュー画質の話なので DNG 解析には影響しない - 後段: 緑かぶり改善(グレーワールド WB or AsShotNeutral 反映).露光時間キャリブ機能.連射再現性検証 V1 + +## プレビュー JPEG をライブビュー(自動 ISP)スナップショットに切替 + +- 状況: プレビュー JPEG の生成元を「Kotlin Bayer demosaic」から「`capturePreviewSnapshot` のライブプレビュー YUV→JPEG」に切替.Kotlin プラグインから約 200 行削除.Dart で `FileService.savePreviewJpeg` を追加し,`takePicture(previewBytes:)` でシャッター瞬間のスナップショットを保存 +- 動機: 自前で Bayer→グレーワールド AWB→自動レベル→sRGB トーンカーブと再現したが,旧 PNG(フル ISP)の見た目に届かなかった.Camera2 ISP がフル稼働するライブプレビューと同じ絵を使えば一発で一致する.かつコードが大幅にシンプルになる +- 設計判断: ライブプレビューの絵を「研究データには使わない確認用」と割り切る.DNG は引き続きマニュアル制御の RAW.Preview のソースが自動 ISP かマニュアルかは独立に決められる.既存の `capturePreviewSnapshot` は保存中オーバーレイで既に使われていたので新規 API 追加なし +- 失敗パターン: ① 自前 demosaic + ガンマ 2.2 → 緑かぶり ② グレーワールド AWB → 緑かぶり解消だが暗い・フラット ③ 自動レベル + sRGB トーンカーブ → さらに改善も自動 ISP に届かない → ライブビュースナップショットを直接使う方針に転換.結果コード ~200 行削減 + ライブビュー完全一致 +- 後段: 解像度がプレビュー由来(おそらく 1280×720)になるが研究用途では十分.画素統計 meta 追加,hot pixel マップ,連射機能 V1 検証 diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 121fb4c..6801ad2 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -141,7 +141,10 @@ } /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. - Future takePicture() async { + /// + /// [previewBytes] が指定された場合,`.preview.jpg` として保存する. + /// 指定しない場合はプレビュー JPEG の保存をスキップする. + Future takePicture({Uint8List? previewBytes}) async { if (!_isInitialized || _isSaving) return; _isSaving = true; @@ -176,11 +179,6 @@ debugPrint( 'DNG saved: ${capture.dngFileSize} bytes at ${capture.dngPath}', ); - if (capture.previewPath != null) { - debugPrint( - 'Preview saved: ${capture.previewFileSize} bytes at ${capture.previewPath}', - ); - } // メタデータ JSON は引き続き Dart 側で保存 final metaPath = await _fileService.saveQuantitativeMetadata( @@ -191,11 +189,23 @@ lscMapColumnCount: capture.lscMapColumnCount, ); + // プレビュー JPEG 保存(snapshot がある場合のみ) + String? previewPath; + if (previewBytes != null) { + previewPath = await _fileService.savePreviewJpeg( + baseName, + previewBytes, + ); + debugPrint( + 'Preview saved: ${previewBytes.length} bytes at $previewPath', + ); + } + // MediaStore に登録(PC から MTP で見えるようにする) await _rawCaptureService.scanFile(capture.dngPath); await _rawCaptureService.scanFile(metaPath); - if (capture.previewPath != null) { - await _rawCaptureService.scanFile(capture.previewPath!); + if (previewPath != null) { + await _rawCaptureService.scanFile(previewPath); } _lastSavedFileName = capture.dngPath.split('/').last; diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 86179fd..ef74d81 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -66,8 +66,9 @@ final cameraProvider = context.read(); // カメラの生フレームからスナップショットを取得(オーバーレイなし,瞬時) + Uint8List? bytes; try { - final bytes = await cameraProvider.capturePreviewSnapshot(); + bytes = await cameraProvider.capturePreviewSnapshot(); if (bytes != null && mounted) { setState(() => _snapshotBytes = bytes); } @@ -78,7 +79,8 @@ } if (!mounted) return; - cameraProvider.takePicture(); + // snapshot bytes を takePicture に渡し,preview.jpg として保存する + cameraProvider.takePicture(previewBytes: bytes); } void _startCountdown() { diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 6b72dcd..b88ed48 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -9,6 +9,14 @@ /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; + /// [directoryPath] が存在しない場合,再帰的に作成する. + Future _ensureDirectory() async { + final directory = Directory(directoryPath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + } + /// `YYYYMMDD_HHmmss` 形式のタイムスタンプ文字列を生成する. static String _buildTimestamp(DateTime dt) => '${dt.year}' @@ -24,12 +32,9 @@ /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り, /// そのまま書き込むため高速に動作する. Future saveImage(Uint8List pngBytes) async { - final directory = Directory(_basePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } + await _ensureDirectory(); final fileName = await generateFileName(); - final filePath = '$_basePath/$fileName'; + final filePath = '$directoryPath/$fileName'; await File(filePath).writeAsBytes(pngBytes); return filePath; } @@ -43,12 +48,12 @@ final baseName = 'MiniTIAS_$timestamp'; final candidate = '$baseName.png'; - if (!await File('$_basePath/$candidate').exists()) { + if (!await File('$directoryPath/$candidate').exists()) { return candidate; } var suffix = 1; - while (await File('$_basePath/${baseName}_$suffix.png').exists()) { + while (await File('$directoryPath/${baseName}_$suffix.png').exists()) { suffix++; } return '${baseName}_$suffix.png'; @@ -60,14 +65,11 @@ /// 保存先: `/storage/emulated/0/Pictures/MiniTIAS` /// 戻り値は保存したファイルのフルパス. Future saveDiagnosticsJson(Map diagnostics) async { - final directory = Directory(_basePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } + await _ensureDirectory(); final fileName = 'camera_diagnostics_${_buildTimestamp(DateTime.now())}.json'; - final filePath = '$_basePath/$fileName'; + final filePath = '$directoryPath/$fileName'; const encoder = JsonEncoder.withIndent(' '); await File(filePath).writeAsString(encoder.convert(diagnostics)); @@ -102,12 +104,12 @@ Future generateQuantitativeBaseName() async { final candidate = 'MiniTIAS_QM_${_buildTimestamp(DateTime.now())}'; - if (!await File('$_basePath/$candidate.dng').exists()) { + if (!await File('$directoryPath/$candidate.dng').exists()) { return candidate; } var suffix = 1; - while (await File('$_basePath/${candidate}_$suffix.dng').exists()) { + while (await File('$directoryPath/${candidate}_$suffix.dng').exists()) { suffix++; } return '${candidate}_$suffix'; @@ -133,6 +135,20 @@ return '${candidate}_$suffix'; } + /// ライブプレビューのスナップショット JPEG を `.preview.jpg` として保存する. + /// + /// [previewBytes] は JPEG エンコード済みのバイト列. + /// 戻り値は保存したファイルのフルパス. + Future savePreviewJpeg( + String baseName, + Uint8List previewBytes, + ) async { + await _ensureDirectory(); + final filePath = '$directoryPath/$baseName.preview.jpg'; + await File(filePath).writeAsBytes(previewBytes); + return filePath; + } + /// メタデータと LSC マップを `.meta.json` として保存する. /// /// [lscMap] が null の場合,`lscMap`/`lscMapRowCount`/`lscMapColumnCount` キーは含めない. @@ -143,10 +159,7 @@ int? lscMapRowCount, int? lscMapColumnCount, }) async { - final directory = Directory(_basePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } + await _ensureDirectory(); final Map jsonMap = Map.from(metadata); if (lscMap != null) { @@ -156,7 +169,7 @@ } const encoder = JsonEncoder.withIndent(' '); - final filePath = '$_basePath/$baseName.meta.json'; + final filePath = '$directoryPath/$baseName.meta.json'; await File(filePath).writeAsString(encoder.convert(jsonMap)); return filePath; } diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 285a609..7e88747 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -167,6 +167,86 @@ }); }); + group('FileService.savePreviewJpeg', () { + late Directory tempDir; + late _FileServiceTestablePreview service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'mini_tias_preview_test_', + ); + service = _FileServiceTestablePreview(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('指定した baseName で .preview.jpg ファイルが生成される', () async { + const baseName = 'MiniTIAS_QM_20260531_120000'; + final previewBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]); + await service.savePreviewJpeg(baseName, previewBytes); + + final expectedFile = File('${tempDir.path}/$baseName.preview.jpg'); + expect(await expectedFile.exists(), isTrue); + }); + + test('戻り値が保存パス(baseName.preview.jpg を含む)になる', () async { + const baseName = 'MiniTIAS_QM_20260531_120000'; + final previewBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]); + final path = await service.savePreviewJpeg(baseName, previewBytes); + + expect(path, contains('$baseName.preview.jpg')); + }); + + test('渡したバイト列がそのままファイルに書き込まれる', () async { + const baseName = 'MiniTIAS_QM_20260531_120001'; + final previewBytes = Uint8List.fromList([ + 0xFF, + 0xD8, + 0xFF, + 0xE0, + 0x00, + 0x10, + 0x4A, + 0x46, + ]); + final path = await service.savePreviewJpeg(baseName, previewBytes); + + final written = await File(path).readAsBytes(); + expect(written, equals(previewBytes)); + }); + + test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { + final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); + final serviceWithSubDir = _FileServiceTestablePreview( + nonExistentSubDir.path, + ); + expect(await nonExistentSubDir.exists(), isFalse); + + const baseName = 'MiniTIAS_QM_20260531_120002'; + final previewBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]); + final path = await serviceWithSubDir.savePreviewJpeg( + baseName, + previewBytes, + ); + + expect(await File(path).exists(), isTrue); + }); + + test('空のバイト列でも .preview.jpg ファイルが生成される', () async { + const baseName = 'MiniTIAS_QM_20260531_120003'; + final previewBytes = Uint8List(0); + final path = await service.savePreviewJpeg(baseName, previewBytes); + + expect(await File(path).exists(), isTrue); + final written = await File(path).readAsBytes(); + expect(written.length, 0); + }); + }); + group('FileService.generateQuantitativeBaseNameSync', () { test('同秒のファイルが存在しない場合,サフィックスなしの baseName を返す', () { final result = FileService.generateQuantitativeBaseNameSync( @@ -418,6 +498,30 @@ } } +/// テスト用: savePreviewJpeg の保存先をオーバーライドする FileService サブクラス. +class _FileServiceTestablePreview extends FileService { + _FileServiceTestablePreview(this._testBasePath); + + final String _testBasePath; + + @override + String get directoryPath => _testBasePath; + + @override + Future savePreviewJpeg( + String baseName, + Uint8List previewBytes, + ) async { + final directory = Directory(_testBasePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + final filePath = '$_testBasePath/$baseName.preview.jpg'; + await File(filePath).writeAsBytes(previewBytes); + return filePath; + } +} + /// テスト用: saveQuantitativeMetadata の保存先をオーバーライドする FileService サブクラス. class _FileServiceTestableQuantitative extends FileService { _FileServiceTestableQuantitative(this._testBasePath);