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>
332 lines
10 KiB
Go
332 lines
10 KiB
Go
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 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.
|
||
// 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 device(indev):
|
||
// - 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
|
||
}
|