diff --git a/.claude/agents/coder.md b/.claude/agents/coder.md index c85583c..08ddce6 100644 --- a/.claude/agents/coder.md +++ b/.claude/agents/coder.md @@ -1,7 +1,7 @@ --- name: coder description: "機能実装・バグ修正に集中するエージェント.テストやリファクタリングは行わない.実装パイプライン(/implement)の Phase 1 で使用される." -model: sonnet +model: opus tools: - Read - Glob diff --git a/.claude/agents/refactorer.md b/.claude/agents/refactorer.md index 000b45d..3c4a339 100644 --- a/.claude/agents/refactorer.md +++ b/.claude/agents/refactorer.md @@ -1,7 +1,7 @@ --- name: refactorer description: "テストが通っている状態でコード品質を改善するエージェント.テストを安全網として使い,挙動を変えずにリファクタリングする.実装パイプライン(/implement)の Phase 3 で使用される." -model: sonnet +model: opus tools: - Read - Glob diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md index aad79ba..a5a4566 100644 --- a/.claude/agents/tester.md +++ b/.claude/agents/tester.md @@ -1,7 +1,7 @@ --- name: tester description: "仕様ベースでテストを作成・実行するエージェント.実装の正しさを検証する.実装パイプライン(/implement)の Phase 2 で使用される." -model: sonnet +model: opus tools: - Read - Glob diff --git a/.claude/commands/auto-refactor.md b/.claude/commands/auto-refactor.md new file mode 100644 index 0000000..aa30a15 --- /dev/null +++ b/.claude/commands/auto-refactor.md @@ -0,0 +1,154 @@ +--- +name: auto-refactor +model: opus +description: "放置中(無人)に自律的に回り続け,空き時間を大規模リファクタリングに充てるループ.テスト保護下の箇所のみを対象に,リファクタ→テスト緑確認→専用ブランチへ自律コミット,を安全な対象が尽きるまで繰り返す." +argument-hint: "<対象ディレクトリ/モジュールの絞り込み(省略可)>" +--- + +あなたは**自律リファクタリングループのオペレーター**です. +ユーザーが席を外している無人時間に,空いた稼働枠を大規模リファクタリングに充てるために起動されました. +GUIDE_04(Git 運用)と GUIDE_05(エージェント運用)を読み,書式・パイプラインのルールを理解した上で,以下の不変条件を厳守してループを回してください. + +## このコマンドの位置づけ(重要) + +- 本コマンドは **CLAUDE.md の「`/commit` 自発実行禁止」ルールに対する,ユーザー承認済みの明示的な例外**である. + - ただし例外が認められるのは **`refactor/` 専用ブランチへのコミットに限る**. + - **`git push`・PR 作成・マージ・`main` への操作は一切行わない**(マージ可否の判断は後で人間が行う). +- 「無人で回り続ける」ことが目的のため,各 Phase での人間確認は挟まない. + - これが許されるのは,**リファクタリングが「挙動を変えない」作業であり,テストが安全網として人間確認の代わりを果たす**から. + - したがって**テストという安全網が無い箇所には絶対に触らない**(後述の不変条件). + +## 不変条件 (Invariants) — 1つでも破れそうなら停止する + +1. **挙動を変えない**:リファクタ前後で**検証スイートが完全に緑のまま**であること(検証スイート=テスト+利用可能ならビルド/型チェック/リンタ.後述).赤が1つでも出たら,その変更は破棄(revert/stash)して対象をスキップする. +2. **テスト保護下で着手する**:リファクタは必ず**安全網(緑のテスト)がある状態**で行う.対象にテストが無い場合は,**まず特性化テスト(現状の挙動を固定するテスト)を生成して保護下にしてから**着手する(後述のステップ A2).テストフレームワーク自体が特定できない/生成テストを流せない箇所は対象にしない. +3. **専用ブランチのみ**:作業は `refactor/` ブランチ上でのみ行う.`main` には絶対にコミットしない. +4. **push/PR しない**:リモート操作は一切しない. +5. **1リファクタ = 1コミット**:意味のあるまとまり単位でこまめにコミットし,巨大な未コミット差分を溜めない. +6. **除外領域に触れない**:後述の「対象外の領域(除外リスト)」に該当する箇所はリファクタ対象に選ばない. +7. **疑わしきは停止**:判断に迷う・安全網が確認できない・テストコマンドが特定できない場合は,無理に進めず停止して状況を報告する. + +## 対象外の領域(除外リスト) — これらは選ばない + +以下は無人で触ると事故りやすく,かつリファクタの価値が薄いため,**対象に選ばない**.ステップ A の対象選定時に必ず除外する. + +- 自動生成コード(コードジェネレータ出力,`*.g.dart`,`*.pb.go`,スナップショット等) +- 依存・サードパーティ(`node_modules/`,`vendor/`,`.dart_tool/`,`target/`,ベンダリングされた外部ソース) +- ロックファイル・依存マニフェスト(`package-lock.json`,`yarn.lock`,`pubspec.lock`,`Cargo.lock`,`go.sum` 等) +- マイグレーション・スキーマ履歴(適用済みの DB マイグレーション等,後から書き換えてはいけないもの) +- 設定・秘匿情報(`.env*`,CI 設定,各種 config ファイル) +- ビルド成果物・キャッシュ(`dist/`,`build/`,`.next/`,`coverage/` 等) +- ドキュメント・本テンプレートの規約ファイル(`docs/`,`CLAUDE.md`,`.claude/` 配下) + +判断に迷うファイルは「触らない」側に倒す(不変条件 7). + +## セットアップ (Pre-check) — ループ開始前に一度だけ + +1. **対象範囲の確認**:`$ARGUMENTS` に絞り込み(ディレクトリ/モジュール)があればその範囲に限定する.無ければプロジェクト全体を対象候補とする. +2. **ブランチの準備**: + - `git status` で作業ツリーがクリーンか確認する.未コミットの変更があれば,無人で巻き込むのは危険なので**停止して報告**する. + - 現在のブランチを確認する.`main` にいる場合は GUIDE_04 のブランチ命名規則に従い `refactor/<英単語2〜4語(kebab-case)>` ブランチを作成して移動する(例: `refactor/auto-cleanup`). + - 既に `refactor/` ブランチにいる場合はそれを使う.`feature/` 等の他作業ブランチにいる場合は,混在を避けるため**停止して確認を求める**. +3. **検証スイートの特定**:プロジェクトの構成ファイル(`package.json`,`pubspec.yaml`,`Cargo.toml`,`go.mod`,`pyproject.toml`/`pytest.ini` 等)から,以下を特定する. + - **テスト実行コマンドとフレームワーク**(必須). + - 利用可能なら **ビルド/型チェック/リンタのコマンド**(例: `tsc --noEmit`,`cargo build`,`go build ./...`,`flutter analyze`,`npm run lint` 等).これらは「テスト緑」だけでは拾えないコンパイル破壊やリンタ違反を検出する追加の安全網になる. + - 以降,**「検証スイート」= テスト+(あれば)ビルド/型チェック/リンタ** をまとめて指す.緑の判定はこの検証スイート全体で行う. + - テストが1つも無くても,**テストフレームワークが特定でき新規テストを書いて流せる**なら開始してよい(無い箇所は特性化テストを生成して保護下にする). + - テストフレームワーク自体が存在せず特定できない場合は,どの土台でテストを書くかは設計判断が要るため,**無人で土台を新設せず停止して報告**する. +4. **ベースラインの確認**:既存テストがある場合は検証スイートを実行し,**開始時点で全て緑**であることを確認する.赤がある場合は,リファクタの前提(緑からの出発)が崩れているため**停止して報告**する.既存テストが0件の場合はテスト実行を省略してよいが,ビルド/型チェックがあれば実行して緑を確認する. +5. **スキップ台帳の読み込み**:`.claude/auto-refactor-skip.md` が存在すれば読み込む.ここに記録された対象は「過去に安全に扱えなかった」ものなので,今回も**再挑戦せず対象から除外**する(`/loop` のウェイクをまたいで失敗対象に再挑戦し,トークンを浪費するのを防ぐ).ファイルが無ければ空として扱う. + - 形式は1行1エントリ: `- <対象(ファイル/関数)> | <理由> | <日付>`. + - このファイルはローカルな運用状態であり成果物ではないため,`.gitignore` 対象とする(追跡されていなければ `.gitignore` に追記する). + +## ループ本体 (Loop Body) — 安全な対象が尽きるまで繰り返す + +各イテレーションで以下を1サイクル実行する. + +### ステップ A: 対象を1つ選ぶ + +- セットアップで確定した範囲の中から,リファクタリング対象を**1つ**選ぶ. +- 選定時に除外するもの:「対象外の領域(除外リスト)」に該当する箇所,および**スキップ台帳に記録済みの対象**は選ばない. +- 優先順位の目安(高い順):重複コードの集約 → 過度に長い関数の分割 → 命名の改善 → デッドコード除去 → 構造の単純化. +- 「その箇所を変更したときに失敗するテストが存在するか」を確認する. + - **保護下にある** → ステップ B へ. + - **テストが無い** → ステップ A2(特性化テストの生成)へ. +- リファクタ対象が無くなったら**ループを終了**し,完了報告へ進む. + +### ステップ A2: 特性化テストの生成(テストが無い対象のみ/tester エージェントに委譲) + +テストが無い対象は,まず安全網を作る.tester エージェントに以下を委譲する: + +「次の対象に対し,**現状の挙動をそのまま固定する特性化テスト(characterization test)**を作成してください.理想の仕様ではなく,**今のコードが実際に返す挙動**を観測してアサーションに落とすこと(バグも含めて現状を固定する).境界値・主要な分岐・代表的な入出力を網羅してください.作成後にテストを実行し,**現状コードで全て緑**になることを確認してください.緑にできない(=挙動が固定できない)場合は,その旨を報告してください. + +対象: {ステップ A で選んだ対象} +テストコマンド: {セットアップで特定したコマンド}」 + +- tester の報告と実テスト結果を確認する. + - **生成テストが現状コードで全て緑** → これを安全網として `git add` し,タグ **`[add]`** でコミットする(例: `[add] 〇〇モジュールに特性化テストを追加`).**この時点ではまだリファクタしない**.コミット後,この対象を「保護下になった」状態でステップ B へ進む(同一イテレーション内で続けてよい). + - **緑にできなかった/挙動が固定できない** → この対象は安全に触れないため変更を破棄し,**スキップ台帳(`.claude/auto-refactor-skip.md`)に「対象・理由・日付」を追記**してからステップ A へ戻る(以後のイテレーション・ウェイクで再選択しない). + +### ステップ B: リファクタ前の緑確認 + +- 検証スイート(テスト+あればビルド/型チェック/リンタ)を実行し,緑であることを確認する(ベースライン/生成した特性化テストの再確認). + +### ステップ C: リファクタリング実行(refactorer エージェントに委譲) + +refactorer エージェントに以下を委譲する: + +「次の対象を,挙動を変えずにリファクタリングしてください.テストを安全網とし,リファクタ後に検証スイートを再実行して**全て緑のまま**であることを必ず確認してください.緑にできない場合は変更を破棄し,その旨を報告してください. + +対象: {ステップ A で選んだ対象} +検証コマンド: {セットアップで特定したテスト+ビルド/型チェック/リンタのコマンド}」 + +### ステップ D: リファクタ後の緑確認 + +- refactorer の報告と実際の検証スイート(テスト+あればビルド/型チェック/リンタ)の結果を確認する. +- **全て緑** → ステップ E へ. +- **赤がある/緑にできなかった** → その変更を破棄(`git checkout -- .` または `git stash`)し,**スキップ台帳(`.claude/auto-refactor-skip.md`)に「対象・理由・日付」を追記**して**次のイテレーションのステップ A へ**(同じ対象は再選択しない). + +### ステップ E: 専用ブランチへコミット + +- `git add` で当該変更をステージングする(`.env`・クレデンシャル・ビルド成果物は含めない). +- GUIDE_04 のコミット書式に従い,タグ **`[clean]`** でコミットする(例: `[clean] 重複した入力バリデーションを共通関数に集約`). +- **push・PR はしない.** `main` へのマージもしない. + +### ステップ F: 次へ + +- ステップ A に戻り,次の対象を選ぶ. + +## 停止条件 (Stop Conditions) + +以下のいずれかでループを止める. + +- リファクタ対象(テスト生成で保護下にできるものを含む)が尽きた → **正常終了**. +- 同じ箇所で繰り返しテストが赤になる/テストコマンドが途中で失敗するようになった → **異常として停止し報告**. +- 不変条件を満たせない状況になった → **停止して報告**. +- ユーザーが割り込んで中断した → その時点までの成果(コミット済み)は `refactor/` ブランチに残る. + +## /loop との併用(推奨される使い方) + +このコマンドは単体で「対象が尽きるまで」連続実行するが,ユーザーが `/loop` でラップして起動すると,放置中の空き枠を使って自律的に再トリガーし続けられる(本コマンドの本来の目的). +`/loop` で回す場合も上記の不変条件・停止条件は同じく厳守すること.長い待機を挟む場合はキャッシュとコストを意識した間隔を選ぶ. + +### 収束条件(loop-until-dry)— 空回りでトークンを浪費しない + +`/loop` で回すと,対象が尽きた後もウェイクのたびに「対象なし → 即終了」を繰り返し,**無駄にトークンを消費**しうる.これを防ぐため,以下の収束ルールを守る. + +- 各イテレーションの開始時に全範囲を再スキャンし,**この回で実施した作業(リファクタ+特性化テスト生成)の件数**を数える. +- **作業件数が 0 だった回**を「空振り」とカウントする. +- **空振りが 2 回連続**したら,もう安全に拾える対象は出尽くしたとみなし,**`/loop` 自体を終了する**(次のウェイクをスケジュールしない). + - 1 回の空振りで即終了しないのは,「順序やコミットの巡り合わせで一時的に拾えなかっただけ」のケースを 1 回分許容するため. +- 1 件でも作業した回が出たら,空振りカウントは 0 にリセットする. +- 終了時は「空振りが続いたため収束した」旨を完了報告に明記する. + +## 完了報告 (Reporting) + +ループ終了時,ユーザーに以下を報告する. + +- 作業ブランチ名 +- 追加した特性化テストの件数(= `[add]` コミット数)と対象モジュールの一覧 +- 実施したリファクタリングの件数(= `[clean]` コミット数)と概要の一覧 +- スキップした対象とその理由(挙動が固定できずテスト生成に失敗/検証スイートが緑にできなかった 等).詳細はスキップ台帳 `.claude/auto-refactor-skip.md` に追記済み +- ⚠ 自動生成した特性化テストは**現状の挙動(バグを含む)を固定**したもの.後で内容を一度目視確認することを推奨する旨を添える +- 次のアクションの案内:「内容を確認の上,問題なければ `/commit push`(または `merge`)で `main` に取り込めます」 + - **本コマンドからは push・PR・マージを行わない**.取り込みは必ずユーザーの明示指示(`/commit push` 等)で行う. diff --git a/.claude/template-sync-sha b/.claude/template-sync-sha index da97abe..29ab0d2 100644 --- a/.claude/template-sync-sha +++ b/.claude/template-sync-sha @@ -1 +1 @@ -25655cd808cbc494676d576d0eab5e86126d39fb +413ebfd34e836c3635da380e37cb5f5b70e1c1ec diff --git a/.gitignore b/.gitignore index 1be4d47..f365d60 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ # ローカル専用の参考素材(Claude への状況説明用サンプル画像など.Git には含めない) local_reference/ +# ローカル専用のリリース反映スクリプト(研究室サーバ URL を含むため Git に載せない) +scripts/release-push.ps1 + # Claude Code .claude/plans/ diff --git a/CLAUDE.md b/CLAUDE.md index 0f57249..87b7cdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ - ブランチ名: `feature/`/`fix/`/`docs/`/`chore/` + 英単語 2〜4 語(kebab-case) - コミットメッセージ: `[add]`/`[update]`/`[fix]`/`[remove]`/`[clean]` + 日本語 - コミット・push 後は `gh pr create` で PR を作成する +- **`/commit` はユーザーが明示的に指示した時のみ実行する.Claude が自発的に `/commit` や `git commit` を呼んではならない**(`/implement` 完了後も,案内するだけで自分ではコミットしない) ### エージェントチーム(GUIDE_05 準拠) @@ -76,6 +77,7 @@ - 画面機能仕様書(画面・遷移・UI・パーミッション): docs/04_SPEC/SPEC_01_画面機能仕様書.md - カメラ撮影仕様書(カメラ制御・撮影フロー・ファイル管理): docs/04_SPEC/SPEC_02_カメラ撮影仕様書.md - アーキテクチャと非機能(コード構成・非機能要件): docs/04_SPEC/SPEC_03_アーキテクチャと非機能.md +- プレビュー向きとクロップ(反転・クロップの座標系・変更時の確認手順): docs/04_SPEC/SPEC_04_プレビュー向きとクロップ.md ### 05_TECH(技術設計・外部要求仕様) diff --git a/assets/config/preview_region.json b/assets/config/preview_region.json new file mode 100644 index 0000000..f6521e5 --- /dev/null +++ b/assets/config/preview_region.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "preview_region": { + "left": 0.244, + "top": 0.2718, + "right": 0.747, + "bottom": 0.7889 + }, + "note": "撮影画面プレビュー領域に対する正規化座標(0-1)。CameraPreviewWidget が表示後の画面座標でクロップする(WYSIWYG)。アプリ内キャリブレーションで実機調整した確定値。" +} \ No newline at end of file diff --git "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" index a649254..b7bfc1b 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" @@ -49,12 +49,14 @@ ### プレビュー表示 +プレビューの**向き変換(反転)とクロップ拡大**の詳細・座標系・変更時の確認手順は +[SPEC_04 プレビュー向きとクロップ](SPEC_04_プレビュー向きとクロップ.md) に分離した(本書より優先). + +概要のみ: + - `camera` パッケージの `CameraPreview` ウィジェットを使用する -- UI 全体を `Transform.rotate(angle: pi)` で 180° 回転する -- カメラ映像に `Matrix4.diagonal3Values(-scale, -scale, 1.0)` を適用する - - X 軸反転: 鏡像表示(操作者が自然に見える向き) - - Y 軸反転: 端末が逆さのため上下を補正 -- プレビューは画面いっぱいに拡大し,はみ出す部分はクロップする(`ClipRect` + スケール計算) +- 撮影者が見る向きへ向き変換する(`OrientedPreview`,X 反転=鏡像 + Y 反転=上下補正) +- 向き変換後,`PreviewRegion`(表示後座標 0.0–1.0)で口元を切り出して拡大表示する(`RegionCrop`) ### 撮影フロー(定量モード) 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" new file mode 100644 index 0000000..52d0a66 --- /dev/null +++ "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" @@ -0,0 +1,151 @@ +# プレビュー向きとクロップ仕様書 (Preview Orientation & Crop Specification) + +本ドキュメントでは,SmTIAS-Capture のプレビュー表示における **向き変換(反転)** と +**クロップ拡大** の仕組み,座標系の定義,および変更時の確認手順を定義する. + +- 画面・UI は [SPEC_01 画面機能仕様書](SPEC_01_画面機能仕様書.md) を参照 +- カメラ制御・撮影フロー・保存ファイルは [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 +- 本書はプレビュー表示の向き・クロップについて SPEC_02「プレビュー表示」の記述を**詳細化・優先**する + +> **重要:撮影画像(DNG)には影響しない.** 本書が扱う向き変換・クロップは +> すべて**プレビュー表示のみ**の処理である.保存される DNG は別経路(Camera2)で取得する. + +--- + +## 1. 背景:なぜ向き変換が必要か + +スマホを**画面が下向き**になるようにアタッチメントへ載せて口元を撮影する運用のため, +カメラの生プレビューをそのまま表示すると,撮影者から見て上下・左右が反転している. +そこで「撮影者が自然に見える向き」に整える変換を表示時に適用する. + +正しい表示状態(撮影者が見る向き)の特徴: + +- 口元が中央に拡大表示される +- 上部のアタッチメントラベル「SmTIAS 001」が**鏡像+上下反転**で写る(=向き変換が効いている証拠) +- カラーチャートが画面右,位置合わせマーカーが四隅 + +> **基準スクリーンショットについて:** 実機の基準画像は被写体(顔・口元)の +> 個人情報を含むため Git にはコミットせず,`.gitignore` 除外の `local_reference/` +> にローカル保管する.確認時はその画像と見比べる(§5). + +--- + +## 2. 座標系の定義(用語の統一) + +本プロジェクトでは以下の2つの座標系を区別する.コード内コメントもこの用語に統一する. + +| 用語 | 意味 | +| --- | --- | +| **生フレーム座標** | カメラから来たままの向き(向き変換適用前).`OrientedPreview` の入力. | +| **表示後座標** | 向き変換を適用し,撮影者が見る向きにした後の座標(正規化 0.0–1.0).`PreviewRegion` / `RegionCrop` が扱う座標系. | + +**`PreviewRegion` のクロップ座標は必ず「表示後座標」で定義する.** +(向き変換の後に切り出すため.生フレーム座標ではない.) + +--- + +## 3. パイプライン全体図 + +プレビューには **ライブ系(描画)** と **スナップショット系(保存中表示)** の2系統があり, +**いずれも最終的に同じ `RegionCrop`(同じ `region`)でクロップ**して表示を一致させる. + +``` + ┌─ ライブプレビュー系 (Flutter 描画) ───────────┐ + │ AspectRatio(h/w で横→縦補正) │ +生カメラフレーム │ OrientedPreview(向き変換 = 180°回転) │ +(生フレーム座標) ────┤ oriented_preview.dart │── RegionCrop(region) + │ ※ camera_preview.dart │ 表示後座標で + └──────────────────────────────────────────────┘ クロップ&拡大 + ┌─ スナップショット系 (native 変換) ────────────┐ ▲ +最新フレーム │ convertYuvToJpeg(rotation:90, mirror:true) │ │ 同じ region・ +(_latestFrame) ────┤ camera_provider.dart │── CroppedSnapshot + │ = 向き処理済みの JPEG(表示後座標) │ → RegionCrop(region) + └──────────────────────────────────────────────┘──────┘ +``` + +### 向き変換が2系統に分かれている点(最重要) + +**同じ「撮影者が見る向き」を作るのに,入力が異なる2系統で別々の手段を使っている.** +ただし両系統の値は **`lib/models/display_orientation.dart` の `DisplayOrientation` に +一元化**されており,変更窓口は1箇所である. + +| 系統 | 向き処理 | 派生元(単一定義) | 適用箇所 | +| --- | --- | --- | --- | +| ライブ(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`) | + +> **向きを変更したいときは,`DisplayOrientation.attachmentDownward` の値だけを変更する.** +> ライブ系とスナップショット系は適用対象(入力空間)が異なるため,1つの定義から +> 派生していても**幾何的な整合は実機でしか保証できない**. +> 変更後は必ず §5 の確認手順(段階1 自動 + 段階2 実機)を実施すること. + +--- + +## 4. クロップ拡大の仕組み(RegionCrop) + +`lib/widgets/region_crop.dart` が表示後座標 0.0–1.0 の矩形 `PreviewRegion` を受け取り, +以下を行う(ライブ・スナップショット共通). + +1. ROI(関心領域)の中心を画面中心へ平行移動 +2. ROI 全体が画面に収まる `contain` ズーム(縦横同一スケールで歪み防止) +3. `ClipRect` ではみ出しを切り取る + +クロップ領域は `assets/config/preview_region.json` から読み込み, +読み込み失敗・不正値の場合は `PreviewRegion.full`(全画面・クロップなし)にフォールバックする +(`lib/services/preview_region_service.dart`). +領域の調整には `scripts/preview_region_picker.html` を用いる. + +--- + +## 5. 変更時の確認手順(必須) + +向き変換・クロップに関わる変更を行ったら,**2段階**で確認する. + +### 段階1:自動チェック(リファクタ前後で見た目が変わっていないか) + +``` +flutter test test/widgets/preview_orientation_golden_test.dart +``` + +- 合成テストパターン(上=赤/下=青/左=緑/右=黄+左上マーカー円)を + `OrientedPreview`・`RegionCrop` に流し,正解ゴールデン画像と照合する +- 反転や座標を取り違えると色帯・円の位置がズレて**即失敗**する +- これは「変換を壊していないか」の高速チェックであり,実機は不要 + +> **意図的に向きを変更した場合**は,ゴールデンが古い正解のままなので失敗する. +> その場合のみ `flutter test --update-goldens` で正解を更新し, +> 更新後のゴールデン画像が意図通りかを目視確認してからコミットする. + +### 段階2:実機での最終確認(向きが本当に正しいか) + +- 実機で口元を撮影し,**画面全体のスクリーンショット**を撮る +- ローカル保管の基準画像(`local_reference/` 配下,個人情報のため非コミット) + と並べて目視で一致を確認する +- 確認観点: + - 口元が中央に拡大表示されているか + - 上部ラベル「SmTIAS 001」が鏡像+上下反転で写るか + - カラーチャートが右,マーカーが四隅にあるか + - 上下・左右が実際の口の向きと一致するか + +> 自動チェック(段階1)は「変わっていないこと」を守り, +> 実機確認(段階2)は「そもそも正しいこと」を守る.役割が異なるため両方行う. + +--- + +## 6. 関連ファイル + +| ファイル | 役割 | +| --- | --- | +| `lib/models/preview_region.dart` | クロップ領域(表示後座標)の不変モデル | +| `lib/services/preview_region_service.dart` | `preview_region.json` の読み込み・フォールバック | +| `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/models/preview_region.dart b/lib/models/preview_region.dart new file mode 100644 index 0000000..9e4346d --- /dev/null +++ b/lib/models/preview_region.dart @@ -0,0 +1,70 @@ +/// プレビュー表示領域を表す正規化矩形(不変クラス). +/// +/// 撮影画像(フル領域)に対する正規化座標(0.0–1.0)で, +/// CameraPreview をクロップ表示する領域を表す. +/// 撮影画像の出力には影響しない(プレビュー表示のみ). +class PreviewRegion { + /// 左端(0.0–1.0). + final double left; + + /// 上端(0.0–1.0). + final double top; + + /// 右端(0.0–1.0). + final double right; + + /// 下端(0.0–1.0). + final double bottom; + + const PreviewRegion({ + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + /// クロップなし(フル領域)を表す矩形. + /// + /// config 読み込み失敗時のフォールバックとして使用する. + static const PreviewRegion full = PreviewRegion( + left: 0.0, + top: 0.0, + right: 1.0, + bottom: 1.0, + ); + + /// クロップ領域の幅(正規化). + double get width => right - left; + + /// クロップ領域の高さ(正規化). + double get height => bottom - top; + + /// フル領域(クロップ不要)かどうか. + bool get isFull => left <= 0.0 && top <= 0.0 && right >= 1.0 && bottom >= 1.0; + + /// 各値が有効範囲内(0.0–1.0,left + left >= 0.0 && + left < right && + right <= 1.0 && + top >= 0.0 && + top < bottom && + bottom <= 1.0; + + /// JSON マップから [PreviewRegion] を生成する. + /// + /// 値が数値でない/欠落している場合はフォールバック値(フル領域)を用いる. + factory PreviewRegion.fromJson(Map json) { + double parse(Object? value, double fallback) { + if (value is num) return value.toDouble(); + return fallback; + } + + return PreviewRegion( + left: parse(json['left'], full.left), + top: parse(json['top'], full.top), + right: parse(json['right'], full.right), + bottom: parse(json['bottom'], full.bottom), + ); + } +} diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index f90f639..8dffa5c 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -1,9 +1,12 @@ 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'; import 'package:smtias_capture/services/permission_service.dart'; +import 'package:smtias_capture/services/preview_region_service.dart'; import 'package:smtias_capture/services/raw_capture_service.dart'; import 'package:smtias_capture/services/sound_service.dart'; @@ -13,9 +16,11 @@ final FileService _fileService = FileService(); final RawCaptureService _rawCaptureService = RawCaptureService(); final SoundService _soundService = SoundService(); + final PreviewRegionService _previewRegionService = PreviewRegionService(); CameraController? _controller; bool _isInitialized = false; + bool _initializing = false; String? _errorMessage; bool _permissionDenied = false; bool _permissionPermanentlyDenied = false; @@ -23,6 +28,7 @@ bool _isSaving = false; bool _imageStreamActive = false; PreviewFrame? _latestFrame; + PreviewRegion _previewRegion = PreviewRegion.full; CameraController? get controller => _controller; bool get isInitialized => _isInitialized; @@ -33,6 +39,9 @@ bool get isSaving => _isSaving; bool get isSoundEnabled => _soundService.isSoundEnabled; + /// プレビュー表示のクロップ領域(撮影画像には影響しない). + PreviewRegion get previewRegion => _previewRegion; + /// サウンド設定を切り替える. Future toggleSound() async { await _soundService.toggleSound(); @@ -45,8 +54,27 @@ } /// カメラを初期化する. + /// + /// 初期化処理が進行中の場合は即座に return して二重実行を防ぐ. + /// すでに初期化済み([isInitialized] が true かつ [controller] が非 null)の + /// 場合も何もしない. Future initialize() async { + // 二重実行防止: 初期化処理が進行中なら早期 return + if (_initializing) return; + // 既に初期化済みなら再初期化不要 + if (_isInitialized && _controller != null) return; + + _initializing = true; + try { + await _initializeInternal(); + } finally { + _initializing = false; + } + } + + Future _initializeInternal() async { await _soundService.init(); + _previewRegion = await _previewRegionService.load(); final granted = await _permissionService.requestCamera(); if (!granted) { @@ -127,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, @@ -136,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/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 8957ad0..c31cd10 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -6,6 +6,7 @@ import 'package:smtias_capture/providers/camera_provider.dart'; import 'package:smtias_capture/widgets/camera_preview.dart'; +import 'package:smtias_capture/widgets/cropped_snapshot.dart'; import 'package:smtias_capture/widgets/shutter_button.dart'; /// 撮影画面.カメラプレビューとシャッターボタンを表示する. @@ -162,19 +163,26 @@ return const Center(child: CircularProgressIndicator()); } + // ─── 通常撮影モード ─────────────────────────────────────────────────────── return Stack( children: [ - // 保存中はスナップショットを表示 + // 保存中はスナップショットを表示(ライブと同じ領域でクロップ) if (_snapshotBytes != null && (!cameraProvider.isInitialized || cameraProvider.isSaving)) Positioned.fill( - child: Image.memory(_snapshotBytes!, fit: BoxFit.cover), + child: CroppedSnapshot( + bytes: _snapshotBytes!, + region: cameraProvider.previewRegion, + ), ) // カメラプレビュー else if (cameraProvider.isInitialized && cameraProvider.controller != null) Positioned.fill( - child: CameraPreviewWidget(controller: cameraProvider.controller!), + child: CameraPreviewWidget( + controller: cameraProvider.controller!, + region: cameraProvider.previewRegion, + ), ), // 保存中インジケーター if (cameraProvider.isSaving) _buildSavingIndicator(), diff --git a/lib/services/preview_region_service.dart b/lib/services/preview_region_service.dart new file mode 100644 index 0000000..dfc1aca --- /dev/null +++ b/lib/services/preview_region_service.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; + +import 'package:smtias_capture/models/preview_region.dart'; + +/// `assets/config/preview_region.json` を読み込み, +/// プレビュークロップ領域([PreviewRegion])を提供するサービス. +/// +/// ロード優先順位: +/// 1. assets の `preview_region.json` が valid ならそれを使用 +/// 2. 読み込み失敗・不正値なら [PreviewRegion.full] にフォールバック +class PreviewRegionService { + static const _assetPath = 'assets/config/preview_region.json'; + + /// config を読み込んでプレビュー領域を返す. + /// + /// 優先順位: assets → [PreviewRegion.full]. + /// assets 読み込み失敗・不正値なら [PreviewRegion.full] にフォールバックする. + Future load() async { + // (1) assets ファイル + final assetsRegion = await _loadAssets(); + if (assetsRegion != null) { + return assetsRegion; + } + + // (2) フォールバック + return PreviewRegion.full; + } + + /// assets ファイルから読み込む.読み込み失敗・不正値なら null を返す. + Future _loadAssets() async { + try { + final raw = await rootBundle.loadString(_assetPath); + final decoded = json.decode(raw); + if (decoded is! Map) { + return null; + } + + final regionJson = decoded['preview_region']; + if (regionJson is! Map) { + return null; + } + + final region = PreviewRegion.fromJson(regionJson); + if (!region.isValid) { + debugPrint('PreviewRegionService: assets の領域値が不正のためフル表示にフォールバック'); + return null; + } + return region; + } catch (e) { + debugPrint('PreviewRegionService: assets 読み込み失敗 ($e)'); + return null; + } + } +} diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 0e3b8a1..545858b 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -2,7 +2,9 @@ /// Camera2 API を使用してフル解像度の画像をキャプチャするサービス. class RawCaptureService { - static const _channel = MethodChannel('com.example.smtias_capture/raw_capture'); + static const _channel = MethodChannel( + 'com.example.smtias_capture/raw_capture', + ); /// フロントカメラからフル解像度の PNG バイト列をキャプチャする(ネイティブ変換). Future captureFullResolutionPng() async { diff --git a/lib/widgets/camera_preview.dart b/lib/widgets/camera_preview.dart index c0b04e7..45f4145 100644 --- a/lib/widgets/camera_preview.dart +++ b/lib/widgets/camera_preview.dart @@ -1,50 +1,51 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -/// カメラのライブプレビューを全画面で表示するウィジェット. +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/widgets/oriented_preview.dart'; +import 'package:smtias_capture/widgets/region_crop.dart'; + +/// カメラのライブプレビューを表示するウィジェット. /// -/// フロントカメラの映像を鏡像(左右反転)で表示する. +/// [region] が指定された場合,**表示向きを適用したプレビューの画面座標** +/// (0.0–1.0)で矩形にクロップして拡大表示する(WYSIWYG / [RegionCrop]). +/// 撮影画像の出力には影響しない. class CameraPreviewWidget extends StatelessWidget { - const CameraPreviewWidget({super.key, required this.controller}); + const CameraPreviewWidget({ + super.key, + required this.controller, + this.region = PreviewRegion.full, + }); final CameraController controller; + /// プレビュー表示のクロップ領域(表示後の画面座標 0.0–1.0). + final PreviewRegion region; + @override Widget build(BuildContext context) { if (!controller.value.isInitialized) { return const Center(child: CircularProgressIndicator()); } - return LayoutBuilder( - builder: (context, constraints) { - final previewSize = controller.value.previewSize; - if (previewSize == null) { - return const Center(child: CircularProgressIndicator()); - } + final previewSize = controller.value.previewSize; + if (previewSize == null) { + return const Center(child: CircularProgressIndicator()); + } - // カメラのアスペクト比(プレビューは横長で返るので反転) - final cameraAspect = previewSize.height / previewSize.width; - final screenAspect = constraints.maxWidth / constraints.maxHeight; + // カメラのアスペクト比(プレビューは横長で返るので反転) + final cameraAspect = previewSize.height / previewSize.width; - // 画面いっぱいに表示するためのスケール - final scale = cameraAspect > screenAspect - ? constraints.maxHeight / (constraints.maxWidth / cameraAspect) - : constraints.maxWidth / (constraints.maxHeight * cameraAspect); - - return ClipRect( - child: Transform( - alignment: Alignment.center, - // X 軸を反転(鏡像),Y 軸を反転(上下逆転) - transform: Matrix4.diagonal3Values(-scale, -scale, 1.0), - child: Center( - child: AspectRatio( - aspectRatio: cameraAspect, - child: CameraPreview(controller), - ), - ), - ), - ); - }, + // 表示向きを適用したフルプレビュー(撮影者が見る向き=クロップ元). + final oriented = OrientedPreview( + child: Center( + child: AspectRatio( + aspectRatio: cameraAspect, + child: CameraPreview(controller), + ), + ), ); + + return RegionCrop(region: region, child: oriented); } } diff --git a/lib/widgets/cropped_snapshot.dart b/lib/widgets/cropped_snapshot.dart new file mode 100644 index 0000000..94b91c8 --- /dev/null +++ b/lib/widgets/cropped_snapshot.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/widgets/region_crop.dart'; + +/// 保存中に表示するスナップショット画像 [bytes] を [region] でクロップ表示する. +/// +/// スナップショット(preview.jpg)は native 側で既に向き処理(`rotation:90 + mirror`) +/// 済みのため,ここでは追加の反転は行わず,[RegionCrop] で同一座標系のクロップのみ +/// 適用してライブ表示と範囲を一致させる. +class CroppedSnapshot extends StatelessWidget { + const CroppedSnapshot({super.key, required this.bytes, required this.region}); + + final Uint8List bytes; + + /// クロップ領域(表示後の画面座標 0.0–1.0/ライブと共通). + final PreviewRegion region; + + @override + Widget build(BuildContext context) { + return RegionCrop( + region: region, + child: Image.memory(bytes, fit: BoxFit.contain, gaplessPlayback: true), + ); + } +} diff --git a/lib/widgets/oriented_preview.dart b/lib/widgets/oriented_preview.dart new file mode 100644 index 0000000..a9e24fe --- /dev/null +++ b/lib/widgets/oriented_preview.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:smtias_capture/models/display_orientation.dart'; + +/// [child](向き調整前のコンテンツ)に表示向き変換を適用するウィジェット. +/// +/// 「生フレーム座標」のコンテンツを,撮影者が見る「表示後座標」へ変換する. +/// 変換内容は [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, + this.orientation = DisplayOrientation.attachmentDownward, + }); + + /// 向き調整前のコンテンツ(生フレーム座標). + final Widget child; + + /// 適用する向き設定(単一の真実). + final DisplayOrientation orientation; + + @override + Widget build(BuildContext context) { + return Transform( + alignment: Alignment.center, + transform: orientation.previewMatrix, + child: child, + ); + } +} diff --git a/lib/widgets/region_crop.dart b/lib/widgets/region_crop.dart new file mode 100644 index 0000000..697bfef --- /dev/null +++ b/lib/widgets/region_crop.dart @@ -0,0 +1,55 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; + +/// 表示向き適用済みの全体コンテンツ [child] を [region](表示後の画面座標 0.0–1.0)で +/// 切り出して画面いっぱいに拡大表示する(WYSIWYG). +/// +/// ライブプレビュー([child] = CameraPreview)と保存中スナップショット +/// ([child] = 画像)で共通利用し,同じ領域・同じ拡大で表示を一致させる. +class RegionCrop extends StatelessWidget { + const RegionCrop({super.key, required this.region, required this.child}); + + /// クロップ領域(表示後の画面座標 0.0–1.0). + final PreviewRegion region; + + /// クロップ対象(表示向き適用済みで領域全体を埋めるコンテンツ). + final Widget child; + + @override + Widget build(BuildContext context) { + if (region.isFull) { + return child; + } + + return LayoutBuilder( + builder: (context, constraints) { + final double w = constraints.maxWidth; + final double h = constraints.maxHeight; + + final double roiCx = (region.left + region.right) / 2.0; + final double roiCy = (region.top + region.bottom) / 2.0; + + // ROI 中心を画面中心へ寄せる平行移動量(画面ピクセル) + final double dx = (0.5 - roiCx) * w; + final double dy = (0.5 - roiCy) * h; + + // ROI 全体を画面に収める contain ズーム(縦横同一スケールで歪み防止) + final double zoom = math.min(1.0 / region.width, 1.0 / region.height); + + return ClipRect( + child: Transform.scale( + scale: zoom, + alignment: Alignment.center, + child: Transform.translate( + offset: Offset(dx, dy), + child: SizedBox(width: w, height: h, child: child), + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e96d400..be7f607 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,9 @@ # the material Icons class. uses-material-design: true + assets: + - assets/config/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/scripts/preview_region_picker.html b/scripts/preview_region_picker.html new file mode 100644 index 0000000..af1968e --- /dev/null +++ b/scripts/preview_region_picker.html @@ -0,0 +1,461 @@ + + + + +Preview Region Picker — SmTIAS-Capture + + + +
+

Preview Region Picker

+ 参照画像でカメラプレビュー表示領域を矩形指定 → 正規化座標を JSON で保存 +
+ +
+ +
右パネルから画像を読み込んでください
+
+ + + + + + 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, + ); + }); + }); +} diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart index db08dca..338abcc 100644 --- a/test/providers/gallery_provider_test.dart +++ b/test/providers/gallery_provider_test.dart @@ -224,7 +224,9 @@ late _GalleryProviderTestable provider; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('smtias_capture_del_test_'); + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_del_test_', + ); provider = _GalleryProviderTestable(tempDir.path); }); diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 842e219..bfef402 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -335,7 +335,9 @@ late _FileServiceTestableQuantitative service; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('smtias_capture_qm_test_'); + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_qm_test_', + ); service = _FileServiceTestableQuantitative(tempDir.path); }); diff --git a/test/widgets/goldens/oriented_preview.png b/test/widgets/goldens/oriented_preview.png new file mode 100644 index 0000000..ff58684 --- /dev/null +++ b/test/widgets/goldens/oriented_preview.png Binary files differ diff --git a/test/widgets/goldens/preview_pipeline.png b/test/widgets/goldens/preview_pipeline.png new file mode 100644 index 0000000..57d726f --- /dev/null +++ b/test/widgets/goldens/preview_pipeline.png Binary files differ diff --git a/test/widgets/goldens/region_crop.png b/test/widgets/goldens/region_crop.png new file mode 100644 index 0000000..8c9a21f --- /dev/null +++ b/test/widgets/goldens/region_crop.png Binary files differ diff --git a/test/widgets/preview_orientation_golden_test.dart b/test/widgets/preview_orientation_golden_test.dart new file mode 100644 index 0000000..012f134 --- /dev/null +++ b/test/widgets/preview_orientation_golden_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/widgets/oriented_preview.dart'; +import 'package:smtias_capture/widgets/region_crop.dart'; + +/// プレビューの「向き変換」「クロップ」の見た目をゴールデン画像で固定する回帰テスト. +/// +/// リファクタリング前に正解画像を生成・コミットし,リファクタリング後に +/// `flutter test` で再照合する(1px でも変われば失敗).これは「変換を壊して +/// いないか」の高速な自動チェックであり,「向きが本当に正しいか」の最終確認は +/// 実機の全体スクリーンショットと基準画像(local_reference/ 配下,個人情報のため +/// 非コミット)の目視比較で行う.詳細は SPEC_04 参照. +void main() { + // ゴールデンの基準サイズ(縦長プレビューを模す). + const size = Size(240, 360); + + // 中央寄りのクロップ領域(表示後座標 0.0–1.0). + const region = PreviewRegion(left: 0.25, top: 0.2, right: 0.75, bottom: 0.8); + + testWidgets('OrientedPreview: 向き変換が正解画像と一致する', (tester) async { + await _pump(tester, size, const OrientedPreview(child: _TestPattern())); + await expectLater( + find.byKey(_targetKey), + matchesGoldenFile('goldens/oriented_preview.png'), + ); + }); + + testWidgets('RegionCrop: クロップ拡大が正解画像と一致する', (tester) async { + await _pump( + tester, + size, + const RegionCrop(region: region, child: _TestPattern()), + ); + await expectLater( + find.byKey(_targetKey), + matchesGoldenFile('goldens/region_crop.png'), + ); + }); + + testWidgets('向き変換 + クロップ: パイプライン全体が正解画像と一致する', (tester) async { + await _pump( + tester, + size, + const RegionCrop( + region: region, + child: OrientedPreview(child: _TestPattern()), + ), + ); + await expectLater( + find.byKey(_targetKey), + matchesGoldenFile('goldens/preview_pipeline.png'), + ); + }); +} + +const _targetKey = ValueKey('golden-target'); + +/// [child] を固定サイズの [RepaintBoundary] で包んでレンダリングする. +Future _pump(WidgetTester tester, Size size, Widget child) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: _targetKey, + child: SizedBox.fromSize(size: size, child: child), + ), + ), + ); +} + +/// 上下左右が一目で判別できる非対称テストパターン(向き・クロップ確認用の入力). +/// +/// 上=赤 / 下=青 / 左=緑 / 右=黄 の色帯と,左上寄りのマゼンタ円を描く. +/// 180°回転すれば色帯の上下・左右が入れ替わり,円が右下へ移動するため, +/// 反転や座標の取り違えがゴールデン差分として確実に現れる. +class _TestPattern extends StatelessWidget { + const _TestPattern(); + + @override + Widget build(BuildContext context) { + return CustomPaint(painter: _TestPatternPainter(), size: Size.infinite); + } +} + +class _TestPatternPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final t = (w < h ? w : h) * 0.15; + + void fill(Rect rect, Color color) { + canvas.drawRect(rect, Paint()..color = color); + } + + fill(Offset.zero & size, const Color(0xFFFFFFFF)); // 背景:白 + fill(Rect.fromLTWH(0, 0, w, t), const Color(0xFFE53935)); // 上:赤 + fill(Rect.fromLTWH(0, h - t, w, t), const Color(0xFF1E88E5)); // 下:青 + fill(Rect.fromLTWH(0, 0, t, h), const Color(0xFF43A047)); // 左:緑 + fill(Rect.fromLTWH(w - t, 0, t, h), const Color(0xFFFDD835)); // 右:黄 + + // 左上寄りのマーカー円(両軸反転とクロップ中心を判別する). + canvas.drawCircle( + Offset(w * 0.3, h * 0.3), + t * 0.6, + Paint()..color = const Color(0xFFD500F9), + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +}