From e4d27594d6c0ce130498a3c6c0c75063b0176e7e Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 2 Aug 2026 03:48:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-agent):=20=E4=BF=AE=20camera=20?= =?UTF-8?q?=E5=8D=B3=E6=99=82=E6=8E=A8=E8=AB=96=E9=96=8B=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=EF=BC=88macOS=20=E6=94=9D=E5=BD=B1=E6=A9=9F=E6=AC=8A=E9=99=90?= =?UTF-8?q?=20+=20=E5=81=87=E6=88=90=E5=8A=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三層疊加根因: 1. 主根因:.app 缺 NSCameraUsageDescription → macOS TCC 靜默拒絕、不彈授權 視窗、綠燈不亮、ffmpeg avfoundation 抓不到 camera。 2. ffmpeg cmd.Start() 只要 fork 成功就回 nil → HTTP 200 假成功(攝影機沒真開)。 3. cmd.Stderr=nil 吞掉 ffmpeg 錯誤 + pipeline 靜默重試 → 極難查。 修法: - Info.plist + Info.dev.plist 加 NSCameraUsageDescription(wails build template, ad-hoc 簽名下只需 usage description,刻意不加 hardened runtime/entitlement 避免 TCC 直接拒絕) - ffmpeg WaitForFirstFrame:收到首張完整 JPEG frame 才回成功;早退/逾時回明確 錯誤 → camera/start 真的回非 200、前端看到真實失敗。timeout 25s(涵蓋首次 TCC 授權彈窗的使用者反應時間;已授權情境仍秒開) - stderr 導到有界 ringBuffer + log;pipeline camera 模式連續失敗 50 次結束 reviewer 通過(0C/0M/2m)。只動 camera 鏈路(影片/圖片/批次/tunnel 上傳不受影響)。 build/vet/test/gosec 過、4 新 camera 測試。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server/internal/camera/ffmpeg_camera.go | 121 +++++++++++++++++- .../internal/camera/ffmpeg_camera_test.go | 92 +++++++++++++ local-agent/server/internal/camera/manager.go | 30 ++++- .../server/internal/camera/pipeline.go | 22 ++++ .../visiona-agent/build/darwin/Info.dev.plist | 2 + .../visiona-agent/build/darwin/Info.plist | 2 + 6 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 local-agent/server/internal/camera/ffmpeg_camera_test.go diff --git a/local-agent/server/internal/camera/ffmpeg_camera.go b/local-agent/server/internal/camera/ffmpeg_camera.go index b6a7c24..45a7c4e 100644 --- a/local-agent/server/internal/camera/ffmpeg_camera.go +++ b/local-agent/server/internal/camera/ffmpeg_camera.go @@ -2,13 +2,20 @@ package camera import ( "bufio" + "bytes" "fmt" "io" "os/exec" "runtime" "sync" + "time" ) +// stderrTailBytes 是保留的 ffmpeg stderr 尾端大小上限。 +// ffmpeg 開攝影機失敗(avfoundation not authorized / device busy 等)的關鍵訊息 +// 都在 stderr 末尾,保留尾端即可診斷;限制大小避免長時間執行累積無界記憶體。 +const stderrTailBytes = 8 * 1024 + // FFmpegCamera captures webcam frames using ffmpeg subprocess. // Supports macOS (AVFoundation) and Windows (DirectShow). // ffmpeg outputs a continuous MJPEG stream to stdout which is parsed @@ -16,10 +23,43 @@ import ( type FFmpegCamera struct { cmd *exec.Cmd stdout io.ReadCloser + stderrBuf *ringBuffer // 保留 ffmpeg stderr 尾端,供失敗診斷(原本 =nil 直接丟棄) latestFrame []byte mu sync.Mutex done chan struct{} err error + + // firstFrame 在第一張完整 JPEG frame 抵達時 close 一次,讓 WaitForFirstFrame 得知 + // 攝影機真的開起來了(cmd.Start() 成功只代表 ffmpeg 進程 fork 成功,不代表拿到 camera)。 + firstFrame chan struct{} + firstFrameOnce sync.Once +} + +// ringBuffer 保留寫入資料的最後 max bytes(thread-safe),用來留住 ffmpeg stderr 尾端。 +type ringBuffer struct { + mu sync.Mutex + buf []byte + max int +} + +func newRingBuffer(max int) *ringBuffer { + return &ringBuffer{max: max} +} + +func (r *ringBuffer) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.buf = append(r.buf, p...) + if len(r.buf) > r.max { + r.buf = r.buf[len(r.buf)-r.max:] + } + return len(p), nil +} + +func (r *ringBuffer) String() string { + r.mu.Lock() + defer r.mu.Unlock() + return string(bytes.TrimSpace(append([]byte(nil), r.buf...))) } // NewFFmpegCamera starts an ffmpeg process to capture from the given camera. @@ -33,25 +73,34 @@ func NewFFmpegCamera(cameraIndex, width, height, framerate int) (*FFmpegCamera, // NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow). func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) { args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate) - cmd := exec.Command("ffmpeg", args...) + return newFFmpegCameraFromCmd(cmd) +} +// newFFmpegCameraFromCmd wires up stdout/stderr and starts the given ffmpeg-like +// command. Extracted so tests can substitute a fake command (e.g. a shell script +// that simulates early-exit or no-frame) to exercise WaitForFirstFrame. +func newFFmpegCameraFromCmd(cmd *exec.Cmd) (*FFmpegCamera, error) { stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to get stdout pipe: %w", err) } - // Suppress ffmpeg's stderr banner/logs - cmd.Stderr = nil + // 保留 ffmpeg stderr 尾端而不是丟棄(原本 cmd.Stderr = nil 讓 avfoundation + // 權限被拒 / 裝置忙碌等錯誤全數消失、極難 debug)。ringBuffer 只留末端、有界。 + stderrBuf := newRingBuffer(stderrTailBytes) + cmd.Stderr = stderrBuf if err := cmd.Start(); err != nil { return nil, fmt.Errorf("failed to start ffmpeg: %w", err) } cam := &FFmpegCamera{ - cmd: cmd, - stdout: stdout, - done: make(chan struct{}), + cmd: cmd, + stdout: stdout, + stderrBuf: stderrBuf, + done: make(chan struct{}), + firstFrame: make(chan struct{}), } go cam.readLoop() @@ -59,6 +108,63 @@ func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, return cam, nil } +// WaitForFirstFrame 等到攝影機真的產出第一張 frame 才回 nil;否則回明確錯誤。 +// +// 存在原因:cmd.Start() 只代表 ffmpeg 進程 fork 成功,avfoundation 抓不到 camera +// (權限被 TCC 靜默拒絕 / 裝置忙碌)是在進程啟動「之後」才失敗、ffmpeg 隨即 exit。 +// 呼叫端(manager.Open → handler)改在 Start 後呼叫此函式,把「真的拿到攝影機」 +// 這件事納入成功判斷,避免 HTTP 200 假成功。 +// +// 三種結束情況: +// 1. 收到第一張 frame → nil +// 2. ffmpeg 提早結束(readLoop 讀到 EOF,設 c.err)→ 回含 stderr 尾端的錯誤 +// 3. 逾時 → 回逾時錯誤(附 stderr 尾端,可能含權限 / 裝置訊息) +func (c *FFmpegCamera) WaitForFirstFrame(timeout time.Duration) error { + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-c.firstFrame: + return nil + case <-c.done: + // ffmpeg 已結束卻沒送出任何 frame → 開攝影機失敗。 + c.mu.Lock() + streamErr := c.err + c.mu.Unlock() + // 有可能 done 與 firstFrame 幾乎同時(極少見):done 後再確認一次是否其實已有 frame。 + select { + case <-c.firstFrame: + return nil + default: + } + return fmt.Errorf("camera did not start: ffmpeg exited before producing a frame: %w%s", + streamErr, c.stderrTailSuffix()) + case <-timer.C: + return fmt.Errorf("camera did not start: timed out after %s waiting for first frame%s", + timeout, c.stderrTailSuffix()) + } +} + +// stderrTailSuffix 回傳可附加到錯誤訊息的 ffmpeg stderr 尾端(若有)。 +func (c *FFmpegCamera) stderrTailSuffix() string { + if c.stderrBuf == nil { + return "" + } + tail := c.stderrBuf.String() + if tail == "" { + return "" + } + return " (ffmpeg: " + tail + ")" +} + +// StderrTail 回傳目前保留的 ffmpeg stderr 尾端(供上層 log 診斷)。 +func (c *FFmpegCamera) StderrTail() string { + if c.stderrBuf == nil { + return "" + } + return c.stderrBuf.String() +} + // buildCaptureArgs returns the ffmpeg arguments for the current OS. func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string { videoSize := fmt.Sprintf("%dx%d", width, height) @@ -154,6 +260,9 @@ func (c *FFmpegCamera) readLoop() { c.latestFrame = frame c.mu.Unlock() + // 通知 WaitForFirstFrame:攝影機真的產出 frame 了(只觸發一次)。 + c.firstFrameOnce.Do(func() { close(c.firstFrame) }) + inFrame = false } } diff --git a/local-agent/server/internal/camera/ffmpeg_camera_test.go b/local-agent/server/internal/camera/ffmpeg_camera_test.go new file mode 100644 index 0000000..15e5945 --- /dev/null +++ b/local-agent/server/internal/camera/ffmpeg_camera_test.go @@ -0,0 +1,92 @@ +package camera + +import ( + "os/exec" + "strings" + "testing" + "time" +) + +// 這些測試用 shell 腳本假扮 ffmpeg,驗證 WaitForFirstFrame 的三種結束路徑, +// 不依賴真實攝影機(CI / 無 camera 環境也能跑)。 + +// TestWaitForFirstFrame_EarlyExit 模擬 ffmpeg 啟動即失敗(如 avfoundation 權限被拒): +// 進程 fork 成功但隨即 exit、不產出任何 frame。WaitForFirstFrame 應回錯誤, +// 且錯誤訊息帶上 stderr 尾端(診斷用)。 +func TestWaitForFirstFrame_EarlyExit(t *testing.T) { + // 寫一行錯誤到 stderr 後立刻 exit 1,stdout 沒有任何 JPEG。 + cmd := exec.Command("sh", "-c", "echo 'avfoundation: not authorized to capture video' 1>&2; exit 1") + cam, err := newFFmpegCameraFromCmd(cmd) + if err != nil { + t.Fatalf("newFFmpegCameraFromCmd failed: %v", err) + } + defer cam.Close() + + if err := cam.WaitForFirstFrame(3 * time.Second); err == nil { + t.Fatal("expected error when ffmpeg exits before producing a frame, got nil") + } else if !strings.Contains(err.Error(), "not authorized") { + t.Errorf("expected stderr tail in error, got: %v", err) + } +} + +// TestWaitForFirstFrame_Timeout 模擬 ffmpeg 啟動後長時間不產出 frame(進程還活著, +// 但 avfoundation 卡住 / 沒資料)。WaitForFirstFrame 應在 timeout 後回逾時錯誤。 +func TestWaitForFirstFrame_Timeout(t *testing.T) { + // 進程存活 10s、不輸出任何 JPEG 到 stdout。 + cmd := exec.Command("sh", "-c", "sleep 10") + cam, err := newFFmpegCameraFromCmd(cmd) + if err != nil { + t.Fatalf("newFFmpegCameraFromCmd failed: %v", err) + } + defer cam.Close() + + start := time.Now() + if err := cam.WaitForFirstFrame(300 * time.Millisecond); err == nil { + t.Fatal("expected timeout error when no frame is produced, got nil") + } else if !strings.Contains(err.Error(), "timed out") { + t.Errorf("expected timeout error, got: %v", err) + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Errorf("WaitForFirstFrame took too long (%s), expected ~timeout", elapsed) + } +} + +// TestWaitForFirstFrame_Success 模擬 ffmpeg 正常產出一張最小 JPEG(SOI+EOI): +// WaitForFirstFrame 應回 nil,且 ReadFrame 拿得到該 frame。 +func TestWaitForFirstFrame_Success(t *testing.T) { + // printf 出最小合法 JPEG 標記:FF D8 ... FF D9,然後 sleep 保持進程存活。 + cmd := exec.Command("sh", "-c", `printf '\xFF\xD8\x00\xFF\xD9'; sleep 2`) + cam, err := newFFmpegCameraFromCmd(cmd) + if err != nil { + t.Fatalf("newFFmpegCameraFromCmd failed: %v", err) + } + defer cam.Close() + + if err := cam.WaitForFirstFrame(3 * time.Second); err != nil { + t.Fatalf("expected success when a frame is produced, got: %v", err) + } + + frame, err := cam.ReadFrame() + if err != nil { + t.Fatalf("ReadFrame after first frame failed: %v", err) + } + if len(frame) < 4 || frame[0] != 0xFF || frame[1] != 0xD8 { + t.Errorf("expected a JPEG frame starting with FFD8, got % x", frame) + } +} + +// TestRingBuffer_KeepsTail 驗證 ringBuffer 只保留尾端、不無界成長。 +func TestRingBuffer_KeepsTail(t *testing.T) { + rb := newRingBuffer(8) + if _, err := rb.Write([]byte("0123456789ABCDEF")); err != nil { + t.Fatalf("write failed: %v", err) + } + got := rb.String() + if got != "9ABCDEF" && got != "89ABCDEF" { + // TrimSpace 不影響此輸入;預期保留最後 8 bytes "9ABCDEF" 前含 '8' + t.Logf("tail = %q", got) + } + if len(got) > 8 { + t.Errorf("ringBuffer exceeded max: len=%d content=%q", len(got), got) + } +} diff --git a/local-agent/server/internal/camera/manager.go b/local-agent/server/internal/camera/manager.go index 2209025..5b14a60 100644 --- a/local-agent/server/internal/camera/manager.go +++ b/local-agent/server/internal/camera/manager.go @@ -3,8 +3,22 @@ package camera import ( "fmt" "sync" + "time" ) +// firstFrameTimeout 是 Open() 等待攝影機產出第一張 frame 的上限。 +// +// 為何 25s(不是更短):首次開攝影機時 macOS 的 TCC 攝影機授權對話框是「同步阻擋」的, +// 逾時從進入 WaitForFirstFrame 就起算、會把使用者在彈窗前猶豫/反應的時間也算進去。 +// 若太短(如 8s),首次點「開始推論」極可能在使用者還沒按下「允許」前就逾時失敗、 +// 要按第二次才成功——首次體驗變成非預期失敗。25s 足夠涵蓋 TCC 授權彈窗的使用者反應時間。 +// +// 為何拉長不會拖慢正常情境:授權彈窗期間 ffmpeg 進程仍存活、不會早退;且一拿到第一張 +// frame 就立即返回,已授權情境仍是秒開,不會真的等滿 25s。只有「真的開不了」(權限被拒 +// 後 ffmpeg exit / 裝置忙碌)時才會等到逾時——ffmpeg 早退會由 done 分支提前回錯誤, +// 真正等滿 25s 的僅剩「進程活著但持續不出 frame」的少數情況。 +const firstFrameTimeout = 25 * time.Second + type CameraInfo struct { ID string `json:"id"` Name string `json:"name"` @@ -51,9 +65,23 @@ func (m *Manager) Open(index, width, height int) error { if err != nil { return fmt.Errorf("failed to open camera (index=%d): %w", index, err) } + + // cmd.Start() 成功 ≠ 攝影機真的開起來。等第一張 frame 才算成功;否則清掉 ffmpeg + // 進程並回錯誤(含 ffmpeg stderr 尾端),讓 handler 回非 200、前端看到真實失敗, + // 而不是「200 假成功、畫面永遠空白」。 + if err := cam.WaitForFirstFrame(firstFrameTimeout); err != nil { + if tail := cam.StderrTail(); tail != "" { + fmt.Printf("[ERROR] Camera open failed (index=%d): %v\n[ffmpeg stderr]\n%s\n", index, err, tail) + } else { + fmt.Printf("[ERROR] Camera open failed (index=%d): %v\n", index, err) + } + _ = cam.Close() + return fmt.Errorf("failed to open camera (index=%d): %w", index, err) + } + m.ffmpegCam = cam m.isOpen = true - fmt.Printf("[INFO] Opened real camera (index=%d) via ffmpeg\n", index) + fmt.Printf("[INFO] Opened real camera (index=%d) via ffmpeg (first frame received)\n", index) return nil } diff --git a/local-agent/server/internal/camera/pipeline.go b/local-agent/server/internal/camera/pipeline.go index de4d024..d24c7b8 100644 --- a/local-agent/server/internal/camera/pipeline.go +++ b/local-agent/server/internal/camera/pipeline.go @@ -2,11 +2,19 @@ package camera import ( "context" + "fmt" "time" "visiona-agent/server/internal/driver" ) +// maxConsecutiveReadErrors 是 camera 模式下連續讀 frame 失敗的容忍上限。 +// +// 原本讀失敗只 sleep 100ms 後無限重試、完全靜默——攝影機中途斷線 / 從未出 frame 時 +// 前端只會看到永遠空白、後端也沒任何跡象。改成連續失敗超過上限就 log + 結束 pipeline, +// 讓失敗看得見。100ms * 50 ≈ 5s,足夠容忍偶發抖動,又不會無限卡住。 +const maxConsecutiveReadErrors = 50 + // SourceType identifies the kind of frame source used in the pipeline. type SourceType string @@ -90,6 +98,7 @@ func (p *InferencePipeline) run(ctx context.Context) { targetInterval := time.Second / 15 // 15 FPS inferenceRan := false // for image mode: only run inference once frameIndex := 0 // video frame counter + consecutiveReadErrors := 0 // camera 模式:連續讀 frame 失敗計數 for { select { @@ -118,9 +127,22 @@ func (p *InferencePipeline) run(ctx context.Context) { } else { jpegFrame, readErr = p.source.ReadFrame() if readErr != nil { + // camera 模式:不再無限靜默重試。連續失敗超過上限就 log + 結束, + // 避免攝影機從未出 frame / 中途斷線時前端永遠空白、後端毫無跡象。 + // 非 camera 來源(理論上不會走到這,image/batch 有各自路徑)維持原重試行為。 + if p.sourceType == SourceCamera { + consecutiveReadErrors++ + if consecutiveReadErrors >= maxConsecutiveReadErrors { + fmt.Printf("[ERROR] camera pipeline aborted after %d consecutive read errors: %v\n", + consecutiveReadErrors, readErr) + return + } + } time.Sleep(100 * time.Millisecond) continue } + // 成功讀到 frame,重置連續失敗計數。 + consecutiveReadErrors = 0 } // Send to MJPEG stream diff --git a/local-agent/visiona-agent/build/darwin/Info.dev.plist b/local-agent/visiona-agent/build/darwin/Info.dev.plist index 14121ef..ba49fd3 100644 --- a/local-agent/visiona-agent/build/darwin/Info.dev.plist +++ b/local-agent/visiona-agent/build/darwin/Info.dev.plist @@ -23,6 +23,8 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSCameraUsageDescription + visionA Agent 需要使用攝影機進行即時推論 {{if .Info.FileAssociations}} CFBundleDocumentTypes diff --git a/local-agent/visiona-agent/build/darwin/Info.plist b/local-agent/visiona-agent/build/darwin/Info.plist index b8b37dc..c255dda 100644 --- a/local-agent/visiona-agent/build/darwin/Info.plist +++ b/local-agent/visiona-agent/build/darwin/Info.plist @@ -25,6 +25,8 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSCameraUsageDescription + visionA Agent 需要使用攝影機進行即時推論 {{if .Info.FileAssociations}} CFBundleDocumentTypes