三層疊加根因: 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>
298 lines
8.4 KiB
Go
298 lines
8.4 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) 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 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 {
|
||
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
|
||
}
|