mirror of
https://github.com/kerberos-io/agent.git
synced 2026-09-13 11:46:40 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c439e34c7 | ||
|
|
d57bea3079 | ||
|
|
46a48db080 | ||
|
|
b7fe9947c2 | ||
|
|
f214a09826 | ||
|
|
7059503ac1 | ||
|
|
7ee79cc063 | ||
|
|
9bfbe4ee0f | ||
|
|
b8c05aa3e2 | ||
|
|
5f7ede40ca | ||
|
|
0ef84c5288 | ||
|
|
1a477bf42d | ||
|
|
22c352e946 | ||
|
|
55b0eb54fe | ||
|
|
68a4ca6bb9 |
31
.github/workflows/pr-description.yaml
vendored
31
.github/workflows/pr-description.yaml
vendored
@@ -2,25 +2,16 @@ name: Autofill PR description
|
||||
|
||||
on: pull_request
|
||||
|
||||
env:
|
||||
ORGANIZATION: uugai
|
||||
PROJECT: ${{ github.event.repository.name }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
|
||||
jobs:
|
||||
openai-pr-description:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Autofill PR description if empty using OpenAI
|
||||
uses: cedricve/azureopenai-pr-description@master
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
openai_model: ${{ secrets.OPENAI_MODEL }}
|
||||
pull_request_url: https://pr${{ env.PR_NUMBER }}.api.kerberos.lol
|
||||
overwrite_description: true
|
||||
uses: uug-ai/workflows/.github/workflows/pr-description.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.number }}
|
||||
pull_request_url: ""
|
||||
overwrite_description: true
|
||||
secrets:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
AZURE_OPENAI_VERSION: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
68
.github/workflows/release-bump.yml
vendored
Normal file
68
.github/workflows/release-bump.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Bump release
|
||||
|
||||
# Manually "promote" the agent to a new release.
|
||||
# Pick which part of the semantic version to bump, this workflow computes the
|
||||
# next vMAJOR.MINOR.PATCH tag, pushes it and triggers the existing
|
||||
# release-create pipeline to build and publish the images and GitHub release.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
bump-release:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Compute next version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
latest=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
|
||||
latest=${latest:-v0.0.0}
|
||||
echo "Latest tag: $latest"
|
||||
version=${latest#v}
|
||||
IFS='.' read -r major minor patch <<< "$version"
|
||||
case "${{ github.event.inputs.bump }}" in
|
||||
major) major=$((major + 1)); minor=0; patch=0 ;;
|
||||
minor) minor=$((minor + 1)); patch=0 ;;
|
||||
patch) patch=$((patch + 1)) ;;
|
||||
esac
|
||||
next="v${major}.${minor}.${patch}"
|
||||
echo "Next tag: $next"
|
||||
echo "tag=$next" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin "${{ steps.version.outputs.tag }}"
|
||||
|
||||
# A tag pushed with the default GITHUB_TOKEN does not trigger other
|
||||
# workflows, so invoke the release pipeline explicitly for the new tag.
|
||||
- name: Trigger release pipeline
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run release-create.yml \
|
||||
--ref "${{ steps.version.outputs.tag }}" \
|
||||
-f tag="${{ steps.version.outputs.tag }}"
|
||||
@@ -95,6 +95,129 @@ type Golibrtsp struct {
|
||||
keyframeBufferSize int
|
||||
keyframeBufferIndex int
|
||||
keyframeMutex sync.Mutex
|
||||
|
||||
// Stream health instrumentation. Used to pinpoint the root cause behind
|
||||
// "RTP packets lost" + watchdog restarts by separating downstream
|
||||
// back-pressure from upstream network/camera stalls.
|
||||
health *streamHealth
|
||||
streamLabel string
|
||||
}
|
||||
|
||||
// streamHealth instruments the RTSP read path. gortsplib delivers every RTP
|
||||
// packet on a single read goroutine; queue.WritePacket() is synchronous, so if
|
||||
// a downstream consumer (recording, muxing, WebRTC) is slow or the process is
|
||||
// CPU-starved, WritePacket() blocks, the TCP socket is not drained, and the
|
||||
// camera advances RTP sequence numbers -> "RTP packets lost". This type makes
|
||||
// the two failure modes distinguishable:
|
||||
// - large writeMax / writeAvg => downstream back-pressure (our side).
|
||||
// - large gapMax with fast writes => upstream network / camera stall.
|
||||
type streamHealth struct {
|
||||
mu sync.Mutex
|
||||
windowStart time.Time
|
||||
lastPacket time.Time
|
||||
frames int64
|
||||
writeSum time.Duration
|
||||
writeMax time.Duration
|
||||
gapMax time.Duration
|
||||
lost uint64
|
||||
decodeErrs int64
|
||||
}
|
||||
|
||||
const (
|
||||
streamHealthWindow = 10 * time.Second
|
||||
streamHealthWriteWarn = 150 * time.Millisecond
|
||||
streamHealthGapWarn = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
func newStreamHealth() *streamHealth {
|
||||
now := time.Now()
|
||||
return &streamHealth{windowStart: now, lastPacket: now}
|
||||
}
|
||||
|
||||
// observePacket records one processed video frame: the wall-clock gap since the
|
||||
// previous frame (arrival cadence) and how long WritePacket() blocked
|
||||
// (back-pressure). It emits an immediate warning when either side stalls and a
|
||||
// periodic summary every streamHealthWindow.
|
||||
func (h *streamHealth) observePacket(streamType string, writeDur time.Duration) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := time.Now()
|
||||
var gap time.Duration
|
||||
if h.frames == 0 {
|
||||
// First frame: initialize timing to avoid counting RTSP setup time as a stall.
|
||||
h.windowStart = now
|
||||
h.lastPacket = now
|
||||
gap = 0
|
||||
} else {
|
||||
gap = now.Sub(h.lastPacket)
|
||||
h.lastPacket = now
|
||||
}
|
||||
h.frames++
|
||||
h.writeSum += writeDur
|
||||
if writeDur > h.writeMax {
|
||||
h.writeMax = writeDur
|
||||
}
|
||||
if gap > h.gapMax {
|
||||
h.gapMax = gap
|
||||
}
|
||||
if writeDur >= streamHealthWriteWarn {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): WritePacket blocked %dms — downstream back-pressure / CPU starvation",
|
||||
streamType, writeDur.Milliseconds()))
|
||||
}
|
||||
if gap >= streamHealthGapWarn {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): %dms since previous frame — upstream network / camera stall",
|
||||
streamType, gap.Milliseconds()))
|
||||
}
|
||||
if now.Sub(h.windowStart) >= streamHealthWindow {
|
||||
elapsed := now.Sub(h.windowStart).Seconds()
|
||||
var avgWriteMs float64
|
||||
if h.frames > 0 {
|
||||
avgWriteMs = float64(h.writeSum.Milliseconds()) / float64(h.frames)
|
||||
}
|
||||
log.Log.Info(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): %.0fs window — frames=%d (%.1f/s) writeAvg=%.1fms writeMax=%dms gapMax=%dms lost=%d decodeErrs=%d",
|
||||
streamType, elapsed, h.frames, float64(h.frames)/elapsed, avgWriteMs,
|
||||
h.writeMax.Milliseconds(), h.gapMax.Milliseconds(), h.lost, h.decodeErrs))
|
||||
h.windowStart = now
|
||||
h.frames = 0
|
||||
h.writeSum = 0
|
||||
h.writeMax = 0
|
||||
h.gapMax = 0
|
||||
h.lost = 0
|
||||
h.decodeErrs = 0
|
||||
}
|
||||
}
|
||||
|
||||
// observeLost is invoked by gortsplib when RTP sequence numbers skip. On a TCP
|
||||
// transport this means the sender (camera) dropped packets because we were not
|
||||
// reading fast enough, not loss on the wire.
|
||||
func (h *streamHealth) observeLost(streamType string, lost uint64) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.lost += lost
|
||||
h.mu.Unlock()
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): %d RTP packet(s) lost — sender-side gap (receiver not draining TCP fast enough)",
|
||||
streamType, lost))
|
||||
}
|
||||
|
||||
// observeDecodeError is invoked by gortsplib on incomplete/invalid access units,
|
||||
// which are a downstream symptom of the loss reported by observeLost.
|
||||
func (h *streamHealth) observeDecodeError(streamType string, err error) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.decodeErrs++
|
||||
h.mu.Unlock()
|
||||
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.health(%s): decode error: %s", streamType, err.Error()))
|
||||
}
|
||||
|
||||
// fpsTracker holds per-stream state for PTS-based FPS calculation.
|
||||
@@ -195,9 +318,20 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
|
||||
defer span.End()
|
||||
|
||||
transport := gortsplib.TransportTCP
|
||||
g.health = newStreamHealth()
|
||||
g.Client = gortsplib.Client{
|
||||
RequestBackChannels: false,
|
||||
Transport: &transport,
|
||||
// Route gortsplib's packet-loss / decode-error reporting through our
|
||||
// structured logger with stream context (replaces its plain stdout
|
||||
// logging). These hooks are what let us tell whether the camera is
|
||||
// dropping packets because we can't drain the socket fast enough.
|
||||
OnPacketsLost: func(lost uint64) {
|
||||
g.health.observeLost(g.streamLabel, lost)
|
||||
},
|
||||
OnDecodeError: func(err error) {
|
||||
g.health.observeDecodeError(g.streamLabel, err)
|
||||
},
|
||||
}
|
||||
|
||||
// parse URL
|
||||
@@ -517,10 +651,45 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
|
||||
return
|
||||
}
|
||||
|
||||
// dtsExtractor abstracts the codec-specific DTS extractors from mediacommon
|
||||
// (h264.DTSExtractor2 and h265.DTSExtractor2), which expose the same method.
|
||||
type dtsExtractor interface {
|
||||
Extract(au [][]byte, pts int64) (int64, error)
|
||||
}
|
||||
|
||||
// compositionOffsetMs returns the composition time offset (PTS - DTS) in
|
||||
// milliseconds for a coded access unit. Streams that contain B-frames deliver
|
||||
// access units in decode order with non-monotonic PTS; the fragmented MP4
|
||||
// writer needs a monotonic DTS timeline plus a per-sample composition offset
|
||||
// so browsers (Media Source Extensions) can decode the chained segments.
|
||||
//
|
||||
// It returns 0 when the codec has no frame reordering (the common case, e.g.
|
||||
// baseline "IPPP" streams) or when extraction fails, making it a safe no-op.
|
||||
func compositionOffsetMs(ext dtsExtractor, au [][]byte, pts int64, clockRate int) int64 {
|
||||
if ext == nil || clockRate <= 0 {
|
||||
return 0
|
||||
}
|
||||
dts, err := ext.Extract(au, pts)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
offset := pts - dts
|
||||
if offset <= 0 {
|
||||
return 0
|
||||
}
|
||||
return offset * 1000 / int64(clockRate)
|
||||
}
|
||||
|
||||
// Start the RTSP client, and start reading packets.
|
||||
func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) (err error) {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): started")
|
||||
|
||||
// Label this client's loss/decode/health logging with the stream type.
|
||||
g.streamLabel = streamType
|
||||
if g.health == nil {
|
||||
g.health = newStreamHealth()
|
||||
}
|
||||
|
||||
// called when a MULAW audio RTP packet arrives
|
||||
if g.AudioG711Media != nil && g.AudioG711Forma != nil {
|
||||
g.Client.OnPacketRTP(g.AudioG711Media, g.AudioG711Forma, func(rtppkt *rtp.Packet) {
|
||||
@@ -602,7 +771,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
var filteredAU [][]byte
|
||||
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
|
||||
|
||||
//dtsExtractor := h264.NewDTSExtractor2()
|
||||
// Extracts DTS from the bitstream to support B-frame H264 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
h264DTSExtractor := h264.NewDTSExtractor2()
|
||||
|
||||
g.Client.OnPacketRTP(g.VideoH264Media, g.VideoH264Forma, func(rtppkt *rtp.Packet) {
|
||||
|
||||
@@ -742,6 +913,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
// Composition time offset (PTS - DTS) in milliseconds. Non-zero
|
||||
// only for streams with B-frames; the MP4 writer uses it to keep a
|
||||
// monotonic decode timeline and present frames in PTS order.
|
||||
compositionOffset := compositionOffsetMs(h264DTSExtractor, au, pts2, g.VideoH264Forma.ClockRate())
|
||||
|
||||
pkt := packets.Packet{
|
||||
IsKeyFrame: idrPresent,
|
||||
Packet: rtppkt,
|
||||
@@ -749,7 +925,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
Time: pts2,
|
||||
TimeLegacy: pts,
|
||||
CurrentTime: time.Now().UnixMilli(),
|
||||
CompositionTime: pts2,
|
||||
CompositionTime: compositionOffset,
|
||||
Idx: g.VideoH264Index,
|
||||
IsVideo: true,
|
||||
IsAudio: false,
|
||||
@@ -785,7 +961,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
}
|
||||
|
||||
writeStart := time.Now()
|
||||
queue.WritePacket(pkt)
|
||||
// Records WritePacket() blocking time and frame arrival cadence so
|
||||
// we can tell back-pressure from a network/camera stall.
|
||||
g.health.observePacket(streamType, time.Since(writeStart))
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
@@ -817,6 +997,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
// called when a video RTP packet arrives for H265
|
||||
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H265 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
h265DTSExtractor := h265.NewDTSExtractor2()
|
||||
|
||||
g.Client.OnPacketRTP(g.VideoH265Media, g.VideoH265Forma, func(rtppkt *rtp.Packet) {
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
@@ -860,6 +1045,10 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the decoded access unit (in decode order) for DTS
|
||||
// extraction before we rewrite it into the filtered/annexb form.
|
||||
decodedAU := au
|
||||
|
||||
filteredAU = [][]byte{
|
||||
{byte(h265.NALUType_AUD_NUT) << 1, 1, 0x50},
|
||||
}
|
||||
@@ -902,6 +1091,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
// Composition time offset (PTS - DTS) in milliseconds; see H264 handler.
|
||||
compositionOffset := compositionOffsetMs(h265DTSExtractor, decodedAU, pts2, g.VideoH265Forma.ClockRate())
|
||||
|
||||
pkt := packets.Packet{
|
||||
IsKeyFrame: isRandomAccess,
|
||||
Packet: rtppkt,
|
||||
@@ -909,7 +1101,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
Time: pts2,
|
||||
TimeLegacy: pts,
|
||||
CurrentTime: time.Now().UnixMilli(),
|
||||
CompositionTime: pts2,
|
||||
CompositionTime: compositionOffset,
|
||||
Idx: g.VideoH265Index,
|
||||
IsVideo: true,
|
||||
IsAudio: false,
|
||||
@@ -935,7 +1127,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
}
|
||||
|
||||
writeStart := time.Now()
|
||||
queue.WritePacket(pkt)
|
||||
// Records WritePacket() blocking time and frame arrival cadence so
|
||||
// we can tell back-pressure from a network/camera stall.
|
||||
g.health.observePacket(streamType, time.Since(writeStart))
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
|
||||
@@ -140,23 +140,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
if start && // If already recording and current frame is a keyframe and we should stop recording
|
||||
nextPkt.IsKeyFrame && (startRecording+postRecording-now <= 0 || now-startRecording > maxRecordingPeriod-500) {
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
// Write the last packet
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
// Write the last packet
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
// Write the last packet before closing the recording.
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
|
||||
// Close mp4
|
||||
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
|
||||
@@ -311,43 +296,12 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
// We might need to use ffmpeg to transcode the audio to AAC.
|
||||
// For now we will skip the audio track.
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
recordingStatus = "started"
|
||||
|
||||
} else if start {
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
// New method using new mp4 library
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
}
|
||||
pkt = nextPkt
|
||||
}
|
||||
@@ -571,29 +525,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
start = true
|
||||
}
|
||||
if start {
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): add video sample")
|
||||
if mp4Video != nil {
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
|
||||
}
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): add audio sample")
|
||||
if pkt.Codec == "AAC" {
|
||||
if mp4Video != nil {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
|
||||
}
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
// We might need to use ffmpeg to transcode the audio to AAC.
|
||||
// For now we will skip the audio track.
|
||||
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
}
|
||||
|
||||
pkt = nextPkt
|
||||
@@ -867,6 +799,41 @@ func convertPTS(v time.Duration) uint64 {
|
||||
return uint64(v.Milliseconds())
|
||||
}
|
||||
|
||||
// writeSampleToMP4 writes a single capture packet to the fragmented MP4.
|
||||
//
|
||||
// For video it derives the decode timestamp (DTS) from the packet PTS using the
|
||||
// per-packet composition offset (PTS - DTS), which is non-zero only for streams
|
||||
// that contain B-frames. Passing the monotonic DTS as the sample timestamp keeps
|
||||
// the fragment timeline (tfdt/sidx) monotonic, while the composition offset is
|
||||
// forwarded so frames are still presented in PTS order.
|
||||
func writeSampleToMP4(mp4Video *video.MP4, videoTrack, audioTrack uint32, pkt packets.Packet) {
|
||||
if mp4Video == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
|
||||
if pkt.IsVideo {
|
||||
compositionOffset := pkt.CompositionTime
|
||||
dts := pts
|
||||
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
|
||||
dts = pts - uint64(compositionOffset)
|
||||
}
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, dts, compositionOffset); err != nil {
|
||||
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts, 0); err != nil {
|
||||
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.writeSampleToMP4(): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*func convertPTS2(v int64) uint64 {
|
||||
return uint64(v) / 100
|
||||
}*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
@@ -446,6 +447,37 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
return status
|
||||
}
|
||||
|
||||
// packetAgeString returns a human readable age (e.g. "12s") since the last
|
||||
// packet timestamp stored in the given atomic.Value, or "unknown" when no
|
||||
// packet has been received yet. Used to add context to watchdog restart logs.
|
||||
func packetAgeString(timer *atomic.Value) string {
|
||||
if timer == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// atomic.Value panics on Load() if it was never initialized via Store().
|
||||
var v any
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
v = nil
|
||||
}
|
||||
}()
|
||||
v = timer.Load()
|
||||
}()
|
||||
|
||||
last, ok := v.(int64)
|
||||
if !ok || last == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
age := time.Now().Unix() - last
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return strconv.FormatInt(age, 10) + "s"
|
||||
}
|
||||
|
||||
// ControlAgent will check if the camera is still connected, if not it will restart the agent.
|
||||
// In the other thread we are keeping track of the number of packets received, and particular the keyframe packets.
|
||||
// Once we are not receiving any packets anymore, we will restart the agent.
|
||||
@@ -480,7 +512,8 @@ func ControlAgent(communication *models.Communication) {
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurence == 3 {
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream.")
|
||||
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream. (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
|
||||
packetsR, packetAgeString(communication.LastPacketTimer), communication.IsConfiguring.IsSet()))
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
|
||||
@@ -507,6 +540,8 @@ func ControlAgent(communication *models.Communication) {
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurenceSub == 3 {
|
||||
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): substream stalled (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
|
||||
packetsSubR, packetAgeString(communication.LastPacketTimerSub), communication.IsConfiguring.IsSet()))
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
|
||||
@@ -752,10 +787,24 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
|
||||
// @Description Get the current configuration.
|
||||
// @Success 200
|
||||
func GetConfig(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
|
||||
// We'll try to get a snapshot from the camera.
|
||||
base64Image := capture.Base64Image(captureDevice, communication, configuration)
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
// We'll try to get a fresh snapshot from the camera. Capturing a snapshot
|
||||
// reads a keyframe from the live stream, which blocks until one arrives.
|
||||
// When the camera is offline or the stream is stalled (no packets being
|
||||
// received) this would block the /config endpoint indefinitely, making the
|
||||
// agent appear unreachable even though its HTTP server is healthy. We
|
||||
// therefore bound the snapshot fetch with a short timeout and fall back to
|
||||
// the last cached snapshot, so /config always responds promptly.
|
||||
snapshot := make(chan string, 1)
|
||||
go func() {
|
||||
snapshot <- capture.Base64Image(captureDevice, communication, configuration)
|
||||
}()
|
||||
select {
|
||||
case base64Image := <-snapshot:
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
log.Log.Info("components.Kerberos.GetConfig(): snapshot timed out (stream stalled or camera offline), returning configuration with the last cached snapshot.")
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
|
||||
@@ -215,7 +215,18 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
configuration.GlobalConfig = globalWrap.Config
|
||||
|
||||
// Parse the per-agent (custom) configuration from the AGENT_* variables.
|
||||
customWrap := &models.Configuration{Config: configuration.CustomConfig}
|
||||
// In ConfigMap mode the per-agent overrides are delivered exclusively
|
||||
// through AGENT_* environment variables, so we must start from an empty
|
||||
// configuration rather than the bundled config.json that OpenConfig loaded
|
||||
// into CustomConfig. Otherwise defaults from that file (e.g. cloud="s3")
|
||||
// would leak into the custom config and be mistaken for explicit per-agent
|
||||
// overrides, hiding inherited global settings (the factory edit page would
|
||||
// show the local default instead of the inherited global persistence).
|
||||
customBase := configuration.CustomConfig
|
||||
if isConfigMapMode() {
|
||||
customBase = models.Config{}
|
||||
}
|
||||
customWrap := &models.Configuration{Config: customBase}
|
||||
initConfigPointers(&customWrap.Config)
|
||||
applyAgentEnvVars(customWrap, "", false)
|
||||
configuration.CustomConfig = customWrap.Config
|
||||
@@ -233,6 +244,21 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
}
|
||||
}
|
||||
|
||||
// isConfigMapMode reports whether the agent is running in ConfigMap mode, i.e.
|
||||
// whether a global configuration layer is delivered separately through
|
||||
// GLOBAL_AGENT_* environment variables. In that mode the per-agent (custom)
|
||||
// configuration must be built solely from the AGENT_* overrides and must not be
|
||||
// seeded with the bundled config.json defaults, so that inherited global
|
||||
// settings remain distinguishable from explicit per-agent overrides.
|
||||
func isConfigMapMode() bool {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "GLOBAL_AGENT_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initConfigPointers ensures all pointer sub-structs are non-nil so that the
|
||||
// environment-variable parsing can assign into them without dereferencing a nil
|
||||
// pointer.
|
||||
|
||||
@@ -14,7 +14,7 @@ type Packet struct {
|
||||
IsKeyFrame bool // video packet is key frame
|
||||
Idx int8 // stream index in container format
|
||||
Codec string // codec name
|
||||
CompositionTime int64 // packet presentation time minus decode time for H264 B-Frame
|
||||
CompositionTime int64 // composition offset (PTS - DTS) in milliseconds, non-zero for H264/H265 B-frames
|
||||
Time int64 // packet decode time
|
||||
TimeLegacy time.Duration
|
||||
CurrentTime int64 // current time in milliseconds (UNIX timestamp)
|
||||
|
||||
@@ -266,7 +266,16 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, pts uint64) error {
|
||||
// AddSampleToTrack appends a sample to the given track.
|
||||
//
|
||||
// For video, pts is the decode timestamp (DTS, in milliseconds) and
|
||||
// compositionOffset is the composition time offset (PTS - DTS, in milliseconds).
|
||||
// The offset is non-zero only for streams that contain B-frames; it is written
|
||||
// as the sample's signed composition time offset so the decoder presents frames
|
||||
// in PTS order while the fragment timeline stays monotonic in DTS.
|
||||
//
|
||||
// For audio, pts is the sample timestamp and compositionOffset should be 0.
|
||||
func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, pts uint64, compositionOffset int64) error {
|
||||
|
||||
if isKeyframe && trackID == uint32(mp4.VideoTrack) {
|
||||
mp4.TotalKeyframesReceived++
|
||||
@@ -375,7 +384,7 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
|
||||
fullSample.Sample = mp4ff.Sample{
|
||||
Size: uint32(len(fullSample.Data)),
|
||||
Flags: flags,
|
||||
CompositionTimeOffset: 0, // No composition time offset for video
|
||||
CompositionTimeOffset: int32(compositionOffset), // PTS-DTS, non-zero for B-frames
|
||||
}
|
||||
mp4.VideoFullSample = &fullSample
|
||||
mp4.PendingSampleIsKeyframe = isKeyframe
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestMP4Duration(t *testing.T) {
|
||||
for i := 0; i < numFrames; i++ {
|
||||
pts := uint64(i) * frameDuration
|
||||
isKeyframe := i%gopSize == 0
|
||||
err := mp4Video.AddSampleToTrack(videoTrack, isKeyframe, makeFrame(isKeyframe), pts)
|
||||
err := mp4Video.AddSampleToTrack(videoTrack, isKeyframe, makeFrame(isKeyframe), pts, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("AddSampleToTrack failed at frame %d: %v", i, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user