avfoundation 修好後實測發現兩層問題(camera 三層根因的後兩層): - 改動1(args):darwin buildCaptureArgs 加 -pixel_format uyvy422(放 -i 前)。 攝影機只支援 uyvy422/yuyv422/nv12(非 yuv420p),不指定→協商失敗 I/O error。 - 改動2(ffmpeg):Makefile decoder 白名單加 rawvideo。攝影機吐 raw uyvy422 codec=rawvideo,轉 mjpeg 前要解,精簡 build 漏了→ no decoder found→EOF。 本機真攝影機端到端實測:/tmp/cam_ok.jpg = JPEG 640x480(rawvideo native→ mjpeg native 全鏈通、真的出畫面)。reviewer 通過(0C/0M)。只影響 camera (video/image/batch 解既有壓縮檔、不碰 rawvideo/avfoundation)。只改 macOS (Windows/Linux full build 內建 rawvideo;pixel_format 待實機 follow-up)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
6.2 KiB
Go
220 lines
6.2 KiB
Go
package camera
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input device(indev):
|
||
// 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
|
||
// wantPixelFormat 若非空,斷言 "-pixel_format <值>" 存在且位於 -i 之前(input 選項)。
|
||
wantPixelFormat string
|
||
}{
|
||
{
|
||
name: "macOS uses avfoundation with index:none and uyvy422 input pixel format",
|
||
goos: "darwin",
|
||
wantIndev: "avfoundation",
|
||
wantInputArg: "0:none",
|
||
wantPixelFormat: "uyvy422",
|
||
},
|
||
{
|
||
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)
|
||
}
|
||
|
||
// macOS avfoundation 必須帶 -pixel_format uyvy422,且它是 input 選項、
|
||
// 必須位於 -i 之前(放到 -i 之後會被當 output 轉碼目標、不解決 input 協商)。
|
||
if tt.wantPixelFormat != "" {
|
||
gotPixFmt := valueAfterFlag(args, "-pixel_format")
|
||
if gotPixFmt != tt.wantPixelFormat {
|
||
t.Errorf("pixel_format = %q, want %q\nargs: %v", gotPixFmt, tt.wantPixelFormat, args)
|
||
}
|
||
pixIdx := indexOfFlag(args, "-pixel_format")
|
||
iIdx := indexOfFlag(args, "-i")
|
||
if pixIdx < 0 || iIdx < 0 || pixIdx > iIdx {
|
||
t.Errorf("-pixel_format (idx %d) must appear before -i (idx %d)\nargs: %v", pixIdx, iIdx, args)
|
||
}
|
||
}
|
||
|
||
// 三平台後段皆須為 image2pipe / mjpeg,MJPEG 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 絕不能
|
||
// 用 avfoundation(macOS 專用)。若未來有人把 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 ""
|
||
}
|
||
|
||
// indexOfFlag 回傳 args 中第一個等於 flag 的元素的索引;不存在回 -1。
|
||
func indexOfFlag(args []string, flag string) int {
|
||
for i, a := range args {
|
||
if a == flag {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// 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)
|
||
}
|