diff --git "a/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" "b/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" index 8730cc5..52d0a66 100644 --- "a/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" +++ "b/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" @@ -65,16 +65,19 @@ ### 向き変換が2系統に分かれている点(最重要) -**同じ「撮影者が見る向き」を作るのに,2箇所で別々の手段を使っている.** +**同じ「撮影者が見る向き」を作るのに,入力が異なる2系統で別々の手段を使っている.** +ただし両系統の値は **`lib/models/display_orientation.dart` の `DisplayOrientation` に +一元化**されており,変更窓口は1箇所である. -| 系統 | 向き処理 | 実装箇所 | -| --- | --- | --- | -| ライブ(Flutter 描画) | `Matrix4.diagonal3Values(-1, -1, 1)`(X 反転+Y 反転=180°回転) | `lib/widgets/oriented_preview.dart`(`kDisplayFlip`) | -| スナップショット(native) | `rotation:90 + mirror:true`(Kotlin 側で回転・ミラー) | `lib/providers/camera_provider.dart`(`convertYuvToJpeg` 呼び出し) | +| 系統 | 向き処理 | 派生元(単一定義) | 適用箇所 | +| --- | --- | --- | --- | +| ライブ(Flutter 描画) | `Matrix4.diagonal3Values(-1, -1, 1)`(X 反転+Y 反転=180°回転) | `DisplayOrientation.previewMatrix` | `lib/widgets/oriented_preview.dart` | +| スナップショット(native) | `rotation:90 + mirror:true`(Kotlin 側で回転・ミラー) | `DisplayOrientation.snapshotRotationDegrees` / `.snapshotMirror` | `lib/providers/camera_provider.dart`(`convertYuvToJpeg`) | -> **向きを変更したいときは,この2箇所を整合させる必要がある.** -> 片方だけ変えるとライブ表示と保存スナップショットがズレる. -> 変更後は必ず §5 の確認手順を実施すること. +> **向きを変更したいときは,`DisplayOrientation.attachmentDownward` の値だけを変更する.** +> ライブ系とスナップショット系は適用対象(入力空間)が異なるため,1つの定義から +> 派生していても**幾何的な整合は実機でしか保証できない**. +> 変更後は必ず §5 の確認手順(段階1 自動 + 段階2 実機)を実施すること. --- @@ -135,12 +138,14 @@ | --- | --- | | `lib/models/preview_region.dart` | クロップ領域(表示後座標)の不変モデル | | `lib/services/preview_region_service.dart` | `preview_region.json` の読み込み・フォールバック | -| `lib/widgets/oriented_preview.dart` | 向き変換(ライブ系,`kDisplayFlip`) | +| `lib/models/display_orientation.dart` | **向き設定の単一定義**(ライブ・スナップショット両系統の派生元) | +| `lib/widgets/oriented_preview.dart` | 向き変換の適用(ライブ系,`DisplayOrientation.previewMatrix`) | | `lib/widgets/region_crop.dart` | クロップ拡大(ライブ・スナップショット共通) | | `lib/widgets/camera_preview.dart` | ライブプレビュー(向き変換+クロップ) | | `lib/widgets/cropped_snapshot.dart` | 保存中スナップショット表示(クロップのみ) | | `lib/providers/camera_provider.dart` | スナップショット系の向き処理(`rotation:90, mirror:true`) | | `assets/config/preview_region.json` | クロップ領域の設定値 | | `scripts/preview_region_picker.html` | クロップ領域の調整ツール | +| `test/models/display_orientation_test.dart` | 向き派生値の契約テスト(段階1,native パラメータの固定) | | `test/widgets/preview_orientation_golden_test.dart` | 向き・クロップの回帰テスト(段階1) | | `local_reference/`(非コミット) | 実機確認の基準スクリーンショット保管場所(段階2,個人情報のため Git 除外) | diff --git a/lib/models/display_orientation.dart b/lib/models/display_orientation.dart new file mode 100644 index 0000000..74de96b --- /dev/null +++ b/lib/models/display_orientation.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +/// プレビューを「撮影者が見る向き」に整えるための向き設定(単一の真実). +/// +/// スマホを画面が下向きになるようアタッチメントへ載せて撮影する運用のため, +/// 生のカメラ映像をそのまま表示すると上下・左右が反転している. +/// 本クラスはその補正方法を一元管理する. +/// +/// 向きの補正は**入力が異なる2系統**に分かれており,本クラスが両者の値を +/// まとめて保持する(座標系・パイプラインの詳細は SPEC_04 参照): +/// +/// - **ライブ系**:`camera` パッケージの `CameraPreview` +/// (プラグインが既にセンサ向きを適用済み)に対し,追加で X 反転(鏡像)+ +/// Y 反転(上下)を行う. → [previewMatrix](`OrientedPreview` が使用) +/// - **スナップショット系**:生 YUV フレーム(センサ向き未適用)を native で +/// 回転・ミラーして同じ最終向きにする. +/// → [snapshotRotationDegrees] / [snapshotMirror](`convertYuvToJpeg` に渡す) +/// +/// 2系統は適用対象(入力空間)が異なるため幾何的には別変換だが, +/// 「下向き設置時の撮影者向き」という**1つの意図**を実現する組として束ねている. +/// 向きを変えたいときは定数 [attachmentDownward] の値のみを変更すればよい. +/// ただし2系統の整合は実機でしか保証できないため,変更後は必ず実機で表示一致を +/// 確認すること(SPEC_04 段階2). +@immutable +class DisplayOrientation { + /// ライブ系で X 反転(鏡像)するか. + final bool flipHorizontal; + + /// ライブ系で Y 反転(上下)するか. + final bool flipVertical; + + /// スナップショット系(native)の回転角(度). + final int snapshotRotationDegrees; + + /// スナップショット系(native)でミラーするか. + final bool snapshotMirror; + + const DisplayOrientation({ + required this.flipHorizontal, + required this.flipVertical, + required this.snapshotRotationDegrees, + required this.snapshotMirror, + }); + + /// 下向きアタッチメント設置時の向き設定(現行の確定値). + /// + /// この値を変えるとライブ・スナップショット両系統の向きが変わる. + static const DisplayOrientation attachmentDownward = DisplayOrientation( + flipHorizontal: true, + flipVertical: true, + snapshotRotationDegrees: 90, + snapshotMirror: true, + ); + + /// ライブプレビューに適用する変換行列(`Transform` 用). + /// + /// X/Y の反転を対角成分の符号で表現する(縦横同一スケールで歪み防止). + Matrix4 get previewMatrix => Matrix4.diagonal3Values( + flipHorizontal ? -1.0 : 1.0, + flipVertical ? -1.0 : 1.0, + 1.0, + ); +} diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 523e5c7..8dffa5c 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:smtias_capture/models/display_orientation.dart'; import 'package:smtias_capture/models/preview_frame.dart'; import 'package:smtias_capture/models/preview_region.dart'; import 'package:smtias_capture/services/file_service.dart'; @@ -154,6 +155,9 @@ if (_latestFrame == null) return null; final frame = _latestFrame!; + // 向き処理はライブ系と共通の単一定義(DisplayOrientation)から派生させる. + // スナップショット系は生 YUV(センサ向き未適用)を native で回転・ミラーする. + const orientation = DisplayOrientation.attachmentDownward; return _rawCaptureService.convertYuvToJpeg( width: frame.width, height: frame.height, @@ -163,8 +167,8 @@ yRowStride: frame.yRowStride, uvRowStride: frame.uvRowStride, uvPixelStride: frame.uvPixelStride, - rotation: 90, - mirror: true, + rotation: orientation.snapshotRotationDegrees, + mirror: orientation.snapshotMirror, ); } diff --git a/lib/widgets/oriented_preview.dart b/lib/widgets/oriented_preview.dart index ee75b4c..a9e24fe 100644 --- a/lib/widgets/oriented_preview.dart +++ b/lib/widgets/oriented_preview.dart @@ -1,37 +1,35 @@ import 'package:flutter/material.dart'; -/// 表示の向き調整を行うかどうか(撮影者が見る向き). -/// -/// スマホを下向きにしてアタッチメントへ載せる運用のため, -/// 生プレビューを 180°回転(X 反転=鏡像 + Y 反転=上下反転)して -/// 「撮影者が見る向き」に整える. -/// -/// 向き変換の詳細・座標系は -/// [SPEC_04 プレビュー向きとクロップ](../../docs/04_SPEC/SPEC_04_プレビュー向きとクロップ.md) 参照. -const bool kDisplayFlip = true; +import 'package:smtias_capture/models/display_orientation.dart'; /// [child](向き調整前のコンテンツ)に表示向き変換を適用するウィジェット. /// /// 「生フレーム座標」のコンテンツを,撮影者が見る「表示後座標」へ変換する. -/// この変換結果に対して [RegionCrop] が表示後座標 0.0–1.0 でクロップする. +/// 変換内容は [orientation](既定: [DisplayOrientation.attachmentDownward])に +/// 従う.向きの定義は `DisplayOrientation` に一元化されており,変更時はそちらを +/// 編集する(座標系・確認手順は SPEC_04 参照). /// +/// この変換結果に対して [RegionCrop] が表示後座標 0.0–1.0 でクロップする. /// ライブプレビュー([child] = `CameraPreview`)とテスト([child] = 任意の画像)で /// 共通利用できるよう,向き変換のみを担い [child] を差し替え可能にしている. class OrientedPreview extends StatelessWidget { - const OrientedPreview({super.key, required this.child}); + const OrientedPreview({ + super.key, + required this.child, + this.orientation = DisplayOrientation.attachmentDownward, + }); /// 向き調整前のコンテンツ(生フレーム座標). final Widget child; + /// 適用する向き設定(単一の真実). + final DisplayOrientation orientation; + @override Widget build(BuildContext context) { return Transform( alignment: Alignment.center, - // kDisplayFlip: X 反転(鏡像)+ Y 反転(上下)= 180°回転. - // 下向き設置で撮影者が見る向きに合わせる.詳細は SPEC_04 参照. - transform: kDisplayFlip - ? Matrix4.diagonal3Values(-1.0, -1.0, 1.0) - : Matrix4.identity(), + transform: orientation.previewMatrix, child: child, ); } diff --git a/test/models/display_orientation_test.dart b/test/models/display_orientation_test.dart new file mode 100644 index 0000000..6d3a30e --- /dev/null +++ b/test/models/display_orientation_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:smtias_capture/models/display_orientation.dart'; + +/// 向きの単一定義 [DisplayOrientation] が,ライブ系・スナップショット系の +/// 両方へ正しい値を派生させることを固定する契約テスト. +/// +/// リファクタリングで「現行の向き挙動」が変わっていないことを保証する. +/// (ライブ系の見た目はゴールデンテスト,最終的な向きの正しさは実機目視で確認.) +void main() { + group('DisplayOrientation.attachmentDownward(下向き設置の確定値)', () { + const orientation = DisplayOrientation.attachmentDownward; + + test('ライブ系: previewMatrix が 180°回転(X反転+Y反転)である', () { + expect(orientation.flipHorizontal, isTrue); + expect(orientation.flipVertical, isTrue); + expect( + orientation.previewMatrix.storage, + Matrix4.diagonal3Values(-1.0, -1.0, 1.0).storage, + ); + }); + + test('スナップショット系: native へ rotation:90, mirror:true を渡す', () { + expect(orientation.snapshotRotationDegrees, 90); + expect(orientation.snapshotMirror, isTrue); + }); + }); + + group('previewMatrix の派生', () { + test('反転なしの場合は単位行列になる', () { + const noFlip = DisplayOrientation( + flipHorizontal: false, + flipVertical: false, + snapshotRotationDegrees: 0, + snapshotMirror: false, + ); + expect(noFlip.previewMatrix.storage, Matrix4.identity().storage); + }); + + test('X のみ反転すると対角 x = -1 になる', () { + const flipX = DisplayOrientation( + flipHorizontal: true, + flipVertical: false, + snapshotRotationDegrees: 0, + snapshotMirror: false, + ); + expect( + flipX.previewMatrix.storage, + Matrix4.diagonal3Values(-1.0, 1.0, 1.0).storage, + ); + }); + }); +}