jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

327 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Reviewer 審查 M8-1 + M8-2 合併結果2026-04-15
## 摘要
- **總結論**:✅ **通過**(零 Major / 零 Critical只有若干 Minor 提醒)
- **M8-1砍 yt-dlp**:✅ 完整砍除8 項 deletions.md 條目全達成
- **M8-2砍 Mock**:✅ 完整砍除11 項 deletions.md 條目全達成
- **是否阻擋 M8-4**:❌ 不阻擋。M8-4 可以開動mockMode field 已清、videoIsURL 已清、StartFromURL 已清M8-4 做 server lifecycle 時無衝突)
- **額外觀察**working tree 同時包含 **M8-3ffmpeg LGPL 切換)** 的改動Makefile / README / installer / scripts / build.yml該部分依使用者指示**不在本次 review 範圍**
---
## A. 砍除完整性
### A1. M8-1 yt-dlp 砍除清單
| # | 項目 | 預期 | 實測 | 證據 |
|---|------|------|------|------|
| 1 | `ResolveWithYTDLP` function | 消失 | ✅ | `video_source.go` diff -91..-140 整段 delete |
| 2 | `friendlyYTDLPError` function | 消失 | ✅ | 同上 |
| 3 | `NewVideoSourceFromURL` / `...WithSeek` | 消失 | ✅ | `video_source.go` diff -79..-86 |
| 4 | `VideoSource.isURL` field + `newVideoSource``isURL` 參數 | 消失 | ✅ | `video_source.go` L63/L68 簽名已簡化 |
| 5 | `ytdlpHosts` map | 消失 | ✅ | `camera_handler.go` -341..-373 |
| 6 | `urlKind` enum / `urlYTDLP` / `urlDirect` / `urlBad` | 消失 | ✅ | 同上 |
| 7 | `classifyVideoURL()` function | 消失 | ✅ | 同上 |
| 8 | `StartFromURL` handler | 消失 | ✅ | -408..-497 整段 delete |
| 9 | `CameraHandler.videoIsURL` field | 消失 | ✅ | `camera_handler.go` L33/L303/L536 三處清除 |
| 10 | `handleVideoSeek``videoIsURL` 分支 | 消失 | ✅ | L566 簡化為直接呼叫 `NewVideoSourceWithSeek` |
| 11 | `/media/url` route | 消失 | ✅ | `router.go:83` delete |
| 12 | `deps/checker.go` yt-dlp check | 消失 | ✅ | `checker.go` -30..-32 |
| 13 | `main.go` 註解改 ffmpeg/ffprobe | ✅ | L88 | 註解已改 |
| 14 | frontend `startFromUrl` function | 消失 | ✅ | `camera-store.ts` -167..-196 |
| 15 | frontend `videoMode` / `videoUrl` state | 消失 | ✅ | `source-selector.tsx` -51..-52 |
| 16 | frontend `handleUrlSubmit` function | 消失 | ✅ | `source-selector.tsx` -94..-96 |
| 17 | frontend URL tab UImode toggle + input + help | 消失 | ✅ | `source-selector.tsx` -183..-253 |
| 18 | i18n `pasteUrl` / `urlPlaceholder` / `urlHelpText` / `cannotOpenVideoUrl` | 消失 | ✅ | `types.ts` / `zh-TW.ts` / `en.ts` 三檔同步 |
| 19 | i18n `camera.uploadFile`mode 切換用) | 消失 | ✅ | M8-1 自補:三檔同步刪(本來 deletions.md 沒列,但砍完 mode toggle 就 orphan |
| 20 | Makefile `vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` | 消失 | ✅ | `Makefile` 全檔無 `yt-dlp` / `ytdlp` / `YTDLP` 字樣 |
| 21 | installer/windows visiona-local.iss yt-dlp.exe 行 | 消失 | ✅ | L76 已 delete |
| 22 | installer/linux build-appimage.sh yt-dlp loop | 消失 | ✅ | L61 `for tool in ffmpeg ffprobe` |
| 23 | scripts/bootstrap-linux.sh `vendor-ytdlp-linux` | 消失 | ✅ | L57 |
| 24 | scripts/bootstrap-windows.ps1 `vendor-ytdlp-windows` | 消失 | ✅ | L200 |
| 25 | visiona-local/app.go `locateBundleBinDir``yt-dlp` 字串 | 改為 `ffprobe` | ✅ | `app.go:871` fileExists("ffprobe") |
| 26 | visiona-local/app.go `startServer` 註解「ffmpeg / yt-dlp」 | 改為 `ffmpeg / ffprobe` | ✅ | L480 |
| 27 | README.md yt-dlp 三處敘述 | 消失 | ✅ | L18, L74, L128, L131, L179 五處清除M8-1 自補) |
| 28 | .github/workflows/build.yml `vendor-ytdlp-*` | 消失 | ✅ | L135/L233 |
**A1 結論**M8-1 砍除 **100% 達成**,且主動自補了 4 個 deletions.md 未明列的位置README 五處、workflow、app.go bundle dir、i18n uploadFile orphan key
### A2. M8-2 Mock 砍除清單
| # | 項目 | 預期 | 實測 | 證據 |
|---|------|------|------|------|
| 1 | `server/internal/driver/mock/mock_driver.go` | 整檔刪 | ✅ | `git status: deleted` |
| 2 | `server/internal/driver/mock/` 目錄 | 刪 | ✅ | `find server/internal/driver/mock` 無 |
| 3 | `server/internal/camera/mock_camera.go` | 整檔刪 | ✅ | `git status: deleted` |
| 4 | `VISIONA_MOCK` env 偵測 | 消失 | ✅ | `grep -r VISIONA_MOCK` 0 match |
| 5 | `config.MockMode` / `MockCamera` / `MockDeviceCount` 欄位 | 消失 | ✅ | `config.go` L19..L26 簡化 |
| 6 | `--mock` / `--mock-camera` / `--mock-devices` flag | 消失 | ✅ | `config.go` L36..L38 已刪 |
| 7 | `device.Manager.mockMode` field | 消失 | ✅ | `device/manager.go` L17 |
| 8 | `NewManager(registry, mockMode, mockCount, scriptPath)` 簽名簡化 | `NewManager(registry, scriptPath)` | ✅ | `device/manager.go` L22 |
| 9 | `device/manager.go` 所有 `if m.mockMode {}` 分支 | 消失 | ✅ | `Start()` / `Rescan()` 已簡化 |
| 10 | `mockdriver` import | 消失 | ✅ | `device/manager.go` import 區已清 |
| 11 | `camera.Manager.mockMode` / `mockCamera` field | 消失 | ✅ | `camera/manager.go` L16..L19 |
| 12 | `camera.NewManager(mockMode)``NewManager()` | 簡化 | ✅ | `camera/manager.go` L23 |
| 13 | `camera.Manager.ListCameras/Open/Close/ReadFrame` mock 分支 | 消失 | ✅ | `camera/manager.go` L29-82 |
| 14 | `main.go` `Mock mode:` log | 消失 | ✅ | `main.go:86` 已改為 `Dev mode: %v, Python mode: %s` |
| 15 | `main.go` `NewManager(registry, cfg.MockMode, ...)` 呼叫 | 簡化 | ✅ | `main.go:141` |
| 16 | `main.go` `camera.NewManager(cfg.MockCamera)` 呼叫 | 簡化 | ✅ | `main.go:146` |
| 17 | `visiona-local/app.go` `mockMode` field | 消失 | ✅ | `app.go:82` 已清除 |
| 18 | `visiona-local/app.go` `NewApp()``VISIONA_MOCK` env | 消失 | ✅ | `app.go:117` 替換為 R5-5a 註解 |
| 19 | `visiona-local/app.go` `startServer` 所有 `a.mockMode` 分支 | 消失 | ✅ | L425 / L435 / L455-465 / L488 皆清除 |
| 20 | frontend `settings/page.tsx` runtimeMode Select + Separator | 消失 | ✅ | `page.tsx` -140..-158 |
| 21 | frontend i18n `runtimeMode` / `...Mock` / `...Real` / `...Hint` | 消失 | ✅ | `types.ts` / `zh-TW.ts` / `en.ts` L270..L277 同步 |
| 22 | frontend i18n `noDevices` 文字去 Mock | 改為 R5-v2-7 empty state | ✅ | 兩語言都改為「請連接 KL520 / KL720 後按掃描」 |
| 23 | Makefile `dev-mock` target | 消失 | ✅ | `Makefile``dev-mock` |
| 24 | README Mock 段落 | 消失 | ✅ | L73 「Mock 模式」bullet 整行刪、L24 零學習成本 bullet 改為「插上 Kneron 裝置 30 秒」 |
| 25 | `manager_test.go` `TestNewManager_MockMode` | 消失 | ✅ | 已刪 |
| 26 | `manager_test.go` `TestManager_ListDevices_Empty` 新增 | 新增 | ✅ | L39..L48R5-5a 紀念註解合法 |
| 27 | `api_e2e_test.go` 整檔刪 | 刪 | ✅ | `git status: deleted` |
| 28 | `camera-controls.tsx` `'mock-cam-0'` fallback | 改為 `disabled={cameras.length === 0}` | ✅ | L22..L28 已改 |
**A2 結論**M8-2 砍除 **100% 達成**,且主動自補了 `api_e2e_test.go` 整檔刪、`manager_test.go` 新增空 list 測試、`camera-controls.tsx` fallback 修正。
---
## B. 誤刪檢查(保留功能 red line
| 保留項目 | 狀態 | 證據 |
|---------|------|------|
| `POST /api/camera/start` | ✅ 保留 | router gin-debug 有 `/api/camera/start` |
| `POST /api/camera/stop` | ✅ 保留 | 同上 |
| `POST /api/media/upload/image` | ✅ 保留 | 同上 |
| `POST /api/media/upload/video`(本機檔案,非 URL | ✅ 保留 | 同上 |
| `POST /api/media/upload/batch-images` | ✅ 保留 | 同上 |
| `GET /api/media/batch-images/:index` | ✅ 保留 | 同上 |
| `POST /api/media/seek`(本機影片 seek | ✅ 保留 | 同上 |
| `GET /api/camera/stream` | ✅ 保留 | 同上 |
| `GET /api/devices`(無硬體回空 list非 mock | ✅ 保留 | smoke test`{"devices":[]}` |
| 模型管理 `/api/models*` | ✅ 保留 | router gin-debug 完整 |
| WebSocket `/ws/devices/:id/inference` | ✅ 保留 | router gin-debug |
| `VideoSource` + `NewVideoSource` + `NewVideoSourceWithSeek` | ✅ 保留 | `video_source.go` L70/L75 |
| `ProbeVideoInfo` | ✅ 保留 | M8-1 未觸碰 |
| `handleVideoSeek` 本地影片分支 | ✅ 保留 | `camera_handler.go:566` |
| `stopActivePipeline` | ✅ 保留 | L532 |
| `ffmpeg` 本身 | ✅ 保留 | deps checker + PATH 注入都還在 |
**B 結論****零誤刪**。所有 R5 要求保留的功能完整存在。
---
## C. 殘留 grep 結果
命令(排除 `.autoflow/` / `.git/` / `vendor/` / `node_modules/`
### C1. yt-dlp 組
```
grep -rn 'yt-dlp\|ytdlp\|YTDLP\|ResolveWithYTDLP\|StartFromURL\|ytdlpHosts\|urlYTDLP\|videoIsURL\|startFromUrl\|pasteUrl\|urlPlaceholder\|urlHelpText'
```
**結果****0 match** ✅
### C2. Mock 組(嚴格:排除合法測試框架用詞)
```
grep -rn 'VISIONA_MOCK\|MockMode\|MockCamera\|NewMockDriver\|NewMockCamera\|mockdriver\|mock_camera\|runtimeModeMock\|runtimeModeReal\|mock-cam-0'
```
**結果****0 match** ✅
### C3. 寬鬆 Mock 組(含一般 `mock` 字樣)
`server/internal/` 下只剩:
- `server/internal/device/manager_test.go:43``// 無硬體時應回傳空 listR5-5a沒插硬體就空白不給 Mock 資料)`**合法紀念註解**
`frontend/src/tests/` 下的 `vi.mock*` / `mockResolvedValue` / `clearAllMocks`**合法 Vitest API**
`visiona-local/` / `server/main.go` / `server/internal/camera/` / `server/internal/driver/` 下 — **0 match**
**C 結論**grep 完全 clean。
---
## D. Build / test 結果
### D1. `cd server && go build ./...`
**PASS**(無輸出)
### D2. `cd server && go vet ./...`
**PASS**(無輸出)
### D3. `cd server && go test ./...`
**PASS**
```
ok visiona-local/server/internal/device (cached)
ok visiona-local/server/internal/model (cached)
```
其餘 package 顯示 `[no test files]`,符合預期(本次沒有新增 unit test
### D4. `cd visiona-local && go build .`
**PASS**(無輸出)
### D5. `cd frontend && pnpm build`
**PASS**`Compiled successfully in 4.2s`TypeScript 通過12 頁 SSG 生成完成。
**D 結論**:五項 build / test 全綠。
---
## E. Smoke test 結果
啟動 `go run . --port 3722`,等 3 秒:
| 測試 | 結果 | 備註 |
|------|------|------|
| Server 啟動成功 | ✅ | 看到 `Server listening on 127.0.0.1:3722` |
| log 無 `Mock mode:` | ✅ | log 只剩 `Dev mode: false, Python mode: auto` |
| log 無 `yt-dlp:` | ✅ | deps check 只有 `ffmpeg``python3` |
| `GET /api/devices` | ✅ 200 | `{"data":{"devices":[]},"success":true}` — 空 list非 mock 資料 |
| `POST /api/media/url` | ✅ 404 | Route 已砍gin 回 404符合預期 |
| router gin-debug 印出 | ✅ | 完全沒有 `StartFromURL`,只有 `UploadVideo / UploadImage / UploadBatchImages / SeekVideo / ListCameras / StartPipeline / StopPipeline / StreamMJPEG` |
| Kneron 偵測 | ✅ | `No Kneron devices detected` — 走真實硬體 path機器上沒插就空 list |
**E 結論**Runtime 行為完全符合 R5-5a / R5-7 預期。
---
## F. Edge case 自補合理性
### F1. M8-1 自補
| 自補項 | 合理性 |
|--------|-------|
| README 清 yt-dlp 五處 + 改「零學習成本」文字 | ✅ 合理deletions.md 未列但必然要改 |
| `.github/workflows/build.yml``vendor-ytdlp-*` 兩處 | ✅ 合理,否則 CI build 會叫不存在的 target |
| `visiona-local/app.go:locateBundleBinDir``fileExists("yt-dlp")``fileExists("ffprobe")` | ✅ 合理。ffprobe 會隨 M8-3 進 bundle bin dir用 ffprobe 當 sentinel 比 ffmpeg 更精準(避免和 system ffmpeg 混淆) |
| 刪 `camera.uploadFile` i18n key | ✅ 合理URL tab 砍後這個 key 變 dead key |
### F2. M8-2 自補
| 自補項 | 合理性 |
|--------|-------|
| `api_e2e_test.go` 整檔刪 | ✅ 合理。該 test 依賴 mock driver + 舊 `NewRouter` 簽名修起來成本高於價值。deletions.md §2.7 本來就留給執行者決定 |
| `camera-controls.tsx``'mock-cam-0'` fallback 改 `disabled={cameras.length === 0}` | ✅ 合理。之前是「沒 camera 還是硬塞 mock-cam-0」現在改為 button disabled + guard `if (firstCam)`,邏輯更嚴謹 |
| `manager_test.go` 新增 `TestManager_ListDevices_Empty` | ✅ 合理。補足了刪除 `TestNewManager_MockMode` 後的測試缺口,明確驗證 R5-5a 的空 list 行為 |
| `visiona-local/app.go` 的註解改寫「M7預設真實硬體模式」→「R5-5a沒插硬體就顯示空白狀態」「M1 預設 mock 模式...」→「R5-5a 之後python 失敗直接擋啟動」) | ✅ 合理,註解與新行為一致 |
**F 結論**:兩個 Agent 的自補都是必要且最小化的,沒有過度也沒有不足。
---
## G. 兩個 milestone 交集檔案的正確性
### G1. `visiona-local/app.go`
- M8-1 改:`locateBundleBinDir``fileExists("yt-dlp")``fileExists("ffprobe")``startServer` 註解「ffmpeg / yt-dlp」→「ffmpeg / ffprobe」
- M8-2 改:`App.mockMode` field 砍、`NewApp()``VISIONA_MOCK` 砍、`startServer` 所有 `a.mockMode` 分支砍、註解改寫
兩者互不干擾merge 正確。`args` 陣列現在無條件帶 `--python-mode` 和(若 `pyBin != ""``--python`,符合 M8-2 deletions.md §3.1 的改法。`ensurePythonRuntime` 錯誤不再被 mockMode 遮蔽,直接 return error符合 R5-5a 精神。
**正確**
### G2. `server/main.go`
- M8-1 改L88 PATH 注入註解「yt-dlp / ffmpeg」→「ffmpeg / ffprobe」
- M8-2 改L86 `logger.Info("Mock mode: ...")``Dev mode: ..., Python mode: ...`L141 `device.NewManager` 簽名簡化L146 `camera.NewManager` 簽名簡化
兩者無衝突merge 正確。
**正確**
### G3. `Makefile`
- M8-1 改:`vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` 三個 target 整段刪、`.PHONY` 移除、`vendor-sync` / `payload-*` 依賴移除、help 文字更新、`YTDLP_URL_*` 變數刪、`payload-*``cp .../yt-dlp ...` 行刪
- M8-2 改:`.PHONY``dev-mock` 移除、底部 `dev-mock:` target 整段刪
- **M8-3不審**`vendor-ffmpeg` 系列從 GPL 改為 LGPL v3 + 新增 `vendor-ffmpeg-macos-build` target + ffmpeg URL 從 evermeet / johnvansickle / BtbN-gpl 改為 BtbN-lgpl
M8-1 + M8-2 在 Makefile 內的改動乾淨、無殘留。M8-3 內容雖然混入但不與 M8-1 / M8-2 衝突(操作的是不同的 line / variable / target
**M8-1 + M8-2 在 Makefile 的部分正確**
### G4. `README.md`
- M8-1 改L18zero-dep bullet 去 yt-dlp、L74支援 URL / yt-dlp 行去掉、L128 / L131vendor-sync / payload-macos 描述去 yt-dlp、L182授權表格 yt-dlp row 刪)
- M8-2 改L24零學習成本 bullet 去 Mock、L73Mock 模式 bullet 整行刪)
- **M8-3不審**L163 GPL TODO 整行刪、L180 ffmpeg GPL row 改 LGPL
M8-1 + M8-2 在 README 的部分正確且完整。
**正確**
### G5. scripts/installer 類bootstrap-linux / bootstrap-windows / visiona-local.iss / build-appimage.sh / build.yml
- M8-1 改:清除 `vendor-ytdlp-*` / `yt-dlp*.exe` / tool loop 中的 yt-dlp
- **M8-3不審**LGPL 相關的 LICENSE.txt / COPYING.LGPLv3 複製、GPL warning 去除、ffprobe 複製
M8-1 的 yt-dlp 清除動作完整。
**正確**
---
## H. 問題清單
### Major阻擋 M8-4
**無。**
### Minor可先往下走後續 M8-6 / 另開處理)
| # | 檔案 | 問題描述 | 建議處理 |
|---|------|---------|---------|
| 1 | `frontend/src/components/camera/source-selector.tsx:184,189` | `accept=".mp4,.avi,.mov"``t('camera.mp4AviMov')` 仍為三種副檔名 | **不是 M8-1 的 bug**。deletions.md §5.1 的「新增 mpeg/mpg」和 milestone-plan M8-6 任務 2+3 明確把副檔名擴充列為 M8-6 的工作。留給 M8-6 處理即可 |
| 2 | `server/internal/api/handlers/camera_handler.go:~251`(未檢視) | 後端影片 extension whitelist 仍為 `.mp4 / .avi / .mov` | 同上milestone-plan M8-6 任務 4 明確列入 |
| 3 | `server/internal/deps/checker.go` | `CheckAll()` 仍只檢查 `ffmpeg`,沒加 `ffprobe` | 不是 M8-1 範圍。M8-3 切 LGPL ffmpeg 後 ffprobe 變必備,屆時應在 deps checker 加一項 |
| 4 | working tree 混入 M8-3 內容 | Makefile / README / installer / scripts / build.yml / `.gitignore` 都有 M8-3 的改動 | 這是「working tree 目前同時有三個 milestone」的狀態不是 bug。Orchestrator 決定要不要分 commit 即可 |
| 5 | `server/internal/api/api_e2e_test.go` 整檔刪 | 本來有 E2E HTTP 層級的煙霧測試,刪除後該層級失去測試 | 不 block M8-4但 M8-10 前應考慮補回一份不依賴 mock 的 e2e可改為無硬體環境下測 `/api/devices` 回空 list + `/api/models` 回內建模型等純 read-only 路徑) |
### Suggestion可做可不做
| # | 檔案 | 建議 |
|---|------|------|
| 1 | `visiona-local/app.go:671` 的 docblock 「R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)」 | 文字 OK可考慮補一句「若要無 Kneron 硬體環境 debug請直接透過 server binary `go run` 而不透過 Wails 殼」方便開發者 |
| 2 | `server/internal/device/manager_test.go:43` 的 R5-5a 紀念註解 | 可愛的紀錄,保留 |
---
## I. 通過 / 不通過結論
### ✅ **通過**
**理由**
1. M8-1 + M8-2 的 28 + 28 = 56 項砍除清單 **100% 達成**
2. 零誤刪,所有 R5 指定保留功能完整存在
3. 殘留 grep 完全 clean
4. 五項 build / test 全綠
5. Smoke test 驗證 runtime 行為正確(空 device list、/media/url 404、log 無 mock / yt-dlp
6. 兩個 Agent 的自補決定全部合理
7. 三個 merge 交集檔案app.go / main.go / Makefile / README的 M8-1 和 M8-2 改動相容無衝突
### 下一步建議給 Orchestrator
1. **M8-1 + M8-2 可以 commit**(建議兩個獨立 commit或一個合併 commit 標示「M8-1 + M8-2 砍除」)
2. **M8-3 的 working tree 改動需另外處理** — 建議分開 commit / 分開 review
3. **M8-4 可以開動** — 無技術阻擋
4. **M8-6 日後執行時** 記得處理 Minor #1#2(副檔名擴充 + 後端 whitelist + i18n 改名)
5. **M8-3 執行完** 順便處理 Minor #3deps checker 加 ffprobe
---
## 優點
- **M8-1 Agent**:砍得非常乾淨,連 orphan i18n key`uploadFile`)和 bundle bin dir 的 yt-dlp sentinel 都主動處理
- **M8-2 Agent**`TestManager_ListDevices_Empty` 新增測試體現 R5-5a 精神;`camera-controls.tsx` 的 fallback 改為 `disabled={cameras.length === 0}` 是比原本塞 `'mock-cam-0'` 更嚴謹的寫法
- **兩個 milestone 的 merge**:沒有互相踩腳。`visiona-local/app.go` 尤其漂亮,`mockMode` 砍掉後 `args` 陣列變得非常直觀
- **grep 殘留 0 match**:連 `videoIsURL` 這種容易忘記的 dead flag 都有徹底清除
砍完這兩個 milestone 後,整個 codebase 瘦了 **~655 行** 程式碼29 修改 + 3 刪除),直接對應 R5-7「砍掉非核心功能、focus 推論核心」的決策。砍得漂亮。