jim800121chen e4d27594d6 fix(local-agent): 修 camera 即時推論開不了(macOS 攝影機權限 + 假成功)
三層疊加根因:
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>
2026-08-02 03:48:30 +08:00

118 lines
3.5 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 (
"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
}