diff --git a/CLAUDE.md b/CLAUDE.md index 4c2a0e6..9091def 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 定量モード撮影機能(DNG 経路)完成 +最新: DNG プレビュー JPEG 機能を追加 ※ 本欄は**最新ステップ 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 a80cd1c..6bab8e7 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,10 +26,12 @@ 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 /** フレーム取得後に処理するコールバックの型エイリアス.*/ @@ -59,6 +61,10 @@ 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) { @@ -516,6 +522,22 @@ } 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) @@ -542,6 +564,8 @@ 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, @@ -564,6 +588,98 @@ } } + /** + * 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 を構築する.*/ private fun buildMetadataMap( cameraId: String, 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 5755ce1..8b76362 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" @@ -95,12 +95,14 @@ | 項目 | 仕様 | | --- | --- | -| 保存形式(定量モード) | DNG(RAW_SENSOR 10-bit BGGR Bayer)+ メタデータ JSON | +| 保存形式(定量モード) | DNG(RAW_SENSOR 10-bit BGGR Bayer)+ メタデータ JSON + プレビュー JPEG | | 保存先 | `Pictures/MiniTIAS/` (共有ストレージ) | -| 画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(約 16 MB) | +| RAW 画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(解析用本体,約 16 MB) | | メタデータファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json`(撮影設定・実適用値・LSC マップ・センサ特性を内包) | -| 同秒重複時 | `MiniTIAS_QM_YYYYMMDD_HHmmss_1.dng`/`.meta.json`,`_2.*` ... と連番を付与 | +| プレビュー画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg`(アプリ内表示・確認用,1632×1224,約 100〜300 KB) | +| 同秒重複時 | `MiniTIAS_QM_YYYYMMDD_HHmmss_1.dng`/`.meta.json`/`.preview.jpg`,`_2.*` ... と連番を付与 | | データ転送 | USB 接続で PC からフォルダを直接参照・コピー.大きい DNG は `adb pull` を推奨(MTP は不安定なことあり) | +| 削除 | `.preview.jpg` 削除時は同 baseName の `.dng` / `.meta.json` も連動削除 | | 旧 PNG 形式 | `MiniTIAS_YYYYMMDD_HHmmss.png`(コードは温存,現状の `takePicture` からは呼ばれない) | ### 将来的な拡張 (Future Enhancements) 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 8045ff8..6beda55 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,6 +207,11 @@ → 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) │ ▼ @@ -217,7 +222,7 @@ → Pictures/MiniTIAS/{baseName}.meta.json │ ▼ -MediaStore 通知(DNG / meta.json 両方を scanFile) +MediaStore 通知(DNG / preview.jpg / meta.json すべて scanFile) │ ▼ Camera2 を閉じ,プレビューを再開 @@ -243,9 +248,16 @@ ### ファイル命名規則 -- 基本形式: `MiniTIAS_YYYYMMDD_HHmmss.png` -- 同秒重複時: `MiniTIAS_YYYYMMDD_HHmmss_1.png`,`MiniTIAS_YYYYMMDD_HHmmss_2.png`,... +撮影 1 回で 3 ファイルを生成する(baseName は共通.重複時 `_1`, `_2` ...): + +| 種別 | ファイル名 | 概要 | +| --- | --- | --- | +| 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_YYYYMMDD_HHmmss.png` は引き続き一覧に表示可能(後方互換).新規撮影は QM 形式で行われる ### 重複回避ロジック @@ -265,7 +277,10 @@ ### 削除 - 一覧画面の詳細表示オーバーレイから削除を実行する -- 削除前に確認ダイアログを表示する(「この画像を削除しますか?」) +- 削除前に確認ダイアログを表示する + - `.preview.jpg` の場合:「この画像を削除しますか?(DNG ファイルとメタデータも一緒に削除されます)」 + - `.png`(旧通常モード)の場合:「この画像を削除しますか?」 +- `.preview.jpg` 削除時は同 baseName の `.dng` と `.meta.json` も**連動削除**する - ファイルシステムから物理削除する(ゴミ箱機能なし) - 削除後,MediaStore からも当該エントリを除去する diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 05b4fb1..88f4c30 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -42,3 +42,12 @@ - 状況: programming-template から進捗記録の二段構成(CLAUDE.md 1 行 + PROGRESS.md 追記ログ)と関連スラッシュコマンド・ガイド更新を取り込み - 動機: テンプレート側で進捗記録ルールが更新された.CLAUDE.md が毎ターン読み込まれることを考えると履歴を切り離す設計は合理的 - 設計判断: `.gitignore` はテンプレート版(ミニマル)を採用せず Flutter 用エントリを温存.`CLAUDE.md` も全体上書きせず進捗欄だけ二段構成に書き換え.既存進捗エントリは本ファイルへ移植 + +## DNG プレビュー JPEG 機能を追加 + +- 状況: 撮影 1 回で `.preview.jpg`(1632×1224,100〜300KB)を `.dng` と並べて保存.一覧画面で表示・拡大できる.削除時は dng / meta.json と連動 +- 動機: DNG は Flutter `Image.file` でデコードできずアプリ内で撮影結果を確認できなかった.研究者が「ちゃんと撮れたか」を端末上で判断できるようにする +- 設計判断: 別ファイル方式(案 B)を採用.案 A(DNG 内サムネ)は Dart で TIFF パーサが必要で実装重い.案 C(マルチストリーム + JPEG)は ISP のフルパイプライン処理が混じり研究データの位置付けが曖昧になる.案 B は Kotlin で Bayer→2×2 demosaic→ガンマ 2.2 で JPEG 化するだけのシンプル実装で,DNG 本体は無変更で済む +- 失敗パターン: なし(実機で意図通り動作) +- 既知の課題: プレビューが緑がかる.原因はおそらく WB 未適用(COLOR_CORRECTION_GAINS=identity)と BGGR の G チャネルが 2 サンプル平均で他より重み付けされる効果の組み合わせ.プレビュー画質の話なので DNG 解析には影響しない +- 後段: 緑かぶり改善(グレーワールド WB or AsShotNeutral 反映).露光時間キャリブ機能.連射再現性検証 V1 diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 25c21be..121fb4c 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -176,6 +176,11 @@ 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( @@ -189,6 +194,9 @@ // MediaStore に登録(PC から MTP で見えるようにする) await _rawCaptureService.scanFile(capture.dngPath); await _rawCaptureService.scanFile(metaPath); + if (capture.previewPath != null) { + await _rawCaptureService.scanFile(capture.previewPath!); + } _lastSavedFileName = capture.dngPath.split('/').last; _errorMessage = null; diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart index 35fb41b..bb4e1d8 100644 --- a/lib/providers/gallery_provider.dart +++ b/lib/providers/gallery_provider.dart @@ -12,7 +12,7 @@ List get images => _images; bool get isLoading => _isLoading; - /// Pictures/MiniTIAS/ 内の PNG ファイルを取得し,新しい順に並べる. + /// Pictures/MiniTIAS/ 内の PNG ファイルと QM プレビュー JPEG を取得し,新しい順に並べる. Future loadImages() async { _isLoading = true; notifyListeners(); @@ -24,10 +24,11 @@ } else { final files = await directory .list() - .where( - (entity) => - entity is File && entity.path.toLowerCase().endsWith('.png'), - ) + .where((entity) { + final lower = entity.path.toLowerCase(); + return entity is File && + (lower.endsWith('.png') || lower.endsWith('.preview.jpg')); + }) .cast() .toList(); @@ -50,14 +51,31 @@ } /// 指定したファイルを削除し,一覧から除去する. + /// + /// QM プレビュー (*.preview.jpg) を削除する場合,同じ baseName の + /// .dng ファイルと .meta.json ファイルも連動して削除する. + /// .png ファイルは単独削除(後方互換). Future deleteImage(File file) async { + final path = file.path; + final lower = path.toLowerCase(); + + // 削除対象ファイル一覧を確定 + final filesToDelete = [file]; + if (lower.endsWith('.preview.jpg')) { + final base = path.substring(0, path.length - '.preview.jpg'.length); + filesToDelete.add(File('$base.dng')); + filesToDelete.add(File('$base.meta.json')); + } + try { - if (await file.exists()) { - await file.delete(); + for (final f in filesToDelete) { + if (await f.exists()) { + await f.delete(); + } } - // 削除成功後にのみ一覧から除去 + // 元ファイルが消えたら成功とみなす if (!await file.exists()) { - _images.remove(file); + _images.removeWhere((f) => f.path == file.path); notifyListeners(); return true; } diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index f25c11f..93aea80 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -71,7 +71,7 @@ children: [ Expanded( child: Text( - _selectedFile!.path.split('/').last, + _displayName(_selectedFile!), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -114,15 +114,35 @@ ); } + /// QM プレビューファイル(*.preview.jpg)かどうかを返す. + bool _isQmPreview(File file) => + file.path.toLowerCase().endsWith('.preview.jpg'); + + /// ファイルの表示名を返す. + /// + /// QM プレビュー (.preview.jpg) は対応する DNG ファイル名で表示する + /// (DNG が「本体」であることをユーザーに伝えるため). + String _displayName(File file) { + final name = file.path.split('/').last; + if (_isQmPreview(file)) { + return '${name.substring(0, name.length - '.preview.jpg'.length)}.dng'; + } + return name; + } + void _confirmDelete(GalleryProvider galleryProvider) { final file = _selectedFile; if (file == null) return; + final message = _isQmPreview(file) + ? 'この画像を削除しますか?\n(DNG ファイルとメタデータも一緒に削除されます)' + : 'この画像を削除しますか?'; + showDialog( context: context, builder: (context) => AlertDialog( title: const Text('画像を削除'), - content: const Text('この画像を削除しますか?'), + content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 7a655fe..223eab1 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -123,6 +123,16 @@ /// DNG ファイルのバイト数(書き込み確認用). final int dngFileSize; + /// 縮小プレビュー JPEG の絶対パス. + /// + /// Kotlin 側でプレビュー生成に失敗した場合は null. + final String? previewPath; + + /// プレビュー JPEG のバイト数. + /// + /// Kotlin 側でプレビュー生成に失敗した場合は null. + final int? previewFileSize; + /// 撮影メタデータ(設定値・実測値・センサ特性など). final Map metadata; @@ -138,6 +148,8 @@ const QuantitativeCaptureResult({ required this.dngPath, required this.dngFileSize, + this.previewPath, + this.previewFileSize, required this.metadata, this.lscMap, this.lscMapRowCount, @@ -149,6 +161,9 @@ final dngPath = map['dngPath'] as String; final dngFileSize = (map['dngFileSize'] as num).toInt(); + final previewPath = map['previewPath'] as String?; + final previewFileSize = (map['previewFileSize'] as num?)?.toInt(); + final rawMetadata = map['metadata']; final metadata = (_deepConvert(rawMetadata) as Map?) ?? {}; @@ -167,6 +182,8 @@ return QuantitativeCaptureResult( dngPath: dngPath, dngFileSize: dngFileSize, + previewPath: previewPath, + previewFileSize: previewFileSize, metadata: metadata, lscMap: lscMap, lscMapRowCount: lscMapRowCount, diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart new file mode 100644 index 0000000..68824a8 --- /dev/null +++ b/test/providers/gallery_provider_test.dart @@ -0,0 +1,341 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// テスト専用の GalleryProvider 実装. +/// +/// プロダクションの GalleryProvider はパスがハードコードされており +/// サブクラスでオーバーライドできないため,同じロジックをテスト用パスで +/// 再実装する.仕様(SPEC_01)に準拠した振る舞いをテストする. +class _GalleryProviderTestable extends ChangeNotifier { + _GalleryProviderTestable(this._basePath); + + final String _basePath; + + List _images = []; + bool _isLoading = false; + + List get images => _images; + bool get isLoading => _isLoading; + + /// Pictures/MiniTIAS/ 内の PNG と .preview.jpg を取得し,新しい順に並べる. + Future loadImages() async { + _isLoading = true; + notifyListeners(); + + try { + final directory = Directory(_basePath); + if (!await directory.exists()) { + _images = []; + } else { + final files = await directory + .list() + .where((entity) { + final lower = entity.path.toLowerCase(); + return entity is File && + (lower.endsWith('.png') || lower.endsWith('.preview.jpg')); + }) + .cast() + .toList(); + + // 更新日時の新しい順にソート + files.sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + _images = files; + } + } catch (e) { + debugPrint('画像一覧の取得に失敗: $e'); + _images = []; + } + + _isLoading = false; + notifyListeners(); + } + + /// 指定したファイルを削除し,一覧から除去する. + /// + /// QM プレビュー (*.preview.jpg) を削除する場合,同じ baseName の + /// .dng ファイルと .meta.json ファイルも連動して削除する. + /// .png ファイルは単独削除(後方互換). + Future deleteImage(File file) async { + final path = file.path; + final lower = path.toLowerCase(); + + final filesToDelete = [file]; + if (lower.endsWith('.preview.jpg')) { + final base = path.substring(0, path.length - '.preview.jpg'.length); + filesToDelete.add(File('$base.dng')); + filesToDelete.add(File('$base.meta.json')); + } + + try { + for (final f in filesToDelete) { + if (await f.exists()) { + await f.delete(); + } + } + if (!await file.exists()) { + _images.remove(file); + notifyListeners(); + return true; + } + return false; + } catch (e) { + debugPrint('画像の削除に失敗: $e'); + return false; + } + } +} + +void main() { + group('GalleryProvider.loadImages', () { + late Directory tempDir; + late _GalleryProviderTestable provider; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'mini_tias_gallery_test_', + ); + provider = _GalleryProviderTestable(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('.png のみある場合,全件取得する', () async { + await File('${tempDir.path}/MiniTIAS_20260101_120000.png').create(); + await File('${tempDir.path}/MiniTIAS_20260101_120001.png').create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, contains('MiniTIAS_20260101_120000.png')); + expect(names, contains('MiniTIAS_20260101_120001.png')); + expect(provider.images, hasLength(2)); + }); + + test('.preview.jpg のみある場合,全件取得する', () async { + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg', + ).create(); + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ).create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, contains('MiniTIAS_QM_20260101_120000.preview.jpg')); + expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg')); + expect(provider.images, hasLength(2)); + }); + + test('.png と .preview.jpg が混在する場合,両方取得する', () async { + await File('${tempDir.path}/MiniTIAS_20260101_120000.png').create(); + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ).create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, contains('MiniTIAS_20260101_120000.png')); + expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg')); + expect(provider.images, hasLength(2)); + }); + + test('.dng や .meta.json は取得しない', () async { + await File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng').create(); + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ).create(); + + await provider.loadImages(); + + expect(provider.images, isEmpty); + }); + + test('通常 .jpg(.preview.jpg 以外)は取得しない', () async { + // .preview.jpg のみが対象,単純な .jpg は除外される + await File('${tempDir.path}/photo.jpg').create(); + await File('${tempDir.path}/MiniTIAS_20260101_120000.jpg').create(); + // 比較用に .preview.jpg を 1 件追加 + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ).create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, isNot(contains('photo.jpg'))); + expect(names, isNot(contains('MiniTIAS_20260101_120000.jpg'))); + expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg')); + expect(provider.images, hasLength(1)); + }); + + test('ディレクトリが存在しない場合,空リストを返す', () async { + final nonExistentPath = '${tempDir.path}/nonexistent'; + final providerNonExistent = _GalleryProviderTestable(nonExistentPath); + + await providerNonExistent.loadImages(); + + expect(providerNonExistent.images, isEmpty); + }); + + test('ファイルが新しい順(更新日時降順)にソートされる', () async { + // 先に古いファイルを作成し,十分な時間差を設けてから新しいファイルを作成する + final olderPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + final newerPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg'; + await File(olderPath).create(); + // Windows NTFS の mtime 精度 (100ns) より十分大きい 1 秒以上待機 + await Future.delayed(const Duration(seconds: 2)); + await File(newerPath).create(); + + await provider.loadImages(); + + expect(provider.images, hasLength(2)); + // 新しい順なので newer が先頭 + final firstBasename = provider.images.first.path + .split(RegExp(r'[\\/]')) + .last; + expect(firstBasename, 'MiniTIAS_QM_20260101_120001.preview.jpg'); + }); + }); + + group('GalleryProvider.deleteImage', () { + late Directory tempDir; + late _GalleryProviderTestable provider; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('mini_tias_del_test_'); + provider = _GalleryProviderTestable(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('.preview.jpg を削除すると同 basename の .dng も削除される', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final dngFile = File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng'); + final metaFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ); + await File(previewPath).create(); + await dngFile.create(); + await metaFile.create(); + + await provider.loadImages(); + // loadImages で取得した同一インスタンスを使って削除する + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(await dngFile.exists(), isFalse); + }); + + test('.preview.jpg を削除すると同 basename の .meta.json も削除される', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final dngFile = File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng'); + final metaFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ); + await File(previewPath).create(); + await dngFile.create(); + await metaFile.create(); + + await provider.loadImages(); + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(await metaFile.exists(), isFalse); + }); + + test('.png を削除しても他のファイルは削除されない', () async { + final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + // 同名の .dng が仮に存在しても,連動削除の対象にならない + final dngFile = File('${tempDir.path}/MiniTIAS_20260101_120000.dng'); + await File(pngPath).create(); + await dngFile.create(); + + await provider.loadImages(); + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + // .dng は削除されていない + expect(await dngFile.exists(), isTrue); + }); + + test('連動削除対象の .dng が最初から存在しない場合でもエラーにならず .preview.jpg の削除は成功する', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final metaFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ); + // .dng は作成しない + await File(previewPath).create(); + await metaFile.create(); + + await provider.loadImages(); + final fileToDelete = provider.images.first; + // .dng が存在しなくても例外にならない + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(await File(previewPath).exists(), isFalse); + }); + + test('削除成功後,images リストから対象ファイルが除去される(.preview.jpg)', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + await File(previewPath).create(); + + await provider.loadImages(); + expect(provider.images, hasLength(1)); + + // loadImages で保持している同一インスタンスを使って削除する + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(provider.images, isEmpty); + }); + + test('削除成功後,images リストから対象ファイルが除去される(.png)', () async { + final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + await File(pngPath).create(); + + await provider.loadImages(); + expect(provider.images, hasLength(1)); + + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(provider.images, isEmpty); + }); + }); +} diff --git a/test/services/raw_capture_service_test.dart b/test/services/raw_capture_service_test.dart index a7ca24e..68daee8 100644 --- a/test/services/raw_capture_service_test.dart +++ b/test/services/raw_capture_service_test.dart @@ -138,5 +138,78 @@ expect(row[1], closeTo(2.0, 0.0001)); expect(row[2], closeTo(3.14, 0.0001)); }); + + // --- previewPath / previewFileSize 関連テスト --- + + test('previewPath と previewFileSize が両方 non-null の場合,フィールドに値がセットされる', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + 'previewPath': '/storage/emulated/0/Pictures/MiniTIAS/test.preview.jpg', + 'previewFileSize': 204800, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect( + result.previewPath, + '/storage/emulated/0/Pictures/MiniTIAS/test.preview.jpg', + ); + expect(result.previewFileSize, 204800); + expect(result.previewFileSize, isA()); + }); + + test('previewPath が null の場合,previewPath は null になる', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + 'previewPath': null, + 'previewFileSize': null, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.previewPath, isNull); + expect(result.previewFileSize, isNull); + }); + + test( + 'map に previewPath / previewFileSize キー自体が存在しない場合でも fromMap が成功し両フィールドが null になる', + () { + // 後方互換: プレビュー生成前の Kotlin が返す map にキーが存在しない場合 + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.previewPath, isNull); + expect(result.previewFileSize, isNull); + }, + ); + + test('previewFileSize が int 直接でも num 経由でも正しく int に変換される', () { + // int 直接のケース + final mapInt = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'previewPath': '/test.preview.jpg', + 'previewFileSize': 204800, // Dart int + }; + final resultInt = QuantitativeCaptureResult.fromMap(mapInt); + expect(resultInt.previewFileSize, 204800); + expect(resultInt.previewFileSize, isA()); + + // num (double) 経由のケース + final mapDouble = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'previewPath': '/test.preview.jpg', + 'previewFileSize': 204800.0, // double として渡される場合 + }; + final resultDouble = QuantitativeCaptureResult.fromMap(mapDouble); + expect(resultDouble.previewFileSize, 204800); + expect(resultDouble.previewFileSize, isA()); + }); }); }