Files
cctv/server/ingest/branch_thumbnail.go
2026-04-22 23:35:59 +01:00

307 lines
9.1 KiB
Go

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")
}
}