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 }