依 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>
19 KiB
v2/deletions.md — 刪檔 / 刪程式碼清單
所屬:TDD v2 §2.5 版本:v2.1(2026-04-14 吸收 PM Minor 5 grep 精準化 + Architect Q1 互審結論) 決策依據:R5-5a(Mock 模式完全砍除)、R5-7 前置(yt-dlp 全套砍除) 對應 milestone:M8-1(砍 yt-dlp)、M8-2(砍 Mock) 給工程師:這是按表操作的清單,依序砍完就能送 Reviewer
操作規則:
- 每個「刪」動作後必須
go build ./server/...和pnpm --dir frontend build確認還可 compile - 每個「改」動作後同樣
- 全部砍完後
git grep -i 'yt-dlp\|ytdlp\|YTDLP\|mock\|Mock\|MOCK\|VISIONA_MOCK'應該只剩註解(如「原本這裡有 Mock 模式,已在 v2 砍除」)或明確是別的 mock(例如 gomock 測試框架)
1. 後端 Go — yt-dlp 全套砍除
1.1 server/internal/camera/video_source.go
刪除區塊:第 91-140 行
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
// from platforms like YouTube, Vimeo, etc.
// Returns the resolved direct URL or an error.
func ResolveWithYTDLP(rawURL string) (string, error) {
... 整個 func ...
}
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
func friendlyYTDLPError(stderr string) string {
... 整個 func ...
}
同時檢查 import:若原本因為 ResolveWithYTDLP 引入了 strings / os/exec / fmt,確認這些 import 在檔案其他地方還有用(視情況保留或移除)。
NewVideoSourceFromURL / NewVideoSourceFromURLWithSeek 的砍除(v2.1 Minor 5 精準化):
v2.0 原本寫「可能仍然有其他路徑呼叫,grep 再決定」— 這是模糊的。Architect Q1 互審時已實際 grep 確認結論:
grep -rn 'NewVideoSourceFromURL' /Users/jimchen/visionA/local-tool/server/
實測結果(2026-04-14 Architect 互審):
server/internal/api/handlers/camera_handler.go:435(StartFromURL內部呼叫)— 即將砍server/internal/api/handlers/camera_handler.go:731(handleVideoSeek的videoIsURLguard 下的 seek 分支)— dead code,連同videoIsURLfield 一起砍
沒有其他呼叫者。所以:
- 連同
NewVideoSourceFromURL整個 function 砍 - 連同
NewVideoSourceFromURLWithSeek(若有)整個 function 砍 - 連同
newVideoSource(..., isURL=true, ...)的isURL參數分支砍(簡化內部函式簽名) camera_handler.go:731的handleVideoSeek中if h.videoIsURL { ... }整段 seek URL 分支砍(dead code,因為videoIsURL將不可能為 true)CameraHandlerstruct 的videoIsURL boolfield 砍
驗收:砍完後以下 grep 應全部無輸出:
grep -rn 'NewVideoSourceFromURL\|videoIsURL' /Users/jimchen/visionA/local-tool/server/
1.2 server/internal/api/handlers/camera_handler.go
刪除區塊:第 341-497 行
// ytdlpHosts lists hostnames where yt-dlp should be used to resolve the actual
var ytdlpHosts = map[string]bool{ ... }
type urlKind int
const (
urlDirect urlKind = iota
urlYTDLP
urlBad
)
// classifyVideoURL determines how to handle the given URL.
func classifyVideoURL(rawURL string) (urlKind, string) { ... }
// StartFromURL handles video/stream inference from a URL (HTTP, HTTPS, RTSP).
func (h *CameraHandler) StartFromURL(c *gin.Context) { ... }
→ 整個 ytdlpHosts / urlKind / classifyVideoURL / StartFromURL 函式全砍。
videoIsURL field 的處置(v2.1 Minor 5 定案):
Architect Q1 互審時已 grep:videoIsURL 只在 camera_handler.go:731 的 handleVideoSeek URL 分支被讀取,沒有 stopActivePipeline 的特殊 cleanup。因此:
- 砍
CameraHandler.videoIsURL boolfield - 砍
handleVideoSeek內if h.videoIsURL { ... }整個 URL seek 分支(dead code — 砍StartFromURL後永遠不會是 true) - 砍
startVideoInference設h.videoIsURL = true的行(若有) - 結論:不保留為 always-false flag,直接刪乾淨(v2.0 原本留餘地「可能保留」,v2.1 明確決定刪)
1.3 server/internal/api/router.go
刪除:第 83 行
api.POST("/media/url", cameraHandler.StartFromURL)
1.4 server/internal/deps/checker.go
刪除:第 30-32 行
check("yt-dlp", false,
"macOS: brew install yt-dlp | Windows: winget install yt-dlp",
"--version"),
同時修改:第 69-70 行的註解提及 yt-dlp 冷啟動 20 秒的內容,更新為:
// 效能:bundle 內的 binary 冷啟動可能較慢(尤其 PyInstaller),
// bundle binary 已知良好,跳過 version 查詢以加速啟動。
1.5 server/main.go
修改:第 89-95 行的 PATH 注入註解。原文:
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH,讓 exec.Command("yt-dlp") / exec.Command("ffmpeg")
// 能透過 LookPath 找到 bundle 內的 binary(Go 1.19+ Windows 不再搜 cwd)。
改為:
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH,讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
// 能透過 LookPath 找到 bundle 內的 binary(Go 1.19+ Windows 不再搜 cwd)。
PATH 注入邏輯本身不動(ffmpeg 仍需要)。
2. 後端 Go — Mock 模式全套砍除(R5-5a)
2.1 整檔刪除
| 檔案 | 行數 | 說明 |
|---|---|---|
server/internal/driver/mock/mock_driver.go |
183 | MockDriver struct 與所有 method |
server/internal/camera/mock_camera.go |
95 | MockCamera struct 與所有 method |
動作:
rm server/internal/driver/mock/mock_driver.go
rmdir server/internal/driver/mock # 該目錄若空就刪
rm server/internal/camera/mock_camera.go
2.2 修改 server/internal/device/manager.go
現況:
import (
...
mockdriver "visiona-local/server/internal/driver/mock"
...
)
type Manager struct {
...
mockMode bool
...
}
func NewManager(registry *DriverRegistry, mockMode bool, mockCount int, scriptPath string) *Manager {
...
if mockMode { ... mockdriver.NewMockDriver(...) ... }
}
func (m *Manager) Scan() error {
if m.mockMode { ... }
...
}
func (m *Manager) someMethod() {
if m.mockMode { ... }
...
}
改法:
- 刪
mockdriverimport - 刪
mockMode/mockCount欄位 - 修改
NewManager簽名:func NewManager(registry *DriverRegistry, scriptPath string) *Manager - 砍掉所有
if m.mockMode { ... }分支,只留 real path
2.3 修改 server/internal/camera/manager.go
現況:
type Manager struct {
mockMode bool
mockCamera *MockCamera
...
}
func NewManager(mockMode bool) *Manager {
return &Manager{mockMode: mockMode}
}
func (m *Manager) Start(...) {
if m.mockMode {
m.mockCamera = NewMockCamera(width, height)
...
}
...
}
改法:
- 刪
mockMode欄位、mockCamera欄位 - 改簽名:
func NewManager() *Manager - 砍所有
if m.mockMode { ... }
2.4 修改 server/internal/config/config.go
現況:第 21-23 行
MockMode bool
MockCamera bool
MockDeviceCount int
第 39-41 行:
flag.BoolVar(&cfg.MockMode, "mock", false, "Enable mock device driver")
flag.BoolVar(&cfg.MockCamera, "mock-camera", false, "Enable mock camera")
flag.IntVar(&cfg.MockDeviceCount, "mock-devices", 1, "Number of mock devices")
改法:整組刪(三個欄位 + 三個 flag)。
2.5 修改 server/main.go
現況:
// 第 86-87 行
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v, Python mode: %s",
cfg.MockMode, cfg.MockCamera, cfg.DevMode, cfg.PythonMode)
// 第 142 行
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, bridgeScript)
// 第 147 行
cameraMgr := camera.NewManager(cfg.MockCamera)
改法:
logger.Info("Dev mode: %v, Python mode: %s", cfg.DevMode, cfg.PythonMode)
deviceMgr := device.NewManager(registry, bridgeScript)
cameraMgr := camera.NewManager()
2.6 修改 server/internal/device/manager_test.go
如果測試檔案裡有 mockMode 相關 case → 整段刪。若整個測試檔都是 mock-based → 整檔刪。
動作:cat server/internal/device/manager_test.go 確認後決定。
2.7 修改 server/internal/api/api_e2e_test.go
如果 e2e test 開啟 mock mode 跑 → 改為 skip(沒硬體時 skip),或改寫成不依賴 mock。
動作:grep -n 'Mock\|mock' server/internal/api/api_e2e_test.go 查看,M8-2 執行者決定保留哪些測試。
3. Wails app.go — Mock 模式砍除
3.1 visiona-local/app.go 修改
現況:
// 第 83 行
type App struct {
...
mockMode bool
...
}
// 第 119-120 行
// M7:預設真實硬體模式(使用者決策 Q8)
// 若要強制 mock 模式(無 Kneron 裝置環境下 debug),設環境變數 VISIONA_MOCK=1
mock := os.Getenv("VISIONA_MOCK") == "1"
return &App{
pythonMode: mode,
mockMode: mock,
}
// 第 429 行(在 startServer 中)
if err != nil && !a.mockMode {
...
}
// 第 441 行
if !a.mockMode && pyBin != "" {
if err := a.ensureDriverInstalled(pyBin); err != nil {
...
}
}
// 第 472-479 行(組 args)
if a.mockMode {
args = append(args, "--mock")
} else {
args = append(args, "--python-mode", string(pyMode))
if pyBin != "" {
args = append(args, "--python", pyBin)
}
}
// 第 502 行
if !a.mockMode && pyBin != "" {
env = append(env, "VISIONA_PYTHON="+pyBin)
...
}
改法:
- 刪
mockModestruct field - 刪
NewApp()的 env 讀取 + 初始化 - 刪所有
!a.mockMode/a.mockMode條件 - 組 args 的邏輯簡化:一定走 real path,
--python-mode與--python一定帶 err != nil && !a.mockMode→ 直接err != nil(失敗就 return 錯誤)
3.2 visiona-local/frontend/app.js
若有任何提及 mock 的內容(M7-B 後應該沒有) → 刪
4. 打包流程砍除
4.1 Makefile — 砍 yt-dlp
刪除 targets:
| 行數 | 內容 |
|---|---|
| ~22 | vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp \ 中的 vendor-ytdlp |
| ~23 | vendor-python-windows ... vendor-ytdlp-windows 同上 |
| ~24 | vendor-python-linux ... vendor-ytdlp-linux 同上 |
| 39 | help 文字中 /yt-dlp |
| 70-71 | YTDLP_URL_DARWIN := ... |
| 73 | vendor-sync 依賴移除 vendor-ytdlp |
| 134-144 | vendor-ytdlp: target 整段 |
| 182 | payload-macos 依賴移除 vendor-ytdlp |
| 188-189 | cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/ + chmod +x payload/darwin/bin/yt-dlp |
| 194 | help 文字 + yt-dlp |
| 219 | YTDLP_URL_WINDOWS := ... |
| 288-296 | vendor-ytdlp-windows: target 整段 |
| 298 | payload-windows 依賴移除 vendor-ytdlp-windows |
| 299 | help 文字 + yt-dlp |
| 307 | cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/ |
| 332 | YTDLP_URL_LINUX := ... |
| 381-390 | vendor-ytdlp-linux: target 整段 |
| 392 | payload-linux 依賴移除 vendor-ytdlp-linux |
| 393 | help 文字 + yt-dlp |
| 401 | cp vendor/yt-dlp/linux/yt-dlp ... 整行 |
同時:刪 /vendor/yt-dlp/ 整個目錄(若 Makefile 已經產出過)
rm -rf vendor/yt-dlp/
4.2 installer/windows/visiona-local.iss
刪除:第 74 行的區段標題 ; ── ffmpeg + yt-dlp ─── 改為 ; ── ffmpeg + ffprobe ───。
第 76 行:
Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
刪除此行。同時新增 ffprobe 和 LGPL license 行(見 v2/ffmpeg-lgpl.md §8)。
4.3 installer/linux/build-appimage.sh
修改:第 61-62 行
# ffmpeg / yt-dlp
for tool in ffmpeg yt-dlp; do
改為:
# ffmpeg / ffprobe
for tool in ffmpeg ffprobe; do
4.4 scripts/bootstrap-linux.sh
修改:第 61 行
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux
改為:
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux
4.5 scripts/bootstrap-windows.ps1
修改:
- 第 191 行註解:
保留 vendor/ 快取(Python runtime / wheels / ffmpeg / yt-dlp)以免重下 200MB→... 200MB)改為... (Python runtime / wheels / ffmpeg) - 第 202 行註解:同上
- 第 207 行:
'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows'移除vendor-ytdlp-windows
5. 前端 TypeScript / React — yt-dlp URL tab 砍除
5.1 frontend/src/components/camera/source-selector.tsx
現況:260 行,使用 videoMode state + pasteUrl / urlPlaceholder / urlHelpText i18n、handleUrlSubmit / startFromUrl 呼叫。
改動:
- 刪 import:
startFromUrl從useCameraStoredestructure 中移除(第 28 行附近) - 刪 state:第 51 行
const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');整行刪 - 刪 state:同一段若有
const [videoUrl, setVideoUrl] = useState('');也刪 - 刪函式:第 94-96 行
handleUrlSubmit整個函式刪 - 刪 UI:第 185-253 行的 video tab 內的 mode toggle +
videoMode === 'file' ? ... : ...整塊改為「只剩 file 選擇」:
{activeTab === 'video' && (
<div className="flex items-center gap-3">
<Button
onClick={() => videoFileRef.current?.click()}
disabled={isUploading}
>
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
</Button>
<span className="text-sm text-muted-foreground">
{t('camera.mp4AviMovMpeg')}
</span>
<input
ref={videoFileRef}
type="file"
accept=".mp4,.avi,.mov,.mpeg,.mpg" {/* R5-6 / 三方共識 #11:新增 mpeg/mpg */}
className="hidden"
onChange={handleVideoSelect}
/>
</div>
)}
砍掉「上傳檔案 / 貼上連結」兩個 mode Button + URL input + help text + loading text。
5.2 frontend/src/stores/camera-store.ts
刪除:
- 第 32 行 type 定義:
startFromUrl: (url: string, deviceId: string) => Promise<void>; - 第 167-196 行:
startFromUrl函式的整個實作(30 行)
5.3 frontend/src/lib/i18n/types.ts
刪除:
- 第 210-212 行:
pasteUrl,urlPlaceholder,urlHelpText三個 key 的 type 定義 - 第 422 行:
cannotOpenVideoUrlkey 的 type 定義
新增:
mp4AviMovMpeg: string;(取代 v1 的mp4AviMov或並存)— 決定改不改 key:建議改名成videoFormats(通用化),以便未來擴充時不用再改型別名。
5.4 frontend/src/lib/i18n/zh-TW.ts
刪除/修改:
- 第 212 行
pasteUrl: '貼上連結',刪 - 第 213 行
urlPlaceholder: 'https://example.com/video.mp4',刪 - 第 214 行
urlHelpText: '支援 YouTube、直接影片 URL(.mp4 等)及 RTSP 串流。',刪 - 第 217 行
mp4AviMov: 'MP4, AVI, MOV',→ 改為videoFormats: 'MP4 / AVI / MOV / MPEG / MPG', - 第 424 行
cannotOpenVideoUrl: '無法開啟影片連結',刪(若 store 沒有呼叫處)
5.5 frontend/src/lib/i18n/en.ts
同 §5.4,英文版對應修改:
- 刪
pasteUrl,urlPlaceholder,urlHelpText,cannotOpenVideoUrl mp4AviMov: 'MP4, AVI, MOV',→videoFormats: 'MP4 / AVI / MOV / MPEG / MPG',
6. 前端 TypeScript / React — Mock 模式砍除
6.1 frontend/src/app/settings/page.tsx
刪除區塊:第 140-156 行
<div className="space-y-2">
<Label>{t('settings.hardware.runtimeMode')}</Label>
{/* TODO: 連接 backend GET /api/system/config ... */}
<Select value="real" disabled>
<SelectTrigger className="w-[420px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mock">{t('settings.hardware.runtimeModeMock')}</SelectItem>
<SelectItem value="real">{t('settings.hardware.runtimeModeReal')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('settings.hardware.runtimeModeHint')}
</p>
</div>
<Separator />
→ 整段 <div> 與其後的 <Separator /> 一起刪(Separator 是用來分隔 runtimeMode 與 pythonMode 的)。
6.2 frontend/src/lib/i18n/types.ts
刪除:第 272-275 行
runtimeMode: string;
runtimeModeMock: string;
runtimeModeReal: string;
runtimeModeHint: string;
6.3 frontend/src/lib/i18n/zh-TW.ts
刪除:第 274-277 行 4 個 key。
6.4 frontend/src/lib/i18n/en.ts
刪除:同 §6.3,第 274-277 行英文版。
6.5 frontend/src/lib/i18n/zh-TW.ts + en.ts 的 noDevices 文字
現況:
// zh-TW.ts:70
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
// en.ts:70
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
改為(對應 R-v2-7 的 empty state 建議):
// zh-TW
noDevices: '未偵測到 Kneron 裝置。請連接 KL520 / KL720 後按「掃描」。',
// en
noDevices: 'No Kneron devices detected. Please connect a KL520 / KL720 device and click "Scan".',
7. Wails 控制台(改寫而非刪)
visiona-local/frontend/index.html / app.js / style.css — 改寫而非刪,詳見 v2/control-panel.md §7「檔案系統變化」。
提醒:改寫步驟屬於 M8-5,不在本文件的 M8-1 / M8-2 範圍。
8. 驗收 grep
M8-1 + M8-2 全部完成後,執行以下 grep 應該全部 clean(只剩註解或非相關的 match):
# yt-dlp
grep -rn 'yt-dlp\|ytdlp\|YTDLP\|ResolveWithYTDLP\|friendlyYTDLPError\|ytdlpHosts\|urlYTDLP\|classifyVideoURL\|StartFromURL\|cannotOpenVideoUrl\|pasteUrl\|urlPlaceholder\|urlHelpText' \
/Users/jimchen/visionA/local-tool/server/ \
/Users/jimchen/visionA/local-tool/frontend/ \
/Users/jimchen/visionA/local-tool/visiona-local/ \
/Users/jimchen/visionA/local-tool/Makefile \
/Users/jimchen/visionA/local-tool/installer/ \
/Users/jimchen/visionA/local-tool/scripts/
# Mock
grep -rn 'MockMode\|mockMode\|MockCamera\|MockDriver\|NewMockDriver\|NewMockCamera\|VISIONA_MOCK\|runtimeModeMock' \
/Users/jimchen/visionA/local-tool/server/ \
/Users/jimchen/visionA/local-tool/frontend/ \
/Users/jimchen/visionA/local-tool/visiona-local/
預期:
yt-dlpgrep:完全無輸出(或只有 v1 文件.autoflow/內的歷史紀錄,忽略)Mockgrep:完全無輸出
若有殘留 → 補刀刪除或保留但註記原因。
9. 砍除後的 compile 確認
# Go server
cd /Users/jimchen/visionA/local-tool/server && go build ./...
cd /Users/jimchen/visionA/local-tool/server && go test ./...
# Wails app
cd /Users/jimchen/visionA/local-tool/visiona-local && go build .
# 前端
cd /Users/jimchen/visionA/local-tool/frontend && pnpm install && pnpm build
四個都要綠才算 M8-1 / M8-2 完成,可送 Reviewer。