wip
This commit is contained in:
335
server/ingest/branch_live.go
Normal file
335
server/ingest/branch_live.go
Normal 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)
|
||||
}
|
||||
}
|
||||
111
server/ingest/branch_stats.go
Normal file
111
server/ingest/branch_stats.go
Normal 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
|
||||
}
|
||||
306
server/ingest/branch_thumbnail.go
Normal file
306
server/ingest/branch_thumbnail.go
Normal 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
110
server/ingest/discovery.go
Normal 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
166
server/ingest/ingest.go
Normal 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
323
server/ingest/pipeline.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user