2026-05-13 13:44:33 -05:00
|
|
|
package webrtcpub
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
type Kind string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
KindDesktop Kind = "desktop"
|
2026-07-13 10:22:19 -05:00
|
|
|
Kindbackstage Kind = "backstage"
|
2026-05-13 20:19:21 -05:00
|
|
|
KindWebcam Kind = "webcam"
|
|
|
|
|
KindAudio Kind = "audio"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type VideoWriter interface {
|
2026-05-13 13:44:33 -05:00
|
|
|
WriteH264(nalu []byte, dur time.Duration) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
type AudioWriter interface {
|
|
|
|
|
WriteAudio(pcm []int16) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type writerEntry struct {
|
2026-07-12 23:14:29 -05:00
|
|
|
video *latestVideoWriter
|
2026-05-13 20:19:21 -05:00
|
|
|
audio AudioWriter
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 23:14:29 -05:00
|
|
|
type latestVideoWriter struct {
|
|
|
|
|
writer VideoWriter
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
pending []byte
|
|
|
|
|
dur time.Duration
|
|
|
|
|
closed bool
|
|
|
|
|
wake chan struct{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newLatestVideoWriter(writer VideoWriter) *latestVideoWriter {
|
|
|
|
|
queued := &latestVideoWriter{writer: writer, wake: make(chan struct{}, 1)}
|
|
|
|
|
go queued.run()
|
|
|
|
|
return queued
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *latestVideoWriter) enqueue(frame []byte, dur time.Duration) {
|
|
|
|
|
w.mu.Lock()
|
|
|
|
|
if w.closed {
|
|
|
|
|
w.mu.Unlock()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.pending = frame
|
|
|
|
|
w.dur = dur
|
|
|
|
|
w.mu.Unlock()
|
|
|
|
|
select {
|
|
|
|
|
case w.wake <- struct{}{}:
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *latestVideoWriter) close() {
|
|
|
|
|
w.mu.Lock()
|
|
|
|
|
w.closed = true
|
|
|
|
|
w.pending = nil
|
|
|
|
|
w.mu.Unlock()
|
|
|
|
|
select {
|
|
|
|
|
case w.wake <- struct{}{}:
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *latestVideoWriter) run() {
|
|
|
|
|
for range w.wake {
|
|
|
|
|
for {
|
|
|
|
|
w.mu.Lock()
|
|
|
|
|
if w.closed {
|
|
|
|
|
w.mu.Unlock()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
frame, dur := w.pending, w.dur
|
|
|
|
|
w.pending = nil
|
|
|
|
|
w.mu.Unlock()
|
|
|
|
|
if len(frame) == 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
_ = w.writer.WriteH264(frame, dur)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 13:44:33 -05:00
|
|
|
var (
|
|
|
|
|
writersMu sync.RWMutex
|
2026-05-13 20:19:21 -05:00
|
|
|
writers = map[string]map[string]writerEntry{} // kind → id → entry
|
2026-05-13 13:44:33 -05:00
|
|
|
)
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
func registerVideoWriter(kind Kind, id string, w VideoWriter) {
|
2026-05-13 13:44:33 -05:00
|
|
|
if id == "" || w == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writersMu.Lock()
|
2026-05-13 20:19:21 -05:00
|
|
|
bucket := writers[string(kind)]
|
|
|
|
|
if bucket == nil {
|
|
|
|
|
bucket = map[string]writerEntry{}
|
|
|
|
|
writers[string(kind)] = bucket
|
|
|
|
|
}
|
|
|
|
|
entry := bucket[id]
|
2026-07-12 23:14:29 -05:00
|
|
|
if entry.video != nil {
|
|
|
|
|
entry.video.close()
|
|
|
|
|
}
|
|
|
|
|
entry.video = newLatestVideoWriter(w)
|
2026-05-13 20:19:21 -05:00
|
|
|
bucket[id] = entry
|
2026-05-13 13:44:33 -05:00
|
|
|
writersMu.Unlock()
|
2026-06-06 16:08:28 -05:00
|
|
|
RequestKeyframe()
|
2026-05-13 13:44:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
func registerAudioWriter(kind Kind, id string, w AudioWriter) {
|
|
|
|
|
if id == "" || w == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writersMu.Lock()
|
|
|
|
|
bucket := writers[string(kind)]
|
|
|
|
|
if bucket == nil {
|
|
|
|
|
bucket = map[string]writerEntry{}
|
|
|
|
|
writers[string(kind)] = bucket
|
|
|
|
|
}
|
|
|
|
|
entry := bucket[id]
|
|
|
|
|
entry.audio = w
|
|
|
|
|
bucket[id] = entry
|
|
|
|
|
writersMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func unregisterWriter(kind Kind, id string) {
|
2026-05-13 13:44:33 -05:00
|
|
|
if id == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writersMu.Lock()
|
2026-05-13 20:19:21 -05:00
|
|
|
if bucket, ok := writers[string(kind)]; ok {
|
2026-07-12 23:14:29 -05:00
|
|
|
if entry, exists := bucket[id]; exists && entry.video != nil {
|
|
|
|
|
entry.video.close()
|
|
|
|
|
}
|
2026-05-13 20:19:21 -05:00
|
|
|
delete(bucket, id)
|
|
|
|
|
if len(bucket) == 0 {
|
|
|
|
|
delete(writers, string(kind))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 13:44:33 -05:00
|
|
|
writersMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
// IsActive reports whether any writer of the given kind is registered.
|
|
|
|
|
// Callers in capture loops use this as a cheap "should I divert this frame
|
|
|
|
|
// to WebRTC?" check before doing more expensive work.
|
|
|
|
|
func IsActive(kind Kind) bool {
|
2026-05-13 13:44:33 -05:00
|
|
|
writersMu.RLock()
|
|
|
|
|
defer writersMu.RUnlock()
|
2026-05-13 20:19:21 -05:00
|
|
|
return len(writers[string(kind)]) > 0
|
2026-05-13 13:44:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var keyframeWanted atomic.Bool
|
|
|
|
|
|
|
|
|
|
func RequestKeyframe() {
|
|
|
|
|
keyframeWanted.Store(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ConsumeKeyframeRequest() bool {
|
|
|
|
|
return keyframeWanted.Swap(false)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:19:21 -05:00
|
|
|
func WriteH264(kind Kind, nalu []byte, dur time.Duration) error {
|
2026-05-13 13:44:33 -05:00
|
|
|
if len(nalu) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-12 23:14:29 -05:00
|
|
|
frame := append([]byte(nil), nalu...)
|
2026-05-13 13:44:33 -05:00
|
|
|
writersMu.RLock()
|
2026-05-13 20:19:21 -05:00
|
|
|
bucket := writers[string(kind)]
|
2026-07-12 23:14:29 -05:00
|
|
|
targets := make([]*latestVideoWriter, 0, len(bucket))
|
2026-05-13 20:19:21 -05:00
|
|
|
for _, w := range bucket {
|
|
|
|
|
if w.video != nil {
|
2026-07-12 23:14:29 -05:00
|
|
|
targets = append(targets, w.video)
|
2026-05-13 20:19:21 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 23:14:29 -05:00
|
|
|
writersMu.RUnlock()
|
|
|
|
|
for _, target := range targets {
|
|
|
|
|
target.enqueue(frame, dur)
|
|
|
|
|
}
|
2026-05-13 20:19:21 -05:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func WriteAudio(kind Kind, pcm []int16) error {
|
|
|
|
|
if len(pcm) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
writersMu.RLock()
|
|
|
|
|
bucket := writers[string(kind)]
|
2026-07-12 23:14:29 -05:00
|
|
|
targets := make([]AudioWriter, 0, len(bucket))
|
2026-05-13 20:19:21 -05:00
|
|
|
for _, w := range bucket {
|
|
|
|
|
if w.audio != nil {
|
2026-07-12 23:14:29 -05:00
|
|
|
targets = append(targets, w.audio)
|
2026-05-13 20:19:21 -05:00
|
|
|
}
|
2026-05-13 13:44:33 -05:00
|
|
|
}
|
2026-07-12 23:14:29 -05:00
|
|
|
writersMu.RUnlock()
|
|
|
|
|
for _, target := range targets {
|
|
|
|
|
_ = target.WriteAudio(pcm)
|
|
|
|
|
}
|
2026-05-13 13:44:33 -05:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Options struct {
|
2026-05-13 20:19:21 -05:00
|
|
|
// (e.g. https://server:5173/api/webrtc/agents/abc/desktop/whip).
|
2026-05-13 13:44:33 -05:00
|
|
|
WhipURL string
|
|
|
|
|
// PublishToken is the bearer token issued by the server.
|
|
|
|
|
PublishToken string
|
|
|
|
|
// TLSInsecureSkipVerify mirrors the agent's existing TLS config.
|
|
|
|
|
TLSInsecureSkipVerify bool
|
|
|
|
|
// TLSCAPath is an optional custom CA bundle.
|
|
|
|
|
TLSCAPath string
|
2026-05-13 20:19:21 -05:00
|
|
|
// HasVideo / HasAudio select which tracks to add to the peer connection.
|
|
|
|
|
HasVideo bool
|
|
|
|
|
HasAudio bool
|
2026-05-13 13:44:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ICECandidate struct {
|
|
|
|
|
Candidate string `msgpack:"candidate"`
|
|
|
|
|
SDPMid string `msgpack:"sdpMid"`
|
|
|
|
|
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type P2POfferCallbacks struct {
|
|
|
|
|
OnICE func(c ICECandidate)
|
|
|
|
|
OnClose func()
|
|
|
|
|
}
|