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 取出 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 形式、略過 } 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;非該形式回 -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 }