jim800121chen e4d27594d6 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>
2026-08-02 03:48:30 +08:00

298 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
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 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.
// On macOS, cameraIndex is used (e.g. 0 for first camera).
// On Windows, cameraName from device detection is used; cameraIndex is ignored
// unless no name is provided.
func NewFFmpegCamera(cameraIndex, width, height, framerate int) (*FFmpegCamera, error) {
return NewFFmpegCameraWithName(cameraIndex, "", width, height, framerate)
}
// 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)
}
// 保留 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,
stderrBuf: stderrBuf,
done: make(chan struct{}),
firstFrame: make(chan struct{}),
}
go cam.readLoop()
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)
fps := fmt.Sprintf("%d", framerate)
switch runtime.GOOS {
case "windows":
// DirectShow on Windows: -f dshow -i video="Camera Name"
inputName := cameraName
if inputName == "" {
// Fallback: try to detect first camera
devices := ListFFmpegDevices()
if len(devices) > 0 {
inputName = devices[0].Name
} else {
inputName = "Integrated Camera"
}
}
return []string{
"-f", "dshow",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("video=%s", inputName),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
default:
// AVFoundation on macOS: -f avfoundation -i "index:none"
return []string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("%d:none", cameraIndex),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
}
}
// readLoop continuously reads ffmpeg's stdout and extracts JPEG frames.
func (c *FFmpegCamera) readLoop() {
defer close(c.done)
reader := bufio.NewReaderSize(c.stdout, 1024*1024) // 1MB buffer
buf := make([]byte, 0, 512*1024) // 512KB initial frame buffer
inFrame := false
for {
b, err := reader.ReadByte()
if err != nil {
c.mu.Lock()
c.err = fmt.Errorf("ffmpeg stream ended: %w", err)
c.mu.Unlock()
return
}
if !inFrame {
// Look for SOI marker: 0xFF 0xD8
if b == 0xFF {
next, err := reader.ReadByte()
if err != nil {
c.mu.Lock()
c.err = fmt.Errorf("ffmpeg stream ended: %w", err)
c.mu.Unlock()
return
}
if next == 0xD8 {
// Start of JPEG
buf = buf[:0]
buf = append(buf, 0xFF, 0xD8)
inFrame = true
}
}
continue
}
// Inside a frame, collect bytes
buf = append(buf, b)
// Look for EOI marker: 0xFF 0xD9
if b == 0xD9 && len(buf) >= 2 && buf[len(buf)-2] == 0xFF {
// Complete JPEG frame
frame := make([]byte, len(buf))
copy(frame, buf)
c.mu.Lock()
c.latestFrame = frame
c.mu.Unlock()
// 通知 WaitForFirstFrame攝影機真的產出 frame 了(只觸發一次)。
c.firstFrameOnce.Do(func() { close(c.firstFrame) })
inFrame = false
}
}
}
// ReadFrame returns the most recently captured JPEG frame.
func (c *FFmpegCamera) ReadFrame() ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.err != nil {
return nil, c.err
}
if c.latestFrame == nil {
return nil, fmt.Errorf("no frame available yet")
}
// Return a copy to avoid data races
frame := make([]byte, len(c.latestFrame))
copy(frame, c.latestFrame)
return frame, nil
}
// Close stops the ffmpeg process and cleans up resources.
func (c *FFmpegCamera) Close() error {
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
_ = c.cmd.Wait()
}
<-c.done
return nil
}