visionA/local-agent/server/internal/camera/ffmpeg_capture_args_test.go
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

193 lines
5.2 KiB
Go
Raw 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 (
"strings"
"testing"
)
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input deviceindev
// macOS→avfoundation、Windows→dshow、Linux→v4l2。這是 ADR-020 的核心修復:
// 原本 Linux 會誤落 default 分支用 avfoundation → 必壞。
func TestBuildCaptureArgsForOS(t *testing.T) {
const (
width = 640
height = 480
fps = 30
index = 0
)
tests := []struct {
name string
goos string
cameraName string
// wantInputFlag 是預期的 "-f <indev>" 值
wantIndev string
// wantInputArg 是 "-i" 後面的值
wantInputArg string
}{
{
name: "macOS uses avfoundation with index:none",
goos: "darwin",
wantIndev: "avfoundation",
wantInputArg: "0:none",
},
{
name: "Windows uses dshow with video=name",
goos: "windows",
cameraName: "Integrated Camera",
wantIndev: "dshow",
wantInputArg: "video=Integrated Camera",
},
{
name: "Linux uses v4l2 with /dev/video path",
goos: "linux",
wantIndev: "v4l2",
wantInputArg: "/dev/video0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := buildCaptureArgsForOS(tt.goos, index, tt.cameraName, width, height, fps)
gotIndev := valueAfterFlag(args, "-f") // 第一個 -f 是 input format
if gotIndev != tt.wantIndev {
t.Errorf("indev = %q, want %q\nargs: %v", gotIndev, tt.wantIndev, args)
}
gotInput := valueAfterFlag(args, "-i")
if gotInput != tt.wantInputArg {
t.Errorf("input = %q, want %q\nargs: %v", gotInput, tt.wantInputArg, args)
}
// 三平台後段皆須為 image2pipe / mjpegMJPEG pipe 架構共用。
if !containsSeq(args, "-f", "image2pipe") {
t.Errorf("missing image2pipe output, args: %v", args)
}
if !containsSeq(args, "-vcodec", "mjpeg") {
t.Errorf("missing mjpeg vcodec, args: %v", args)
}
if args[len(args)-1] != "-" {
t.Errorf("last arg should be stdout '-', got %q", args[len(args)-1])
}
})
}
}
// TestBuildCaptureArgsForOS_LinuxIndexToDevicePath 驗證 Linux 的 cameraIndex 正確
// 對應到 /dev/video<N> 節點路徑。
func TestBuildCaptureArgsForOS_LinuxIndexToDevicePath(t *testing.T) {
for _, idx := range []int{0, 1, 2, 10} {
args := buildCaptureArgsForOS("linux", idx, "", 640, 480, 30)
want := "/dev/video" + itoa(idx)
if got := valueAfterFlag(args, "-i"); got != want {
t.Errorf("index %d → input %q, want %q", idx, got, want)
}
}
}
// TestBuildCaptureArgsForOS_LinuxNotAVFoundation 是 ADR-020 的回歸鎖Linux 絕不能
// 用 avfoundationmacOS 專用)。若未來有人把 Linux case 拿掉、讓它落回 default
// 這個測試會抓到。
func TestBuildCaptureArgsForOS_LinuxNotAVFoundation(t *testing.T) {
args := buildCaptureArgsForOS("linux", 0, "", 640, 480, 30)
joined := strings.Join(args, " ")
if strings.Contains(joined, "avfoundation") {
t.Fatalf("Linux must NOT use avfoundation (macOS-only), args: %v", args)
}
if !strings.Contains(joined, "v4l2") {
t.Fatalf("Linux must use v4l2, args: %v", args)
}
}
// TestParseV4L2Devices 驗證 /dev/video* 路徑清單解析成 CameraInfo且依 index 數值
// 排序video10 排在 video2 之後、而非字典序)。
func TestParseV4L2Devices(t *testing.T) {
paths := []string{
"/dev/video10",
"/dev/video2",
"/dev/video0",
"/dev/video-not-a-number", // 應被略過
"/dev/videoX", // 應被略過
}
got := parseV4L2Devices(paths)
wantIndexes := []int{0, 2, 10}
if len(got) != len(wantIndexes) {
t.Fatalf("got %d devices, want %d: %+v", len(got), len(wantIndexes), got)
}
for i, want := range wantIndexes {
if got[i].Index != want {
t.Errorf("device[%d].Index = %d, want %d", i, got[i].Index, want)
}
if got[i].Name != "/dev/video"+itoa(want) {
t.Errorf("device[%d].Name = %q, want /dev/video%d", i, got[i].Name, want)
}
}
}
// TestParseV4L2Devices_Empty 驗證無裝置時回 nil/空。
func TestParseV4L2Devices_Empty(t *testing.T) {
if got := parseV4L2Devices(nil); len(got) != 0 {
t.Errorf("expected no devices, got %+v", got)
}
}
// TestV4L2Index 驗證從路徑取 index。
func TestV4L2Index(t *testing.T) {
cases := map[string]int{
"/dev/video0": 0,
"/dev/video12": 12,
"/dev/video": -1,
"/dev/videoab": -1,
"videoX": -1,
}
for path, want := range cases {
if got := v4l2Index(path); got != want {
t.Errorf("v4l2Index(%q) = %d, want %d", path, got, want)
}
}
}
// --- helpers ---
// valueAfterFlag 回傳 args 中第一個等於 flag 的元素的下一個值。
func valueAfterFlag(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
// containsSeq 判斷 args 是否包含連續的 a b 兩個元素。
func containsSeq(args []string, a, b string) bool {
for i := 0; i < len(args)-1; i++ {
if args[i] == a && args[i+1] == b {
return true
}
}
return false
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf []byte
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}