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

253 lines
6.1 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 (
"context"
"fmt"
"time"
"visiona-agent/server/internal/driver"
)
// maxConsecutiveReadErrors 是 camera 模式下連續讀 frame 失敗的容忍上限。
//
// 原本讀失敗只 sleep 100ms 後無限重試、完全靜默——攝影機中途斷線 / 從未出 frame 時
// 前端只會看到永遠空白、後端也沒任何跡象。改成連續失敗超過上限就 log + 結束 pipeline
// 讓失敗看得見。100ms * 50 ≈ 5s足夠容忍偶發抖動又不會無限卡住。
const maxConsecutiveReadErrors = 50
// SourceType identifies the kind of frame source used in the pipeline.
type SourceType string
const (
SourceCamera SourceType = "camera"
SourceImage SourceType = "image"
SourceVideo SourceType = "video"
SourceBatchImage SourceType = "batch_image"
)
type InferencePipeline struct {
source FrameSource
sourceType SourceType
device driver.DeviceDriver
frameCh chan<- []byte
resultCh chan<- *driver.InferenceResult
cancel context.CancelFunc
doneCh chan struct{}
frameOffset int // starting frame index (non-zero after seek)
}
func NewInferencePipeline(
source FrameSource,
sourceType SourceType,
device driver.DeviceDriver,
frameCh chan<- []byte,
resultCh chan<- *driver.InferenceResult,
) *InferencePipeline {
return &InferencePipeline{
source: source,
sourceType: sourceType,
device: device,
frameCh: frameCh,
resultCh: resultCh,
doneCh: make(chan struct{}),
}
}
// NewInferencePipelineWithOffset creates a pipeline with a frame offset (used after seek).
func NewInferencePipelineWithOffset(
source FrameSource,
sourceType SourceType,
device driver.DeviceDriver,
frameCh chan<- []byte,
resultCh chan<- *driver.InferenceResult,
frameOffset int,
) *InferencePipeline {
return &InferencePipeline{
source: source,
sourceType: sourceType,
device: device,
frameCh: frameCh,
resultCh: resultCh,
doneCh: make(chan struct{}),
frameOffset: frameOffset,
}
}
func (p *InferencePipeline) Start() {
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.run(ctx)
}
func (p *InferencePipeline) Stop() {
if p.cancel != nil {
p.cancel()
}
}
// Done returns a channel that closes when the pipeline finishes.
// For camera mode this only closes on Stop(); for image/video it
// closes when the source is exhausted.
func (p *InferencePipeline) Done() <-chan struct{} {
return p.doneCh
}
func (p *InferencePipeline) run(ctx context.Context) {
defer close(p.doneCh)
targetInterval := time.Second / 15 // 15 FPS
inferenceRan := false // for image mode: only run inference once
frameIndex := 0 // video frame counter
consecutiveReadErrors := 0 // camera 模式:連續讀 frame 失敗計數
for {
select {
case <-ctx.Done():
return
default:
}
start := time.Now()
var jpegFrame []byte
var readErr error
// Video mode: ReadFrame blocks on channel, need to respect ctx cancel
if p.sourceType == SourceVideo {
vs := p.source.(*VideoSource)
select {
case <-ctx.Done():
return
case frame, ok := <-vs.frameCh:
if !ok {
return // all frames consumed
}
jpegFrame = frame
}
} else {
jpegFrame, readErr = p.source.ReadFrame()
if readErr != nil {
// camera 模式:不再無限靜默重試。連續失敗超過上限就 log + 結束,
// 避免攝影機從未出 frame / 中途斷線時前端永遠空白、後端毫無跡象。
// 非 camera 來源理論上不會走到這image/batch 有各自路徑)維持原重試行為。
if p.sourceType == SourceCamera {
consecutiveReadErrors++
if consecutiveReadErrors >= maxConsecutiveReadErrors {
fmt.Printf("[ERROR] camera pipeline aborted after %d consecutive read errors: %v\n",
consecutiveReadErrors, readErr)
return
}
}
time.Sleep(100 * time.Millisecond)
continue
}
// 成功讀到 frame重置連續失敗計數。
consecutiveReadErrors = 0
}
// Send to MJPEG stream
select {
case p.frameCh <- jpegFrame:
default:
}
// Batch image mode: process each image sequentially, then advance.
if p.sourceType == SourceBatchImage {
mis := p.source.(*MultiImageSource)
for {
select {
case <-ctx.Done():
return
default:
}
frame, err := mis.ReadFrame()
if err != nil {
return
}
// Send current frame to MJPEG
select {
case p.frameCh <- frame:
default:
}
// Run inference on this image
result, inferErr := p.device.RunInference(frame)
if inferErr == nil {
entry := mis.CurrentEntry()
result.ImageIndex = mis.CurrentIndex()
result.TotalImages = mis.TotalImages()
result.Filename = entry.Filename
select {
case p.resultCh <- result:
default:
}
}
// Move to next image
if !mis.Advance() {
// Keep sending last frame for late-connecting MJPEG clients (~2s)
for i := 0; i < 30; i++ {
select {
case <-ctx.Done():
return
default:
}
select {
case p.frameCh <- frame:
default:
}
time.Sleep(time.Second / 15)
}
return
}
}
}
// Image mode: only run inference once, then keep sending
// the same frame to MJPEG so late-connecting clients can see it.
if p.sourceType == SourceImage {
if !inferenceRan {
inferenceRan = true
result, err := p.device.RunInference(jpegFrame)
if err == nil {
select {
case p.resultCh <- result:
default:
}
}
}
elapsed := time.Since(start)
if elapsed < targetInterval {
time.Sleep(targetInterval - elapsed)
}
continue
}
// Camera / Video mode: run inference every frame
result, err := p.device.RunInference(jpegFrame)
if err != nil {
continue
}
// Video mode: attach frame progress
if p.sourceType == SourceVideo {
result.FrameIndex = p.frameOffset + frameIndex
frameIndex++
vs := p.source.(*VideoSource)
result.TotalFrames = vs.TotalFrames()
}
select {
case p.resultCh <- result:
default:
}
elapsed := time.Since(start)
if elapsed < targetInterval {
time.Sleep(targetInterval - elapsed)
}
}
}