三層疊加根因: 1. 主根因:.app 缺 NSCameraUsageDescription → macOS TCC 靜默拒絕、不彈授權 視窗、綠燈不亮、ffmpeg avfoundation 抓不到 camera。 2. ffmpeg cmd.Start() 只要 fork 成功就回 nil → HTTP 200 假成功(攝影機沒真開)。 3. cmd.Stderr=nil 吞掉 ffmpeg 錯誤 + pipeline 靜默重試 → 極難查。 修法: - Info.plist + Info.dev.plist 加 NSCameraUsageDescription(wails build template, ad-hoc 簽名下只需 usage description,刻意不加 hardened runtime/entitlement 避免 TCC 直接拒絕) - ffmpeg WaitForFirstFrame:收到首張完整 JPEG frame 才回成功;早退/逾時回明確 錯誤 → camera/start 真的回非 200、前端看到真實失敗。timeout 25s(涵蓋首次 TCC 授權彈窗的使用者反應時間;已授權情境仍秒開) - stderr 導到有界 ringBuffer + log;pipeline camera 模式連續失敗 50 次結束 reviewer 通過(0C/0M/2m)。只動 camera 鏈路(影片/圖片/批次/tunnel 上傳不受影響)。 build/vet/test/gosec 過、4 新 camera 測試。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
3.5 KiB
Go
118 lines
3.5 KiB
Go
package camera
|
||
|
||
import (
|
||
"fmt"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// firstFrameTimeout 是 Open() 等待攝影機產出第一張 frame 的上限。
|
||
//
|
||
// 為何 25s(不是更短):首次開攝影機時 macOS 的 TCC 攝影機授權對話框是「同步阻擋」的,
|
||
// 逾時從進入 WaitForFirstFrame 就起算、會把使用者在彈窗前猶豫/反應的時間也算進去。
|
||
// 若太短(如 8s),首次點「開始推論」極可能在使用者還沒按下「允許」前就逾時失敗、
|
||
// 要按第二次才成功——首次體驗變成非預期失敗。25s 足夠涵蓋 TCC 授權彈窗的使用者反應時間。
|
||
//
|
||
// 為何拉長不會拖慢正常情境:授權彈窗期間 ffmpeg 進程仍存活、不會早退;且一拿到第一張
|
||
// frame 就立即返回,已授權情境仍是秒開,不會真的等滿 25s。只有「真的開不了」(權限被拒
|
||
// 後 ffmpeg exit / 裝置忙碌)時才會等到逾時——ffmpeg 早退會由 done 分支提前回錯誤,
|
||
// 真正等滿 25s 的僅剩「進程活著但持續不出 frame」的少數情況。
|
||
const firstFrameTimeout = 25 * time.Second
|
||
|
||
type CameraInfo struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Index int `json:"index"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
}
|
||
|
||
type Manager struct {
|
||
ffmpegCam *FFmpegCamera
|
||
isOpen bool
|
||
mu sync.Mutex
|
||
}
|
||
|
||
func NewManager() *Manager {
|
||
return &Manager{}
|
||
}
|
||
|
||
func (m *Manager) ListCameras() []CameraInfo {
|
||
// Try to detect real cameras via ffmpeg (auto-detects OS)
|
||
devices := ListFFmpegDevices()
|
||
if len(devices) > 0 {
|
||
return devices
|
||
}
|
||
|
||
if !DetectFFmpeg() {
|
||
fmt.Println("[WARN] ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
|
||
} else {
|
||
fmt.Println("[WARN] No video devices detected by ffmpeg")
|
||
}
|
||
return []CameraInfo{}
|
||
}
|
||
|
||
func (m *Manager) Open(index, width, height int) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
// Try real camera via ffmpeg
|
||
if !DetectFFmpeg() {
|
||
return fmt.Errorf("ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
|
||
}
|
||
|
||
cam, err := NewFFmpegCamera(index, width, height, 30)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to open camera (index=%d): %w", index, err)
|
||
}
|
||
|
||
// cmd.Start() 成功 ≠ 攝影機真的開起來。等第一張 frame 才算成功;否則清掉 ffmpeg
|
||
// 進程並回錯誤(含 ffmpeg stderr 尾端),讓 handler 回非 200、前端看到真實失敗,
|
||
// 而不是「200 假成功、畫面永遠空白」。
|
||
if err := cam.WaitForFirstFrame(firstFrameTimeout); err != nil {
|
||
if tail := cam.StderrTail(); tail != "" {
|
||
fmt.Printf("[ERROR] Camera open failed (index=%d): %v\n[ffmpeg stderr]\n%s\n", index, err, tail)
|
||
} else {
|
||
fmt.Printf("[ERROR] Camera open failed (index=%d): %v\n", index, err)
|
||
}
|
||
_ = cam.Close()
|
||
return fmt.Errorf("failed to open camera (index=%d): %w", index, err)
|
||
}
|
||
|
||
m.ffmpegCam = cam
|
||
m.isOpen = true
|
||
fmt.Printf("[INFO] Opened real camera (index=%d) via ffmpeg (first frame received)\n", index)
|
||
return nil
|
||
}
|
||
|
||
func (m *Manager) Close() error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
if m.ffmpegCam != nil {
|
||
_ = m.ffmpegCam.Close()
|
||
m.ffmpegCam = nil
|
||
}
|
||
m.isOpen = false
|
||
return nil
|
||
}
|
||
|
||
func (m *Manager) ReadFrame() ([]byte, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
if !m.isOpen {
|
||
return nil, fmt.Errorf("camera not open")
|
||
}
|
||
if m.ffmpegCam != nil {
|
||
return m.ffmpegCam.ReadFrame()
|
||
}
|
||
return nil, fmt.Errorf("no camera available")
|
||
}
|
||
|
||
func (m *Manager) IsOpen() bool {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.isOpen
|
||
}
|