jim800121chen 17134e8eae feat(local-agent): camera 三平台 input device(ADR-020 WP-1/2)
camera 即時推論開不了根因:vendor decoder-only ffmpeg --disable-everything
沒 enable 任何 input device → macOS avfoundation 認不得。

- WP-1 macOS:ffmpeg rebuild 加 --enable-avfoundation + --enable-indev=avfoundation
  (--disable-autodetect 會靜默 disable、兩行要一起帶)。-list_devices 列出相機、
  +22KB、LGPL-safe。sha 已核對。
- WP-2:buildCaptureArgs 補 Linux v4l2 分支(原誤落 avfoundation default 必壞)+
  ListFFmpegDevices Linux glob /dev/video*。四路明確 case + 回歸鎖。

reviewer WP-1(0C/0M/0m) + WP-2(0C/0M/2m) 通過。Windows dshow / Linux v4l2 實機驗待機器。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:28:51 +08:00

332 lines
10 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), Windows (DirectShow) and Linux (Video4Linux2).
// 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 {
return buildCaptureArgsForOS(runtime.GOOS, cameraIndex, cameraName, width, height, framerate)
}
// buildCaptureArgsForOS 依指定 goos 組 ffmpeg capture args把平台判斷抽成參數以便
// table test 三平台輸出(不用 mock runtime.GOOS。三平台各用不同 input deviceindev
// - macOS → avfoundation-i "<index>:none"
// - Windows → dshow-i video="<name>"
// - Linux → v4l2-i /dev/video<N>
//
// 三平台後段皆接 -f image2pipe -vcodec mjpeg -q:v 5 -an -MJPEG pipe 架構共用。
// 對照見 ADR-020 §2.2 三平台 capture args 對照表。
func buildCaptureArgsForOS(goos string, cameraIndex int, cameraName string, width, height, framerate int) []string {
videoSize := fmt.Sprintf("%dx%d", width, height)
fps := fmt.Sprintf("%d", framerate)
// captureTail 是三平台共用的輸出段(把 raw frame 轉成 stdout 上的 MJPEG stream
captureTail := func(args []string) []string {
return append(args,
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
)
}
switch 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 captureTail([]string{
"-f", "dshow",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("video=%s", inputName),
})
case "linux":
// Video4Linux2 on Linux: -f v4l2 -i /dev/video<N>
// v4l2 以裝置節點路徑(非 index指定攝影機cameraIndex 對應 /dev/video<N>
// 這是 Linux 攝影機的慣例(/dev/video0 = 第一支。cameraName 在 Linux 未使用。
return captureTail([]string{
"-f", "v4l2",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("/dev/video%d", cameraIndex),
})
case "darwin":
// AVFoundation on macOS: -f avfoundation -i "index:none"
return captureTail([]string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("%d:none", cameraIndex),
})
default:
// 未知平台:明確落到 macOS 的 avfoundation 是錯的(原本 default 就是這個 bug
// 保留 avfoundation 作為最後手段,但這只是為了讓非三大平台不至於 build 失敗;
// 實務上未知平台的 camera 抓取本就不支援,會在 ffmpeg 層以「Unknown input format」失敗。
// 三大目標平台darwin/windows/linux都有明確 case、不會落到這裡。
return captureTail([]string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("%d:none", cameraIndex),
})
}
}
// 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
}