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>
207 lines
5.6 KiB
Go
207 lines
5.6 KiB
Go
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_devices:v4l2 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
|
||
}
|