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