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

19 KiB
Raw Permalink Blame History

v2/deletions.md — 刪檔 / 刪程式碼清單

所屬TDD v2 §2.5 版本v2.12026-04-14 吸收 PM Minor 5 grep 精準化 + Architect Q1 互審結論) 決策依據R5-5aMock 模式完全砍除、R5-7 前置yt-dlp 全套砍除) 對應 milestoneM8-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:435StartFromURL 內部呼叫)— 即將砍
  • server/internal/api/handlers/camera_handler.go:731handleVideoSeekvideoIsURL guard 下的 seek 分支)— dead code連同 videoIsURL field 一起砍

沒有其他呼叫者。所以:

  • 連同 NewVideoSourceFromURL 整個 function 砍
  • 連同 NewVideoSourceFromURLWithSeek(若有)整個 function 砍
  • 連同 newVideoSource(..., isURL=true, ...)isURL 參數分支砍(簡化內部函式簽名)
  • camera_handler.go:731handleVideoSeekif h.videoIsURL { ... } 整段 seek URL 分支砍dead code因為 videoIsURL 將不可能為 true
  • CameraHandler struct 的 videoIsURL bool field 砍

驗收:砍完後以下 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 互審時已 grepvideoIsURL 只在 camera_handler.go:731handleVideoSeek URL 分支被讀取,沒有 stopActivePipeline 的特殊 cleanup。因此

  • CameraHandler.videoIsURL bool field
  • handleVideoSeekif h.videoIsURL { ... } 整個 URL seek 分支dead code — 砍 StartFromURL 後永遠不會是 true
  • startVideoInferenceh.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 內的 binaryGo 1.19+ Windows 不再搜 cwd

改為:

// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
// 能透過 LookPath 找到 bundle 內的 binaryGo 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 { ... }
    ...
}

改法

  • mockdriver import
  • 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)
    ...
}

改法

  • mockMode struct 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 呼叫。

改動

  1. 刪 importstartFromUrluseCameraStore destructure 中移除(第 28 行附近)
  2. 刪 state第 51 行 const [videoMode, setVideoMode] = useState<'file' | 'url'>('file'); 整行刪
  3. 刪 state同一段若有 const [videoUrl, setVideoUrl] = useState(''); 也刪
  4. 刪函式:第 94-96 行 handleUrlSubmit 整個函式刪
  5. 刪 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 行:cannotOpenVideoUrl key 的 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.tsnoDevices 文字

現況

// 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-dlp grep完全無輸出(或只有 v1 文件 .autoflow/ 內的歷史紀錄,忽略)
  • Mock grep完全無輸出

若有殘留 → 補刀刪除或保留但註記原因。


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。