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:
parent
44b877318d
commit
e4d27594d6
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
92
local-agent/server/internal/camera/ffmpeg_camera_test.go
Normal file
92
local-agent/server/internal/camera/ffmpeg_camera_test.go
Normal 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 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -23,6 +23,8 @@
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>visionA Agent 需要使用攝影機進行即時推論</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
|
||||
@ -25,6 +25,8 @@
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>visionA Agent 需要使用攝影機進行即時推論</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user