fix(local-agent): 修 camera 即時推論開不了(macOS 攝影機權限 + 假成功)

三層疊加根因:
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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-08-02 03:48:30 +08:00
parent 44b877318d
commit e4d27594d6
6 changed files with 262 additions and 7 deletions

View File

@ -2,13 +2,20 @@ package camera
import ( import (
"bufio" "bufio"
"bytes"
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"runtime" "runtime"
"sync" "sync"
"time"
) )
// stderrTailBytes 是保留的 ffmpeg stderr 尾端大小上限。
// ffmpeg 開攝影機失敗avfoundation not authorized / device busy 等)的關鍵訊息
// 都在 stderr 末尾,保留尾端即可診斷;限制大小避免長時間執行累積無界記憶體。
const stderrTailBytes = 8 * 1024
// FFmpegCamera captures webcam frames using ffmpeg subprocess. // FFmpegCamera captures webcam frames using ffmpeg subprocess.
// Supports macOS (AVFoundation) and Windows (DirectShow). // Supports macOS (AVFoundation) and Windows (DirectShow).
// ffmpeg outputs a continuous MJPEG stream to stdout which is parsed // ffmpeg outputs a continuous MJPEG stream to stdout which is parsed
@ -16,10 +23,43 @@ import (
type FFmpegCamera struct { type FFmpegCamera struct {
cmd *exec.Cmd cmd *exec.Cmd
stdout io.ReadCloser stdout io.ReadCloser
stderrBuf *ringBuffer // 保留 ffmpeg stderr 尾端,供失敗診斷(原本 =nil 直接丟棄)
latestFrame []byte latestFrame []byte
mu sync.Mutex mu sync.Mutex
done chan struct{} done chan struct{}
err error err error
// firstFrame 在第一張完整 JPEG frame 抵達時 close 一次,讓 WaitForFirstFrame 得知
// 攝影機真的開起來了cmd.Start() 成功只代表 ffmpeg 進程 fork 成功,不代表拿到 camera
firstFrame chan struct{}
firstFrameOnce sync.Once
}
// ringBuffer 保留寫入資料的最後 max bytesthread-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. // NewFFmpegCamera starts an ffmpeg process to capture from the given camera.
@ -33,16 +73,23 @@ func NewFFmpegCamera(cameraIndex, width, height, framerate int) (*FFmpegCamera,
// NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow). // NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow).
func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) { func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) {
args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate) args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate)
cmd := exec.Command("ffmpeg", args...) 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() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err) return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
} }
// Suppress ffmpeg's stderr banner/logs // 保留 ffmpeg stderr 尾端而不是丟棄(原本 cmd.Stderr = nil 讓 avfoundation
cmd.Stderr = nil // 權限被拒 / 裝置忙碌等錯誤全數消失、極難 debug。ringBuffer 只留末端、有界。
stderrBuf := newRingBuffer(stderrTailBytes)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err) return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
@ -51,7 +98,9 @@ func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height,
cam := &FFmpegCamera{ cam := &FFmpegCamera{
cmd: cmd, cmd: cmd,
stdout: stdout, stdout: stdout,
stderrBuf: stderrBuf,
done: make(chan struct{}), done: make(chan struct{}),
firstFrame: make(chan struct{}),
} }
go cam.readLoop() go cam.readLoop()
@ -59,6 +108,63 @@ func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height,
return cam, nil 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. // buildCaptureArgs returns the ffmpeg arguments for the current OS.
func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string { func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string {
videoSize := fmt.Sprintf("%dx%d", width, height) videoSize := fmt.Sprintf("%dx%d", width, height)
@ -154,6 +260,9 @@ func (c *FFmpegCamera) readLoop() {
c.latestFrame = frame c.latestFrame = frame
c.mu.Unlock() c.mu.Unlock()
// 通知 WaitForFirstFrame攝影機真的產出 frame 了(只觸發一次)。
c.firstFrameOnce.Do(func() { close(c.firstFrame) })
inFrame = false inFrame = false
} }
} }

View File

@ -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 1stdout 沒有任何 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 正常產出一張最小 JPEGSOI+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)
}
}

View File

@ -3,8 +3,22 @@ package camera
import ( import (
"fmt" "fmt"
"sync" "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 { type CameraInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -51,9 +65,23 @@ func (m *Manager) Open(index, width, height int) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to open camera (index=%d): %w", index, err) 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.ffmpegCam = cam
m.isOpen = true 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 return nil
} }

View File

@ -2,11 +2,19 @@ package camera
import ( import (
"context" "context"
"fmt"
"time" "time"
"visiona-agent/server/internal/driver" "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. // SourceType identifies the kind of frame source used in the pipeline.
type SourceType string type SourceType string
@ -90,6 +98,7 @@ func (p *InferencePipeline) run(ctx context.Context) {
targetInterval := time.Second / 15 // 15 FPS targetInterval := time.Second / 15 // 15 FPS
inferenceRan := false // for image mode: only run inference once inferenceRan := false // for image mode: only run inference once
frameIndex := 0 // video frame counter frameIndex := 0 // video frame counter
consecutiveReadErrors := 0 // camera 模式:連續讀 frame 失敗計數
for { for {
select { select {
@ -118,9 +127,22 @@ func (p *InferencePipeline) run(ctx context.Context) {
} else { } else {
jpegFrame, readErr = p.source.ReadFrame() jpegFrame, readErr = p.source.ReadFrame()
if readErr != nil { 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) time.Sleep(100 * time.Millisecond)
continue continue
} }
// 成功讀到 frame重置連續失敗計數。
consecutiveReadErrors = 0
} }
// Send to MJPEG stream // Send to MJPEG stream

View File

@ -23,6 +23,8 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>{{.Info.Copyright}}</string>
<key>NSCameraUsageDescription</key>
<string>visionA Agent 需要使用攝影機進行即時推論</string>
{{if .Info.FileAssociations}} {{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>

View File

@ -25,6 +25,8 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>{{.Info.Copyright}}</string>
<key>NSCameraUsageDescription</key>
<string>visionA Agent 需要使用攝影機進行即時推論</string>
{{if .Info.FileAssociations}} {{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>