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 ":none") // - Windows → dshow(-i video="") // - Linux → v4l2(-i /dev/video) // // 三平台後段皆接 -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 // v4l2 以裝置節點路徑(非 index)指定攝影機;cameraIndex 對應 /dev/video, // 這是 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 -pixel_format uyvy422 -i "index:none" // // 必須明確指定攝影機支援的 input pixel format。攝影機只支援 // uyvy422/yuyv422/nv12/0rgb/bgr0;不指定時 avfoundation 會嘗試 yuv420p // → 協商失敗(Input/output error)。指定 uyvy422(攝影機原生格式)→ 攝影機成功打開。 // // 位置關鍵:-pixel_format 是 input 選項,必須放在 -i 之前(跟 -framerate/ // -video_size 同段)。放到 -i 之後會被當成 output 轉碼目標、不解決 input 協商。 // 對照見根因文件 .autoflow/05-implementation/camera-pixel-format-rootcause.md。 return captureTail([]string{ "-f", "avfoundation", "-pixel_format", "uyvy422", "-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 }