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

207 lines
5.6 KiB
Go
Raw Permalink 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 (
"fmt"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
)
// DetectFFmpeg checks if ffmpeg is available on the system.
func DetectFFmpeg() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
// ListFFmpegDevices detects available video devices using ffmpeg.
// Automatically selects the correct capture framework for the current OS:
// - macOS: AVFoundation
// - Windows: DirectShow (dshow)
// - Linux: Video4Linux2 (v4l2, enumerated from /dev/video*)
func ListFFmpegDevices() []CameraInfo {
// Linux 走 /dev/video* 列舉、不依賴 ffmpeg binary-list_devices 在部分 v4l2 build
// 不穩定),故不要求 DetectFFmpeg 也能列出裝置節點;實際抓 frame 時才需要 ffmpeg。
if runtime.GOOS == "linux" {
return listV4L2Devices()
}
if !DetectFFmpeg() {
return nil
}
switch runtime.GOOS {
case "windows":
return listDShowDevices()
default:
return listAVFoundationDevices()
}
}
// --- macOS (AVFoundation) ---
func listAVFoundationDevices() []CameraInfo {
cmd := exec.Command("ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", "")
output, _ := cmd.CombinedOutput()
return parseAVFoundationOutput(string(output))
}
// parseAVFoundationOutput parses ffmpeg AVFoundation device listing.
// Example:
//
// [AVFoundation indev @ 0x...] AVFoundation video devices:
// [AVFoundation indev @ 0x...] [0] FaceTime HD Camera
// [AVFoundation indev @ 0x...] [1] Capture screen 0
// [AVFoundation indev @ 0x...] AVFoundation audio devices:
func parseAVFoundationOutput(output string) []CameraInfo {
var cameras []CameraInfo
lines := strings.Split(output, "\n")
deviceRe := regexp.MustCompile(`\[AVFoundation[^\]]*\]\s*\[(\d+)\]\s*(.+)`)
inVideoSection := false
for _, line := range lines {
if strings.Contains(line, "AVFoundation video devices") {
inVideoSection = true
continue
}
if strings.Contains(line, "AVFoundation audio devices") {
break
}
if !inVideoSection {
continue
}
matches := deviceRe.FindStringSubmatch(line)
if len(matches) == 3 {
index, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
name := strings.TrimSpace(matches[2])
// Skip screen capture devices
if strings.Contains(strings.ToLower(name), "capture screen") {
continue
}
cameras = append(cameras, CameraInfo{
ID: fmt.Sprintf("cam-%d", index),
Name: name,
Index: index,
Width: 640,
Height: 480,
})
}
}
return cameras
}
// --- Windows (DirectShow) ---
func listDShowDevices() []CameraInfo {
cmd := exec.Command("ffmpeg", "-f", "dshow", "-list_devices", "true", "-i", "dummy")
output, _ := cmd.CombinedOutput()
return parseDShowOutput(string(output))
}
// parseDShowOutput parses ffmpeg DirectShow device listing.
// Example:
//
// [dshow @ 0x...] "Integrated Camera" (video)
// [dshow @ 0x...] Alternative name "@device_pnp_..."
// [dshow @ 0x...] "Microphone" (audio)
func parseDShowOutput(output string) []CameraInfo {
var cameras []CameraInfo
lines := strings.Split(output, "\n")
// Match: [dshow @ 0x...] "Device Name" (video)
deviceRe := regexp.MustCompile(`\[dshow[^\]]*\]\s*"([^"]+)"\s*\(video\)`)
index := 0
for _, line := range lines {
matches := deviceRe.FindStringSubmatch(line)
if len(matches) == 2 {
name := strings.TrimSpace(matches[1])
cameras = append(cameras, CameraInfo{
ID: fmt.Sprintf("cam-%d", index),
Name: name,
Index: index,
Width: 640,
Height: 480,
})
index++
}
}
return cameras
}
// --- Linux (Video4Linux2) ---
// v4l2DeviceGlob 是列舉 v4l2 攝影機節點的 glob pattern。抽成變數讓測試可覆寫成
// 假的目錄,不依賴實機 /dev。
var v4l2DeviceGlob = "/dev/video*"
// v4l2DeviceIndexRe 從 /dev/video<N> 取出 index N。
var v4l2DeviceIndexRe = regexp.MustCompile(`video(\d+)$`)
// listV4L2Devices 列舉 Linux 上的 v4l2 攝影機節點(/dev/video*)。
//
// 為何用 glob 而非 ffmpeg -list_devicesv4l2 indev 的 -list_devices 支援度依 ffmpeg
// 版本而異、部分 build 不輸出可解析清單;直接列舉 /dev/video* 節點是最穩定的跨版本做法。
//
// 注意:/dev/video* 也包含非攝影機的 V4L2 節點(如 metadata / output device這裡先
// 全數列出、由使用者選擇;抓 frame 失敗會在 ffmpeg 層以明確錯誤浮現camera 修 bug 後不再吞錯)。
func listV4L2Devices() []CameraInfo {
matches, err := filepath.Glob(v4l2DeviceGlob)
if err != nil {
return nil
}
return parseV4L2Devices(matches)
}
// parseV4L2Devices 把 /dev/video* 路徑清單轉成 CameraInfo依 index 排序)。
// 抽出來讓測試可直接餵路徑清單、不碰檔案系統。
func parseV4L2Devices(paths []string) []CameraInfo {
// 依裝置 index 數值排序Glob 回傳為字典序video10 會排在 video2 前,需正規化)。
sorted := append([]string(nil), paths...)
sort.Slice(sorted, func(i, j int) bool {
return v4l2Index(sorted[i]) < v4l2Index(sorted[j])
})
var cameras []CameraInfo
for _, path := range sorted {
idx := v4l2Index(path)
if idx < 0 {
continue // 不是 /dev/video<N> 形式、略過
}
cameras = append(cameras, CameraInfo{
ID: fmt.Sprintf("cam-%d", idx),
Name: path, // Linux 以裝置節點路徑作為名稱buildCaptureArgs 用 index 組回路徑)
Index: idx,
Width: 640,
Height: 480,
})
}
return cameras
}
// v4l2Index 從 /dev/video<N> 取出 N非該形式回 -1。
func v4l2Index(path string) int {
m := v4l2DeviceIndexRe.FindStringSubmatch(path)
if len(m) != 2 {
return -1
}
n, err := strconv.Atoi(m[1])
if err != nil {
return -1
}
return n
}