從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
231 lines
5.0 KiB
Go
231 lines
5.0 KiB
Go
package camera
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"visiona-agent/server/internal/driver"
|
|
)
|
|
|
|
// 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
|
|
|
|
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 {
|
|
time.Sleep(100 * time.Millisecond)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|