visionA/local-agent/server/internal/camera/ffmpeg_capture_args_test.go
jim800121chen e27d8e3bd2 fix(local-agent): camera pixel format + rawvideo decoder(實測出畫面)
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>
2026-08-03 00:53:35 +08:00

220 lines
6.2 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 (
"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
// 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 / 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 ""
}
// 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)
}