This commit is contained in:
2026-04-22 23:35:59 +01:00
parent df6c33bc3a
commit bee7869af4
116 changed files with 13552 additions and 0 deletions

View File

@@ -0,0 +1,335 @@
package ingest
import (
"bytes"
"context"
"fmt"
"time"
"github.com/go-gst/go-gst/gst"
"github.com/go-gst/go-gst/gst/app"
)
const (
liveSampleTimeout = 200 * time.Millisecond
liveSampleBufferSize = 5
)
type subscribeReq struct {
BufferSize int
Result chan<- subscribeRes
}
type subscribeRes struct {
Id int
Stream <-chan *bytes.Reader
Err error
}
type unsubscribeReq struct {
Id int
}
// addLiveBranch populates the pipeline with:
//
// [vTee] ---> queue ---> valve ---> mp4mux ---> appsink
// [aTee] ---> queue ---> valve -----^
func (p *CameraPipeline) addLiveBranch(branchTimeout time.Duration) error {
if p.vTee == nil {
return fmt.Errorf("video tee not initialized")
}
if p.aTee == nil {
return fmt.Errorf("audio tee not initialized")
}
vPad := p.vTee.GetRequestPad("src_%u")
if vPad == nil {
return fmt.Errorf("failed to get request pad from video tee")
}
aPad := p.aTee.GetRequestPad("src_%u")
if aPad == nil {
return fmt.Errorf("failed to get request pad from audio tee")
}
vQueue, err := gst.NewElement("queue")
if err != nil {
return fmt.Errorf("failed to create video queue element: %w", err)
}
if err := setGlibValueProperty(vQueue, "leaky", 2); err != nil { // downstream
return fmt.Errorf("failed to set video queue leaky property: %w", err)
}
if err := vQueue.SetProperty("max-size-time", uint64(2*time.Second.Nanoseconds())); err != nil {
return fmt.Errorf("failed to set video queue max-size-time property: %w", err)
}
vValve, err := gst.NewElement("valve")
if err != nil {
return fmt.Errorf("failed to create video valve element: %w", err)
}
if err := vValve.SetProperty("drop", true); err != nil {
return fmt.Errorf("failed to set video valve drop property: %w", err)
}
if err := setGlibValueProperty(vValve, "drop-mode", 1); err != nil { // forward-sticky
return fmt.Errorf("failed to set video valve drop-mode property: %w", err)
}
aQueue, err := gst.NewElement("queue")
if err != nil {
return fmt.Errorf("failed to create audio queue element: %w", err)
}
if err := setGlibValueProperty(aQueue, "leaky", 2); err != nil { // downstream
return fmt.Errorf("failed to set audio queue leaky property: %w", err)
}
if err := aQueue.SetProperty("max-size-time", uint64(2*time.Second.Nanoseconds())); err != nil {
return fmt.Errorf("failed to set audio queue max-size-time property: %w", err)
}
aValve, err := gst.NewElement("valve")
if err != nil {
return fmt.Errorf("failed to create audio valve element: %w", err)
}
if err := aValve.SetProperty("drop", true); err != nil {
return fmt.Errorf("failed to set audio valve drop property: %w", err)
}
if err := setGlibValueProperty(aValve, "drop-mode", 1); err != nil { // forward-sticky
return fmt.Errorf("failed to set audio valve drop-mode property: %w", err)
}
mp4Mux, err := gst.NewElement("mp4mux")
if err != nil {
return fmt.Errorf("failed to create mp4mux element: %w", err)
}
if err := mp4Mux.SetProperty("fragment-duration", uint(100*time.Millisecond.Nanoseconds())); err != nil {
return fmt.Errorf("failed to set mp4mux fragment-duration property: %w", err)
}
if err := setGlibValueProperty(mp4Mux, "fragment-mode", 0); err != nil { // dash-or-mss
return fmt.Errorf("failed to set mp4mux fragment-mode property: %w", err)
}
if err := mp4Mux.SetProperty("interleave-time", uint64(0)); err != nil {
return fmt.Errorf("failed to set mp4mux interleave-time property: %w", err)
}
if err := mp4Mux.SetProperty("latency", uint64(0)); err != nil {
return fmt.Errorf("failed to set mp4mux latency property: %w", err)
}
appSink, err := gst.NewElement("appsink")
if err != nil {
return fmt.Errorf("failed to create appsink element: %w", err)
}
if err := appSink.SetProperty("max-buffers", uint(5)); err != nil {
return fmt.Errorf("failed to set appsink max-buffers property: %w", err)
}
if err := appSink.SetProperty("sync", false); err != nil {
return fmt.Errorf("failed to set appsink sync property: %w", err)
}
if err := appSink.SetProperty("async", false); err != nil {
return fmt.Errorf("failed to set appsink async property: %w", err)
}
if err := p.pipeline.AddMany(vQueue, vValve, aQueue, aValve, mp4Mux, appSink); err != nil {
return fmt.Errorf("failed to add live branch elements to pipeline: %w", err)
}
if res := vPad.Link(vQueue.GetStaticPad("sink")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link video tee to video queue: %s", res)
}
if res := aPad.Link(aQueue.GetStaticPad("sink")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link audio tee to audio queue: %s", res)
}
if res := vQueue.GetStaticPad("src").Link(vValve.GetStaticPad("sink")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link video queue to video valve: %s", res)
}
if res := aQueue.GetStaticPad("src").Link(aValve.GetStaticPad("sink")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link audio queue to audio valve: %s", res)
}
if res := vValve.GetStaticPad("src").Link(mp4Mux.GetRequestPad("video_%u")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link video valve to mp4mux: %s", res)
}
if res := aValve.GetStaticPad("src").Link(mp4Mux.GetRequestPad("audio_%u")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link audio valve to mp4mux: %s", res)
}
if res := mp4Mux.GetStaticPad("src").Link(appSink.GetStaticPad("sink")); res != gst.PadLinkOK {
return fmt.Errorf("failed to link mp4mux to appsink: %s", res)
}
p.liveVValve = vValve
p.liveAValve = aValve
p.liveSink = app.SinkFromElement(appSink)
p.liveTimeout = branchTimeout
p.liveSubscribe = make(chan subscribeReq)
p.liveUnsubscribe = make(chan unsubscribeReq)
return nil
}
func (p *CameraPipeline) liveSampler(ctx context.Context, result chan<- []byte) {
SamplerLoop:
for {
sample := p.liveSink.TryPullSample(gst.ClockTime(liveSampleTimeout.Nanoseconds()))
if sample == nil {
select {
case <-ctx.Done():
return
default:
p.log.Debug("Live sampler timed out waiting for sample")
continue SamplerLoop
}
}
buf := sample.GetBuffer()
if buf == nil {
p.log.Warn("Received nil buffer from live sink")
continue SamplerLoop
}
data, err := gstBufferToBytes(buf)
if err != nil {
p.log.Warn("Failed to convert buffer to bytes")
continue SamplerLoop
}
select {
case <-ctx.Done():
return
case result <- data:
}
}
}
func (p *CameraPipeline) liveManager(ctx context.Context) {
active := false
var initSeg []byte
subscribers := &subscribersManager{m: make(map[int]subscriber), nextID: 0}
samples := make(chan []byte, liveSampleBufferSize)
samplerCtx, samplerCancel := context.WithCancel(ctx)
defer func() {
subscribers.Close()
samplerCancel()
}()
defer p.log.Debug("Live branch manager exiting")
var start time.Time
var numSamples int
for {
var timeout <-chan time.Time
if active && subscribers.len() == 0 {
timeout = time.After(p.liveTimeout)
}
select {
case <-ctx.Done():
samplerCancel()
p.log.Info("Context cancelled", "numSamples", numSamples, "duration", time.Since(start))
return
case req := <-p.liveSubscribe:
if !active {
p.log.Info("Activating live branch")
if err := p.liveVValve.SetProperty("drop", false); err != nil {
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate video valve: %w", err)}
continue
}
if err := p.liveAValve.SetProperty("drop", false); err != nil {
if rollbackErr := p.liveVValve.SetProperty("drop", true); rollbackErr != nil { // try to rollback
p.log.Warn("Failed to rollback video valve state after audio valve activation failure", "error", rollbackErr)
}
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate audio valve: %w", err)}
continue
}
go p.liveSampler(samplerCtx, samples)
active = true
start = time.Now()
}
stream := make(chan *bytes.Reader, req.BufferSize)
id := subscribers.AddSubscriber(stream)
if initSeg != nil {
if !subscribers.SendOrUnsubscribe(id, initSeg) {
req.Result <- subscribeRes{Err: fmt.Errorf("subscriber missed init segment")}
continue
}
}
req.Result <- subscribeRes{Id: id, Stream: stream}
case req := <-p.liveUnsubscribe:
subscribers.RemoveSubscriber(req.Id)
case data := <-samples:
// TODO : init segment
p.log.Debug("got video sample")
numSamples += 1
subscribers.Broadcast(data)
case <-timeout:
p.log.Info("Deactivating live branch due to inactivity")
if err := p.liveVValve.SetProperty("drop", true); err != nil {
p.log.Warn("Failed to deactivate video valve: %w", err)
}
if err := p.liveAValve.SetProperty("drop", true); err != nil {
p.log.Warn("Failed to deactivate audio valve: %w", err)
}
samplerCancel()
samplerCtx, samplerCancel = context.WithCancel(ctx)
active = false
}
}
}
func (p *CameraPipeline) LiveSubscribe(timeout time.Duration, bufferSize int) (id int, stream <-chan *bytes.Reader, err error) {
result := make(chan subscribeRes)
req := subscribeReq{
BufferSize: bufferSize,
Result: result,
}
select {
case <-time.After(timeout):
return 0, nil, fmt.Errorf("live subscribe request timed out")
case p.liveSubscribe <- req:
}
res := <-result
return res.Id, res.Stream, res.Err
}
func (p *CameraPipeline) LiveUnsubscribe(id int) {
p.liveUnsubscribe <- unsubscribeReq{Id: id}
}
type subscriber struct {
Stream chan<- *bytes.Reader
}
type subscribersManager struct {
m map[int]subscriber
nextID int
}
func (sm subscribersManager) len() int {
return len(sm.m)
}
func (sm *subscribersManager) AddSubscriber(stream chan<- *bytes.Reader) int {
id := sm.nextID
sm.m[id] = subscriber{Stream: stream}
sm.nextID++
return id
}
func (sm *subscribersManager) RemoveSubscriber(id int) {
if sub, ok := sm.m[id]; ok {
close(sub.Stream)
delete(sm.m, id)
}
}
func (sm *subscribersManager) SendOrUnsubscribe(id int, data []byte) bool {
if sub, ok := sm.m[id]; ok {
select {
case sub.Stream <- bytes.NewReader(data):
return true
default:
sm.RemoveSubscriber(id)
return false
}
}
return false
}
func (sm *subscribersManager) Close() {
for id := range sm.m {
sm.RemoveSubscriber(id)
}
}
func (sm *subscribersManager) Broadcast(data []byte) {
for id := range sm.m {
sm.SendOrUnsubscribe(id, data)
}
}

View File

@@ -0,0 +1,111 @@
package ingest
import (
"fmt"
"github.com/go-gst/go-gst/gst"
)
// addStatsBranch populates the pipeline with:
//
// [vTee] ---> fakesink
//
// StreamStats are extracted from the fakesink's handovers (using the sink pad caps) and sent to the returned channel.
// If the channel has no received or is full, the stats are dropped to avoid blocking the pipeline. The channel is never closed.
func (p *CameraPipeline) addStatsBranch() (<-chan StreamStats, error) {
if p.vTee == nil {
return nil, fmt.Errorf("video tee not initialized")
}
vPad := p.vTee.GetRequestPad("src_%u")
if vPad == nil {
return nil, fmt.Errorf("failed to get request pad from video tee")
}
statsChan := make(chan StreamStats)
fakeSink, err := gst.NewElement("fakesink")
if err != nil {
return nil, fmt.Errorf("failed to create fakesink element: %w", err)
}
if err := fakeSink.SetProperty("signal-handoffs", true); err != nil {
return nil, fmt.Errorf("failed to set fakesink signal-handoffs property: %w", err)
}
if err := p.pipeline.Add(fakeSink); err != nil {
return nil, fmt.Errorf("failed to add fakesink to pipeline: %w", err)
}
if res := vPad.Link(fakeSink.GetStaticPad("sink")); res != gst.PadLinkOK {
return nil, fmt.Errorf("failed to link video tee to fakesink: %s", res)
}
fakeSink.Connect("handoff", func(element *gst.Element, _ *gst.Buffer, pad *gst.Pad) {
caps := pad.GetCurrentCaps()
if caps == nil || caps.GetSize() == 0 {
p.log.Error("Stats pad has no or empty caps")
return
}
if caps.GetSize() > 1 {
// Note: should be impossible
p.log.Warn("Stats pad has multiple structures, using the first one", "caps", caps.String())
}
structure := caps.GetStructureAt(0)
if structure == nil {
p.log.Error("Stats pad received nil structure")
return
}
rawWidth, err := structure.GetValue("width")
if err != nil {
p.log.Error("Failed to get width from stats pad caps", "error", err)
return
}
width, ok := rawWidth.(int)
if !ok {
p.log.Error("Width field in stats pad caps is not an int", "value", rawWidth)
return
}
rawHeight, err := structure.GetValue("height")
if err != nil {
p.log.Error("Failed to get height from stats pad caps", "error", err)
return
}
height, ok := rawHeight.(int)
if !ok {
p.log.Error("Height field in stats pad caps is not an int", "value", rawHeight)
return
}
fps, err := structure.GetValue("framerate")
if err != nil {
p.log.Error("Failed to get framerate from stats pad caps", "error", err)
return
}
var fpsFloat float64
switch v := fps.(type) {
case *gst.FractionValue:
fpsFloat = float64(v.Num()) / float64(v.Denom())
case gst.FractionValue:
fpsFloat = float64(v.Num()) / float64(v.Denom())
case float64:
fpsFloat = v
default:
p.log.Error("Framerate field in stats pad caps is not a fraction or float", "type", fmt.Sprintf("%T", fps), "value", fps)
return
}
select {
case statsChan <- StreamStats{
FPS: fpsFloat,
Width: width,
Height: height,
}:
default:
p.log.Warn("Stats channel is full, dropping stats update")
}
return
})
return statsChan, nil
}

View File

@@ -0,0 +1,306 @@
package ingest
import (
"bytes"
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/go-gst/go-gst/gst"
"github.com/go-gst/go-gst/gst/app"
)
type thumbnailReq struct {
Result chan<- thumbnailRes
}
type thumbnailRes struct {
Reader *bytes.Reader
Err error
}
// addThumbnailBranch populates the pipeline with:
//
// [vTee] ---> identity[drop D, limit fps] ---> queue ---> [dynamic decoder]
// ---> videoconvert ---> aspectratiocrop ---> videoscale ---> capsfilter
// ---> jpegenc ---> appsink
//
// Essentially, this branch:
// - Removes any deltas from the stream.
// - Limits FPS (we only have keyframes, so this is roughly possible before decoding).
// - Decode, convert, crop to aspect ratio, scale and encode to JPEG
// - Then we can query the appsink for thumbnails
//
// width and height are for the dimensions of the generated thumbnails.
// minInterval has no effect when zero or negative, otherwise specifies the minimum interval between thumbnails.
// timeoutSecs is used to deactivate this branch after a period of inactivity (to save CPU), this is done by blocking after the queue.
func (p *CameraPipeline) addThumbnailBranch(width, height int, minInterval, branchTimeout time.Duration) error {
if p.thumbnailSink != nil {
return fmt.Errorf("thumbnail branch already added")
}
if p.vTee == nil {
return fmt.Errorf("video tee not initialized")
}
vPad := p.vTee.GetRequestPad("src_%u")
if vPad == nil {
return fmt.Errorf("failed to get request pad from video tee")
}
preDecodeChain, err := p.addStaticChainProps(
vPad, nil,
elementFactory{name: "identity", props: map[string]any{
"drop-buffer-flags": gst.BufferFlagDeltaUnit,
}},
elementFactory{name: "queue", props: map[string]any{
"max-size-buffers": uint(1),
"leaky": 2, // downstream
}},
)
if err != nil {
return fmt.Errorf("failed to add pre-decode chain: %w", err)
}
preDecodeSrc := preDecodeChain[len(preDecodeChain)-1].GetStaticPad("src")
if preDecodeSrc == nil {
return fmt.Errorf("failed to get src pad of pre-decode chain")
}
identitySrc := preDecodeChain[0].GetStaticPad("src")
if identitySrc == nil {
return fmt.Errorf("failed to get src pad of identity element in pre-decode chain")
}
transformChain, err := p.addStaticChainProps(
nil, nil,
elementFactory{name: "videoconvert", props: nil},
elementFactory{name: "aspectratiocrop", props: map[string]any{
"aspect-ratio": gst.Fraction(width, height),
}},
elementFactory{name: "videoscale", props: nil},
elementFactory{name: "capsfilter", props: map[string]any{
"caps": gst.NewCapsFromString(fmt.Sprintf("video/x-raw,width=%d,height=%d", width, height)),
}},
elementFactory{name: "jpegenc", props: map[string]any{
"quality": 85,
}},
elementFactory{name: "appsink", props: map[string]any{
"max-buffers": uint(1),
"drop": true,
"sync": false,
"async": false,
}},
)
if err != nil {
return fmt.Errorf("failed to add transform chain: %w", err)
}
if minInterval > 0 {
minInterval := uint64(minInterval.Nanoseconds())
lastKeptTS, haveLastTS := uint64(0), false
identitySrc.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
buf := info.GetBuffer()
if buf == nil {
return gst.PadProbeOK
}
ts := buf.PresentationTimestamp()
if ts == gst.ClockTimeNone {
ts = buf.DecodingTimestamp()
}
if ts == gst.ClockTimeNone || ts < 0 {
return gst.PadProbeOK
}
curr := uint64(ts)
if haveLastTS {
resetOccurred := curr < lastKeptTS
if !resetOccurred && curr-lastKeptTS < minInterval {
return gst.PadProbeDrop
}
}
haveLastTS = true
lastKeptTS = curr
return gst.PadProbeOK
})
}
// Dynamically add decoder (decodebin has an internal queue that is unnecessary for our use case,
// so it just adds unwanted latency).
transformSink := transformChain[0].GetStaticPad("sink")
if transformSink == nil {
return fmt.Errorf("failed to get sink pad for transform chain")
}
once := sync.Once{}
preDecodeSrc.AddProbe(gst.PadProbeTypeEventDownstream, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
event := info.GetEvent()
if event != nil && event.Type() == gst.EventTypeCaps {
caps := event.ParseCaps()
if caps == nil || caps.GetSize() == 0 {
p.log.Error("Thumbnail pad probe received caps event with no or empty caps")
return gst.PadProbeOK
}
if caps.GetSize() > 1 {
// Note: should be impossible
p.log.Warn("Thumbnail pad probe received caps event with multiple structures, using the first one", "caps", caps.String())
}
structure := caps.GetStructureAt(0)
if structure == nil {
p.log.Error("Thumbnail pad probe received caps event with nil structure")
return gst.PadProbeOK
}
res := gst.PadProbeOK
once.Do(func() {
res = gst.PadProbeRemove
var (
chain []*gst.Element
err error
)
switch structure.Name() {
case "video/x-h264":
chain, err = p.addStaticChain(preDecodeSrc, transformSink, "avdec_h264")
case "video/x-h265":
chain, err = p.addStaticChain(preDecodeSrc, transformSink, "avdec_h265")
default:
p.log.Error("Thumbnail pad probe received caps event with unsupported encoding", "caps", caps.String())
return
}
if err != nil {
p.log.Error("Failed to add decoder chain in thumbnail pad probe", "caps", caps.String(), "error", err)
return
}
for _, element := range chain {
element.SyncStateWithParent()
}
})
return res
} else {
return gst.PadProbeOK
}
})
p.thumbnailBlockPad = preDecodeSrc
p.thumbnailSink = app.SinkFromElement(transformChain[len(transformChain)-1])
p.thumbnailTimeout = branchTimeout
p.thumbnailReq = make(chan thumbnailReq)
return nil
}
func (p *CameraPipeline) addThumbnailBlockProbe() {
p.thumbnailBlockProbe = p.thumbnailBlockPad.AddProbe(gst.PadProbeTypeBlock, func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
p.thumbnailBlocked.Store(true)
return gst.PadProbeOK
})
}
func (p *CameraPipeline) handleThumbnailReq(active bool, lastThumbnail []byte) (bool, []byte, error) {
var sample *gst.Sample
if active {
sample = p.thumbnailSink.TryPullSample(0)
} else {
p.log.Info("Activating thumbnail branch")
// Install one-shot probe to drop the first buffer (which is a stale frame that was blocked by the blocking probe
// when the branch was first deactivated).
if p.thumbnailBlocked.Swap(false) {
dropProbeId := atomic.Pointer[uint64]{}
once := sync.Once{}
probeId := p.thumbnailBlockPad.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn {
if probeId := dropProbeId.Load(); probeId != nil {
p.thumbnailBlockPad.RemoveProbe(*probeId)
}
res := gst.PadProbeRemove
once.Do(func() {
res = gst.PadProbeDrop
})
return res
})
dropProbeId.Store(&probeId)
}
p.thumbnailBlockPad.RemoveProbe(p.thumbnailBlockProbe)
active = true
sample = p.thumbnailSink.PullSample()
}
if sample == nil {
if lastThumbnail == nil {
return active, lastThumbnail, fmt.Errorf("no thumbnail available")
} else {
p.log.Debug("Resolving last thumbnail")
return active, lastThumbnail, nil
}
}
p.log.Debug("Resolving new thumbnail")
data, err := gstBufferToBytes(sample.GetBuffer())
if err != nil {
return active, lastThumbnail, fmt.Errorf("failed to convert thumbnail buffer to bytes: %w", err)
}
return active, data, nil
}
func (p *CameraPipeline) thumbnailManager(ctx context.Context) {
active := true
var lastThumbnail []byte
defer p.log.Debug("Thumbnail manager exiting")
ManagerLoop:
for {
var timeout <-chan time.Time
if active {
timeout = time.After(p.thumbnailTimeout)
}
select {
case <-ctx.Done():
return
case req := <-p.thumbnailReq:
var (
thumbnail []byte
err error
)
active, thumbnail, err = p.handleThumbnailReq(active, lastThumbnail)
if err != nil {
req.Result <- thumbnailRes{Err: err}
continue ManagerLoop
}
lastThumbnail = thumbnail
select {
case req.Result <- thumbnailRes{Reader: bytes.NewReader(thumbnail)}:
case <-ctx.Done():
return
}
case <-timeout:
p.log.Info("Deactivating thumbnail branch due to inactivity")
// Install block probe to stop flow into the thumbnail branch
p.addThumbnailBlockProbe()
// Flush buffer to avoid sending stale thumbnail when reactivating
for {
sample := p.thumbnailSink.TryPullSample(0)
if sample == nil {
break
}
}
active = false
}
}
}
// GetThumbnail either retrieves a JPEG thumbnail from the pipeline, or returns the previous if no new thumbnail is available.
func (p *CameraPipeline) GetThumbnail(ctx context.Context) (*bytes.Reader, error) {
if p.thumbnailSink == nil {
return nil, fmt.Errorf("thumbnail branch not initialized")
}
resp := make(chan thumbnailRes, 1)
select {
case p.thumbnailReq <- thumbnailReq{Result: resp}:
case <-ctx.Done():
return nil, fmt.Errorf("context cancelled while requesting thumbnail")
}
select {
case thumbnail := <-resp:
return thumbnail.Reader, thumbnail.Err
case <-ctx.Done():
return nil, fmt.Errorf("context cancelled while waiting for thumbnail")
}
}

110
server/ingest/discovery.go Normal file
View File

@@ -0,0 +1,110 @@
package ingest
import (
"context"
"fmt"
"log/slog"
"net/url"
"github.com/gowvp/onvif"
"github.com/gowvp/onvif/media"
mediaSdk "github.com/gowvp/onvif/sdk/media"
onvifXsd "github.com/gowvp/onvif/xsd/onvif"
)
type OnvifProfile struct {
Name onvifXsd.Name
Token onvifXsd.ReferenceToken
URI *url.URL
}
// GetOnvifProfiles retrieves all available ONVIF stream profiles for a camera.
//
// Note that the result is very limited since many cameras provide extremely incomplete data,
// so it's safest to assume that we only have the bare minimum.
func GetOnvifProfiles(ctx context.Context, host, username, password string) ([]OnvifProfile, error) {
dev, err := onvif.NewDevice(onvif.DeviceParams{
Xaddr: host,
Username: username,
Password: password,
})
if err != nil {
return nil, fmt.Errorf("failed to create ONVIF device: %w", err)
}
// Get available profiles
profiles, err := mediaSdk.Call_GetProfiles(ctx, dev, media.GetProfiles{})
if err != nil {
return nil, fmt.Errorf("failed to get ONVIF profiles: %w", err)
}
// Get available URIs for each profile
onvifProfiles := make([]OnvifProfile, 0, len(profiles.Profiles))
for _, p := range profiles.Profiles {
uri, err := mediaSdk.Call_GetStreamUri(ctx, dev, media.GetStreamUri{
StreamSetup: onvifXsd.StreamSetup{
Stream: onvifXsd.StreamType("RTP-Unicast"),
Transport: onvifXsd.Transport{
Protocol: onvifXsd.TransportProtocol("TCP"),
},
},
ProfileToken: p.Token,
})
if err != nil {
return nil, fmt.Errorf("failed to get stream URI for profile %s: %w", p.Token, err)
}
if uri.MediaUri.Uri != "" {
u, err := url.Parse(string(uri.MediaUri.Uri))
if err != nil {
return nil, fmt.Errorf("failed to parse stream URI for profile %s: %w", p.Token, err)
}
onvifProfiles = append(onvifProfiles, OnvifProfile{
Name: p.Name,
Token: p.Token,
URI: u,
})
}
}
return onvifProfiles, nil
}
type StreamStats struct {
FPS float64
Width int
Height int
}
// GetStreamStats starts a temporary pipeline to retrieve some basic (pre-decoding) information about the RTSP stream.
func GetStreamStats(ctx context.Context, log *slog.Logger, rtspUrl *url.URL, profileToken string) (stats StreamStats, err error) {
p, err := CreatePipeline(log)
if err != nil {
return stats, fmt.Errorf("failed to create pipeline: %w", err)
}
if err := p.addRtspSource(rtspUrl); err != nil {
return stats, fmt.Errorf("failed to add RTSP source: %w", err)
}
statsChan, err := p.addStatsBranch()
if err != nil {
return stats, fmt.Errorf("failed to add stats branch: %w", err)
}
statsFound := false
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case stats = <-statsChan:
statsFound = true
cancel()
case <-runCtx.Done():
}
}()
if err := p.Run(runCtx); err != nil {
return stats, fmt.Errorf("failed to run pipeline: %w", err)
}
if !statsFound {
return stats, fmt.Errorf("failed to retrieve stream stats: no stats received")
}
return stats, nil
}

166
server/ingest/ingest.go Normal file
View File

@@ -0,0 +1,166 @@
package ingest
import (
"bytes"
"context"
"fmt"
"log/slog"
"net/url"
"sync"
"time"
"git.koval.net/cyclane/cctv/server/models"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/sync/errgroup"
)
type Ingest struct {
sync.WaitGroup
app core.App
log *slog.Logger
pipelines *store.Store[string, *activePipeline]
}
type activePipeline struct {
stream *models.Stream
pipeline *CameraPipeline
cancel context.CancelFunc
terminated <-chan struct{}
}
func BeginIngest(ctx context.Context, app core.App) *Ingest {
log := app.Logger().With("svc", "ingest")
ingest := &Ingest{
WaitGroup: sync.WaitGroup{},
app: app,
log: log,
pipelines: store.New[string, *activePipeline](nil),
}
// initialise thumbnail pipelines
thumbnailStreams := map[string]*models.Stream{}
streams := []*models.Stream{}
err := app.RunInTransaction(func(txApp core.App) error {
if err := txApp.RecordQuery("streams").All(&streams); err != nil {
return fmt.Errorf("failed to fetch stream records: %w", err)
}
for _, stream := range streams {
other, ok := thumbnailStreams[stream.CameraID()]
if !ok || thumbnailStreamScore(stream) < thumbnailStreamScore(other) {
thumbnailStreams[stream.CameraID()] = stream
}
}
streamRecords := make([]*core.Record, len(streams))
for i, stream := range streams {
streamRecords[i] = stream.Record
}
if errs := txApp.ExpandRecords(streamRecords, []string{"camera"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand camera relation for stream records: %v", errs)
}
return nil
})
if err != nil {
log.Error("Failed to initialize stream pipelines", "error", err)
return ingest
}
group := errgroup.Group{}
for _, stream := range streams {
group.Go(func() error {
log := log.With("stream", stream.Id)
rtspUrl, err := url.Parse(stream.URL())
if err != nil {
return fmt.Errorf("failed to parse stream URL for stream %s: %w", stream.Id, err)
}
password, err := stream.Camera().Password(app.EncryptionEnv())
if err != nil {
return fmt.Errorf("failed to decrypt camera password for stream %s: %w", stream.Id, err)
}
rtspUrl.User = url.UserPassword(stream.Camera().Username(), password)
pipeline, err := CreatePipeline(log)
if err != nil {
return fmt.Errorf("failed to create pipeline for stream %s: %w", stream.Id, err)
}
if err := pipeline.addRtspSource(rtspUrl); err != nil {
return fmt.Errorf("failed to add RTSP source to pipeline for stream %s: %w", stream.Id, err)
}
consumers := false
if thumbnailStream, ok := thumbnailStreams[stream.CameraID()]; ok && thumbnailStream.Id == stream.Id {
consumers = true
if err := pipeline.addThumbnailBranch(480, 270, 1000*time.Millisecond, 20*time.Second); err != nil {
return fmt.Errorf("failed to add thumbnail branch to pipeline for stream %s: %w", stream.Id, err)
}
}
if err := pipeline.addLiveBranch(20 * time.Second); err != nil {
return fmt.Errorf("failed to add live branch to pipeline for stream %s: %w", stream.Id, err)
}
if consumers {
log.Info("Starting pipeline for stream with consumers.")
} else {
log.Info("Stream has no consumers, ignoring pipeline.")
pipeline.Close()
return nil
}
pipelineCtx, cancel := context.WithCancel(ctx)
terminated := make(chan struct{})
ingest.pipelines.Set(stream.Id, &activePipeline{
stream: stream,
pipeline: pipeline,
cancel: cancel,
terminated: terminated,
})
ingest.WaitGroup.Go(func() {
if err := pipeline.Run(pipelineCtx); err != nil {
log.Error("Stream pipeline exited with error", "error", err)
}
close(terminated)
})
return nil
})
}
if err := group.Wait(); err != nil {
log.Error("Failed to start ingest pipelines", "error", err)
}
return ingest
}
func (ingest *Ingest) GetThumbnail(ctx context.Context, streamId string) (*bytes.Reader, error) {
active, ok := ingest.pipelines.GetOk(streamId)
if !ok {
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
}
thumb, err := active.pipeline.GetThumbnail(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get thumbnail from pipeline for stream %s: %w", streamId, err)
}
return thumb, nil
}
func thumbnailStreamScore(stream *models.Stream) float64 {
return stream.FPS() * float64(stream.Height()*stream.Width())
}
func (ingest *Ingest) SubscribeLive(ctx context.Context, streamId string) (<-chan *bytes.Reader, error) {
active, ok := ingest.pipelines.GetOk(streamId)
if !ok {
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
}
id, stream, err := active.pipeline.LiveSubscribe(1*time.Second, 16)
if err != nil {
return nil, fmt.Errorf("failed to subscribe to live stream %s: %w", streamId, err)
}
defer active.pipeline.LiveUnsubscribe(id)
for {
select {
case <-ctx.Done():
return nil, nil
case buf := <-stream:
ingest.log.Debug("Received live stream chunk", "streamId", streamId, "chunkSize", buf.Len())
}
}
}

323
server/ingest/pipeline.go Normal file
View File

@@ -0,0 +1,323 @@
package ingest
import (
"context"
"fmt"
"io"
"log/slog"
"net/url"
"reflect"
"sync/atomic"
"time"
"github.com/go-gst/go-glib/glib"
"github.com/go-gst/go-gst/gst"
"github.com/go-gst/go-gst/gst/app"
)
type CameraPipeline struct {
log *slog.Logger
pipeline *gst.Pipeline
vTee *gst.Element
aTee *gst.Element
thumbnailBlockPad *gst.Pad
thumbnailSink *app.Sink
thumbnailBlockProbe uint64
thumbnailBlocked atomic.Bool
thumbnailTimeout time.Duration
thumbnailReq chan thumbnailReq
liveVValve *gst.Element
liveAValve *gst.Element
liveSink *app.Sink
liveTimeout time.Duration
liveSubscribe chan subscribeReq
liveUnsubscribe chan unsubscribeReq
}
type jobResult struct {
Err error
}
func CreatePipeline(log *slog.Logger) (*CameraPipeline, error) {
var err error
p := &CameraPipeline{
log: log,
}
p.pipeline, err = gst.NewPipeline("")
if err != nil {
return nil, fmt.Errorf("failed to create GStreamer pipeline: %w", err)
}
return p, nil
}
func (p *CameraPipeline) Close() {
p.pipeline.SetState(gst.StateNull)
p.thumbnailBlockPad = nil
p.thumbnailSink = nil
p.liveVValve = nil
p.liveAValve = nil
p.liveSink = nil
p.vTee = nil
p.aTee = nil
p.pipeline = nil
}
func (p *CameraPipeline) Run(ctx context.Context) error {
if err := p.pipeline.SetState(gst.StatePlaying); err != nil {
return fmt.Errorf("failed to set pipeline to playing: %w", err)
}
if p.thumbnailSink != nil {
go p.thumbnailManager(ctx)
}
if p.liveSink != nil {
go p.liveManager(ctx)
}
bus := p.pipeline.GetBus()
for {
select {
case <-ctx.Done():
p.Close()
return nil
default:
}
done, err := handleMessage(p.log, bus.TimedPop(gst.ClockTime((500 * time.Millisecond).Nanoseconds())))
if err != nil {
p.Close()
return fmt.Errorf("pipeline error: %w", err)
}
if done {
p.Close()
return nil
}
}
}
func handleMessage(log *slog.Logger, msg *gst.Message) (bool, error) {
if msg == nil {
return false, nil
}
// nolint:exhaustive
switch msg.Type() {
case gst.MessageEOS:
log.Info("Pipeline reached EOS")
return true, nil
case gst.MessageError:
err := msg.ParseError()
return true, fmt.Errorf("pipeline error: %w", err)
default:
log.Debug(fmt.Sprintf("Pipeline message: %s", msg))
return false, nil
}
}
// addRtspSource populates the pipeline with:
//
// rtspsrc
// |---> (dynamic video depayloader + parser) --> vTee
// |---> (dynamic audio depayloader + parser) --> aTee
func (p *CameraPipeline) addRtspSource(rtspURL *url.URL) error {
if p.vTee != nil || p.aTee != nil {
return fmt.Errorf("source already added")
}
src, err := gst.NewElement("rtspsrc")
if err != nil {
return fmt.Errorf("failed to create rtspsrc element: %w", err)
}
if err := src.SetProperty("location", rtspURL.String()); err != nil {
return fmt.Errorf("failed to set rtspsrc location property: %w", err)
}
if err := src.SetProperty("latency", uint(200)); err != nil {
return fmt.Errorf("failed to set rtspsrc latency property: %w", err)
}
p.vTee, err = gst.NewElement("tee")
if err != nil {
return fmt.Errorf("failed to create video tee element: %w", err)
}
if err := p.vTee.SetProperty("name", "video_tee"); err != nil {
return fmt.Errorf("failed to set video tee name property: %w", err)
}
p.aTee, err = gst.NewElement("tee")
if err != nil {
return fmt.Errorf("failed to create audio tee element: %w", err)
}
if err := p.aTee.SetProperty("name", "audio_tee"); err != nil {
return fmt.Errorf("failed to set audio tee name property: %w", err)
}
if err := p.pipeline.AddMany(src, p.vTee, p.aTee); err != nil {
return fmt.Errorf("failed to add elements to pipeline: %w", err)
}
// Dynamically link the appropriate depayloader
src.Connect("pad-added", func(element *gst.Element, pad *gst.Pad) {
log := p.log.With("src", element.GetName(), "pad", pad.GetName())
caps := pad.GetCurrentCaps()
if caps == nil || caps.GetSize() == 0 {
log.Error("Ignoring pad (no or empty caps)")
return
}
if caps.GetSize() > 1 {
// Note: should be impossible
log.Warn("Pad has multiple structures, using the first one", "caps", caps.String())
}
structure := caps.GetStructureAt(0)
if structure == nil || structure.Name() != "application/x-rtp" {
log.Error("Ignoring pad (not RTP)", "caps", caps.String())
return
}
mediaRaw, err := structure.GetValue("media")
media, ok := mediaRaw.(string)
if err != nil || !ok {
log.Error("Ignoring pad (no media field)", "caps", caps.String())
return
}
encodingRaw, err := structure.GetValue("encoding-name")
encoding, ok := encodingRaw.(string)
if err != nil || !ok {
log.Error("Ignoring pad (no encoding-name field)", "caps", caps.String())
return
}
var chain []*gst.Element
switch media {
case "video":
switch encoding {
case "H264":
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph264depay", "h264parse")
case "H265":
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph265depay", "h265parse")
default:
log.Error("Ignoring video pad (unsupported encoding)", "caps", caps.String())
return
}
case "audio":
switch encoding {
case "MPEG4-GENERIC":
chain, err = p.addStaticChain(pad, p.aTee.GetStaticPad("sink"), "rtpmp4gdepay", "aacparse")
default:
log.Error("Ignoring audio pad (unsupported encoding)", "caps", caps.String())
return
}
default:
log.Error("Ignoring pad (unsupported media)", "caps", caps.String())
return
}
if err != nil {
log.Error("Failed to add depayloader", "caps", caps.String(), "error", err)
return
}
for _, element := range chain {
element.SyncStateWithParent()
}
})
return nil
}
func (p *CameraPipeline) addStaticChain(src *gst.Pad, sink *gst.Pad, chain ...string) ([]*gst.Element, error) {
factories := make([]elementFactory, len(chain))
for i, name := range chain {
factories[i] = elementFactory{name: name, props: nil}
}
return p.addStaticChainProps(src, sink, factories...)
}
type elementFactory struct {
name string
props map[string]any
}
func (p *CameraPipeline) addStaticChainProps(src *gst.Pad, sink *gst.Pad, chain ...elementFactory) ([]*gst.Element, error) {
chainElements := make([]*gst.Element, len(chain))
last := src
for i, factory := range chain {
elem, err := gst.NewElement(factory.name)
if err != nil {
return nil, fmt.Errorf("failed to create %s: %w", factory.name, err)
}
for propName, propValue := range factory.props {
if err := elem.SetProperty(propName, propValue); err != nil {
// try to set enum property
valueErr := setGlibValueProperty(elem, propName, propValue)
if valueErr != nil {
p.log.Warn("Failed to set property glib value", "name", propName, "value", propValue, "error", valueErr)
return nil, fmt.Errorf("failed to set property %s on %s: %w", propName, factory.name, err)
}
}
}
if err := p.pipeline.Add(elem); err != nil {
return nil, fmt.Errorf("failed to add %s to pipeline: %w", factory.name, err)
}
if last != nil {
if res := last.Link(elem.GetStaticPad("sink")); res != gst.PadLinkOK {
return nil, fmt.Errorf("failed to link %s to src: %s", factory.name, res)
}
}
last = elem.GetStaticPad("src")
chainElements[i] = elem
}
if sink != nil {
if res := last.Link(sink); res != gst.PadLinkOK {
return nil, fmt.Errorf("failed to link depayloader chain to sink: %s", res)
}
}
return chainElements, nil
}
func gstBufferToBytes(buf *gst.Buffer) ([]byte, error) {
mapInfo := buf.Map(gst.MapRead)
if mapInfo == nil {
return nil, fmt.Errorf("failed to map buffer")
}
defer buf.Unmap()
data := make([]byte, mapInfo.Size())
if _, err := io.ReadFull(mapInfo.Reader(), data); err != nil {
return nil, fmt.Errorf("failed to read buffer data: %w", err)
}
return data, nil
}
func setGlibValueProperty(elem *gst.Element, name string, value any) error {
propType, err := elem.GetPropertyType(name)
if err != nil {
return fmt.Errorf("get property type %s: %w", name, err)
}
v, err := glib.ValueInit(propType)
if err != nil {
return fmt.Errorf("init gvalue for %s: %w", name, err)
}
val := reflect.ValueOf(value)
switch {
case propType.IsA(glib.TYPE_ENUM) && val.CanInt():
v.SetEnum(int(val.Int())) // safe because these config values are all done locally and guaranteed to be within int32
case propType.IsA(glib.TYPE_ENUM) && val.CanUint():
v.SetEnum(int(val.Uint())) // safe because these config values are all done locally and guaranteed to be within int32
case propType.IsA(glib.TYPE_FLAGS) && val.CanInt():
v.SetFlags(uint(val.Int())) // safe because these config values are all done locally and guaranteed to be within uint32
case propType.IsA(glib.TYPE_FLAGS) && val.CanUint():
v.SetFlags(uint(val.Uint())) // safe because these config values are all done locally and guaranteed to be within uint32
default:
return fmt.Errorf("unsupported property type for %s: %T (kind = %s), expected %s", name, value, val.Kind(), propType.Name())
}
if err := elem.SetPropertyValue(name, v); err != nil {
return fmt.Errorf("failed to set glib value property %s: %w", name, err)
}
return nil
}