Compare commits

..
9 changed files with 924 additions and 343 deletions
-5
View File
@@ -65,7 +65,6 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
### Contributing
1. [Security vulnerability reporting](#security-vulnerability-reporting)
1. [Contribute with Codespaces](#contribute-with-codespaces)
2. [Develop and build](#develop-and-build)
3. [Building from source](#building-from-source)
@@ -302,10 +301,6 @@ If we talk about video encoders and decoders (codecs) there are 2 major video co
Conclusion: depending on the use case you might choose one over the other, and you can use both at the same time. For example you can use H264 (main stream) for livestreaming, and H265 (sub stream) for recording. If you wish to play recordings in a cross-platform and cross-browser environment, you might opt for H264 for better support.
## Security vulnerability reporting
If you found a potential security vulnerability, please use the private channels described in [SECURITY.md](SECURITY.md). Avoid opening public GitHub issues for sensitive findings.
## Contribute with Codespaces
One of the major blockers for letting you contribute to an Open Source project is to set up your local development machine. Why? Because you might already have some tools and libraries installed that are used for other projects, and the libraries you would need for Kerberos Agent, for example FFmpeg, might require a different version. Welcome to dependency hell...
-40
View File
@@ -1,40 +0,0 @@
# Security Policy
## Supported Versions
We only provide security fixes for the latest release series on the `master` branch.
## Reporting a Vulnerability
Please do **not** open a public GitHub issue for potential security vulnerabilities.
Use one of the private channels below:
1. Preferred: GitHub private vulnerability reporting
- https://github.com/kerberos-io/agent/security/advisories/new
2. Fallback: Email
- support@kerberos.io
- Optional CC: support@uug.ai
Please include:
- A short summary and impact.
- Reproduction steps or proof of concept.
- Affected version(s), commit hash, or deployment details.
- Any proposed mitigation/workaround.
- Your preferred attribution name.
For faster triage, use this subject format in email:
`[Security][Kerberos Agent] <short title>`
## Response Expectations
- Acknowledgement target: within 3 business days.
- Triage/update target: within 7 business days after acknowledgement.
If you do not receive a response in time, please resend your report and include your original timestamp.
## Disclosure and Credits
We follow coordinated disclosure. After a fix is available, we will credit reporters unless they prefer to stay anonymous.
+1 -5
View File
@@ -22,8 +22,4 @@ https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d314
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io)
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
## Security reporting
For sensitive vulnerabilities, use private disclosure channels documented in [../SECURITY.md](../SECURITY.md).
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
+86
View File
@@ -339,6 +339,40 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
}
}
// Look for audio stream.
// find the LPCM media and format
audioFormaLPCM, audioMediLPCM := FindLPCM(desc, false)
g.AudioLPCMMedia = audioMediLPCM
g.AudioLPCMForma = audioFormaLPCM
if audioMediLPCM == nil {
log.Log.Debug("capture.golibrtsp.Connect(LPCM): " + "audio media not found")
} else {
_, err = g.Client.Setup(desc.BaseURL, audioMediLPCM, 0, 0)
if err != nil {
log.Log.Error("capture.golibrtsp.Connect(LPCM): " + err.Error())
} else {
audiortpDec, err := audioFormaLPCM.CreateDecoder()
if err != nil {
log.Log.Error("capture.golibrtsp.Connect(LPCM): " + err.Error())
} else {
g.AudioLPCMDecoder = audiortpDec
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "LPCM",
IsVideo: false,
IsAudio: true,
IsBackChannel: false,
SampleRate: audioFormaLPCM.SampleRate,
Channels: audioFormaLPCM.ChannelCount,
BitDepth: audioFormaLPCM.BitDepth,
})
g.AudioLPCMIndex = int8(len(g.Streams)) - 1
}
}
}
// Look for audio stream.
// find the G711 media and format
audioForma, audioMedi := FindPCMU(desc, false)
@@ -367,6 +401,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
IsVideo: false,
IsAudio: true,
IsBackChannel: false,
SampleRate: defaultPCMUSampleRate,
Channels: defaultPCMUChannels,
})
// Set the index for the audio
@@ -509,6 +545,8 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
IsVideo: false,
IsAudio: true,
IsBackChannel: true,
SampleRate: defaultPCMUSampleRate,
Channels: defaultPCMUChannels,
})
// Set the index for the audio
g.AudioG711IndexBackChannel = int8(len(g.Streams)) - 1
@@ -521,6 +559,39 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
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")
// called when a MULAW audio RTP packet arrives
if g.AudioLPCMMedia != nil && g.AudioLPCMForma != nil {
g.Client.OnPacketRTP(g.AudioLPCMMedia, g.AudioLPCMForma, func(rtppkt *rtp.Packet) {
pts, ok := g.Client.PacketPTS(g.AudioLPCMMedia, rtppkt)
pts2, ok := g.Client.PacketPTS2(g.AudioLPCMMedia, rtppkt)
if !ok {
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
op, err := g.AudioLPCMDecoder.Decode(rtppkt)
if err != nil {
log.Log.Error("capture.golibrtsp.Start(): " + err.Error())
return
}
pkt := packets.Packet{
IsKeyFrame: false,
Packet: rtppkt,
Data: op,
Time: pts2,
TimeLegacy: pts,
CompositionTime: pts2,
CurrentTime: time.Now().UnixMilli(),
Idx: g.AudioLPCMIndex,
IsVideo: false,
IsAudio: true,
Codec: "LPCM",
}
queue.WritePacket(pkt)
})
}
// 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) {
@@ -1259,6 +1330,21 @@ func FindPCMU(desc *description.Session, isBackChannel bool) (*format.G711, *des
return nil, nil
}
func FindLPCM(desc *description.Session, isBackChannel bool) (*format.LPCM, *description.Media) {
for _, media := range desc.Medias {
if media.IsBackChannel == isBackChannel {
for _, forma := range media.Formats {
if lpcm, ok := forma.(*format.LPCM); ok {
if lpcm.SampleRate > 0 && lpcm.ChannelCount > 0 && lpcm.BitDepth > 0 {
return lpcm, media
}
}
}
}
}
return nil, nil
}
func FindOPUS(desc *description.Session, isBackChannel bool) (*format.Opus, *description.Media) {
for _, media := range desc.Medias {
if media.IsBackChannel == isBackChannel {
+98 -63
View File
@@ -87,13 +87,31 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// We only expect one audio and one video codec.
// If there are multiple audio or video streams, we will use the first one.
audioCodec := ""
audioBitDepth := 0
videoCodec := ""
configuredAudioSampleRate := config.Capture.IPCamera.SampleRate
configuredAudioChannels := config.Capture.IPCamera.Channels
audioStreams, _ := rtspClient.GetAudioStreams()
videoStreams, _ := rtspClient.GetVideoStreams()
if len(audioStreams) > 0 {
audioCodec = audioStreams[0].Name
config.Capture.IPCamera.SampleRate = audioStreams[0].SampleRate
config.Capture.IPCamera.Channels = audioStreams[0].Channels
resolvedSampleRate := audioStreams[0].SampleRate
resolvedChannels := audioStreams[0].Channels
if audioCodec == "LPCM" {
if configuredAudioSampleRate > 0 && configuredAudioSampleRate != resolvedSampleRate {
log.Log.Warning("capture.main.HandleRecordStream(): LPCM sample rate mismatch between configuration and RTSP stream; using configured value " + strconv.Itoa(configuredAudioSampleRate) + " instead of detected value " + strconv.Itoa(resolvedSampleRate))
resolvedSampleRate = configuredAudioSampleRate
}
if configuredAudioChannels > 0 && configuredAudioChannels != resolvedChannels {
log.Log.Warning("capture.main.HandleRecordStream(): LPCM channel count mismatch between configuration and RTSP stream; using configured value " + strconv.Itoa(configuredAudioChannels) + " instead of detected value " + strconv.Itoa(resolvedChannels))
resolvedChannels = configuredAudioChannels
}
}
config.Capture.IPCamera.SampleRate = resolvedSampleRate
config.Capture.IPCamera.Channels = resolvedChannels
audioBitDepth = audioStreams[0].BitDepth
}
if len(videoStreams) > 0 {
videoCodec = videoStreams[0].Name
@@ -105,13 +123,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
//var cws *cacheWriterSeeker
var mp4Video *video.MP4
var videoTrack uint32
var audioTrack uint32
var audioWriter *recordingAudioWriter
var name string
// Do not do anything!
log.Log.Info("capture.main.HandleRecordStream(continuous): start recording")
start := false
rolloverRequested := false
rolloverMaxLogged := false
// If continuous record the full length
postRecording = maxRecordingPeriod
@@ -135,29 +155,42 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
nextPkt, cursorError = recordingCursor.ReadPacket()
now := time.Now().UnixMilli()
packetTime := pkt.CurrentTime
hardMaxReached := packetTime-startRecording > maxRecordingPeriod-500
postRecordingElapsed := startRecording+postRecording-packetTime <= 0
if start && (postRecordingElapsed || hardMaxReached) {
rolloverRequested = true
if hardMaxReached && !rolloverMaxLogged {
log.Log.Info("capture.main.HandleRecordStream(continuous): max recording period reached, waiting for next keyframe to roll over without dropping frames")
rolloverMaxLogged = true
}
}
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) {
closeOnCurrentKeyframe := rolloverRequested && pkt.IsKeyFrame && pkt.CurrentTime > startRecording
closeOnNextKeyframe := rolloverRequested && nextPkt.IsKeyFrame
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 {
if start && (closeOnCurrentKeyframe || closeOnNextKeyframe) {
if !closeOnCurrentKeyframe {
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
// Write the last packet before the rollover keyframe.
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 err := audioWriter.WritePacket(pkt); 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.")
}
}
if err := audioWriter.Flush(); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
audioWriter.Close()
audioWriter = nil
// Close mp4
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
@@ -177,6 +210,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Cleanup muxer
start = false
rolloverRequested = false
rolloverMaxLogged = false
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
@@ -305,11 +340,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
audioWriter = newRecordingAudioWriter(mp4Video, audioCodec, config.Capture.IPCamera.SampleRate, config.Capture.IPCamera.Channels, audioBitDepth, "capture.main.HandleRecordStream(continuous)")
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
@@ -317,15 +348,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
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.")
if err := audioWriter.WritePacket(pkt); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
}
recordingStatus = "started"
@@ -339,13 +363,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
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.")
if err := audioWriter.WritePacket(pkt); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
}
}
@@ -355,6 +374,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// We might have interrupted the recording while restarting the agent.
// If this happens we need to check to properly close the recording.
if cursorError != nil {
if audioWriter != nil {
audioWriter.Close()
audioWriter = nil
}
if recordingStatus == "started" {
log.Log.Info("capture.main.HandleRecordStream(continuous): Recording finished: file save: " + name)
@@ -434,7 +457,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
var displayTime int64 = 0 // display time in milliseconds
var videoTrack uint32
var audioTrack uint32
for motion := range communication.HandleMotion {
@@ -448,6 +470,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
motionTimestamp := now
start := false
rolloverRequested := false
rolloverMaxLogged := false
if cursorError == nil {
pkt, cursorError = recordingCursor.ReadPacket()
@@ -520,6 +544,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
// Create the MP4 only once the first keyframe arrives.
var mp4Video *video.MP4
var audioWriter *recordingAudioWriter
for cursorError == nil {
@@ -528,20 +553,33 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + cursorError.Error())
}
now = time.Now().UnixMilli()
select {
case motion := <-communication.HandleMotion:
motionTimestamp = now
motionTimestamp = pkt.CurrentTime
log.Log.Info("capture.main.HandleRecordStream(motiondetection): motion detected while recording. Expanding recording.")
numberOfChanges := motion.NumberOfChanges
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Received message with recording data, detected changes to save: " + strconv.Itoa(numberOfChanges))
default:
}
if start && (motionTimestamp+postRecording-now < 0 || now-startRecording > maxRecordingPeriod-500) && nextPkt.IsKeyFrame {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-now < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-now, 10) + " < 0")
log.Log.Info("capture.main.HandleRecordStream(motiondetection): now-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(now-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(motionTimestamp, 10) + ", postRecording: " + strconv.FormatInt(postRecording, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
packetTime := pkt.CurrentTime
hardMaxReached := packetTime-startRecording > maxRecordingPeriod-500
postRecordingElapsed := motionTimestamp+postRecording-packetTime < 0
if start && (postRecordingElapsed || hardMaxReached) {
rolloverRequested = true
if hardMaxReached && !rolloverMaxLogged {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): max recording period reached, waiting for next keyframe to close without dropping frames")
rolloverMaxLogged = true
}
}
closeOnCurrentKeyframe := rolloverRequested && pkt.IsKeyFrame && pkt.CurrentTime > startRecording
closeOnNextKeyframe := rolloverRequested && nextPkt.IsKeyFrame
if start && (closeOnCurrentKeyframe || closeOnNextKeyframe) {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-packetTime < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-packetTime, 10) + " < 0")
log.Log.Info("capture.main.HandleRecordStream(motiondetection): packetTime-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(packetTime-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(motionTimestamp, 10) + ", postRecording: " + strconv.FormatInt(postRecording, 10) + ", packetTime: " + strconv.FormatInt(packetTime, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
break
}
if pkt.IsKeyFrame && !start && pkt.CurrentTime >= startRecording {
@@ -550,7 +588,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): write frames")
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): recording started on keyframe")
// Align duration timers with the first keyframe.
// Align duration timers with the first keyframe so audio/video start together.
startRecording = pkt.CurrentTime
// Create a video file, and set the dimensions.
@@ -563,11 +601,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
audioWriter = newRecordingAudioWriter(mp4Video, audioCodec, config.Capture.IPCamera.SampleRate, config.Capture.IPCamera.Channels, audioBitDepth, "capture.main.HandleRecordStream(motiondetection)")
start = true
}
if start {
@@ -581,17 +615,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
} 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())
}
if mp4Video != nil {
if err := audioWriter.WritePacket(pkt); 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.")
}
}
}
@@ -604,10 +631,18 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
lastRecordingTime = pkt.CurrentTime
if mp4Video == nil {
if audioWriter != nil {
audioWriter.Close()
}
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): recording closed without keyframe; no MP4 created")
continue
}
if err := audioWriter.Flush(); err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
}
audioWriter.Close()
// This will close the recording and write the last packet.
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
@@ -0,0 +1,566 @@
package capture
import (
"bytes"
"errors"
"io"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/video"
)
const (
defaultPCMUSampleRate = 8000
defaultPCMUChannels = 1
)
type audioToAACTranscoder interface {
Transcode([]byte) ([]byte, error)
Flush() ([]byte, error)
Close()
}
func PCMUToAACTranscodingAvailable() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
type ffmpegToAACTranscoder struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr *bytes.Buffer
mu sync.Mutex
outMu sync.Mutex
outBuf bytes.Buffer
adtsBuf []byte
closed bool
stdinClosed bool
closeOnce sync.Once
stdoutDone chan struct{}
waitDone chan struct{}
waitErr error
}
func newFFmpegToAACTranscoder(inputFormat string, sampleRate int, channels int) (*ffmpegToAACTranscoder, error) {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, errors.New("audio to AAC transcoding not available: ffmpeg binary not found in PATH")
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-fflags", "+nobuffer",
"-flags", "low_delay",
"-f", inputFormat,
"-ar", intToString(sampleRate),
"-ac", intToString(channels),
"-i", "pipe:0",
"-vn",
"-ac", "1",
"-ar", intToString(sampleRate),
"-c:a", "aac",
"-profile:a", "aac_low",
"-b:a", "32k",
"-f", "adts",
"pipe:1",
)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr := &bytes.Buffer{}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
return nil, err
}
t := &ffmpegToAACTranscoder{
cmd: cmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
stdoutDone: make(chan struct{}),
waitDone: make(chan struct{}),
}
go func() {
defer close(t.stdoutDone)
buf := make([]byte, 4096)
for {
n, readErr := stdout.Read(buf)
if n > 0 {
t.outMu.Lock()
_, _ = t.outBuf.Write(buf[:n])
t.outMu.Unlock()
}
if readErr != nil {
if readErr != io.EOF {
log.Log.Warning("capture.pcmu_to_aac: stdout reader stopped: " + readErr.Error())
}
return
}
}
}()
go func() {
t.waitErr = cmd.Wait()
close(t.waitDone)
}()
log.Log.Info("capture.audio_to_aac: " + strings.ToUpper(inputFormat) + " -> AAC transcoder initialised (ffmpeg process)")
return t, nil
}
func NewPCMUToAACTranscoder(sampleRate int, channels int) (*ffmpegToAACTranscoder, error) {
if sampleRate <= 0 {
sampleRate = defaultPCMUSampleRate
}
if channels <= 0 {
channels = defaultPCMUChannels
}
return newFFmpegToAACTranscoder("mulaw", sampleRate, channels)
}
func NewLPCMToAACTranscoder(sampleRate int, channels int, bitDepth int) (*ffmpegToAACTranscoder, error) {
inputFormat, err := lpcmFFmpegInputFormat(bitDepth)
if err != nil {
return nil, err
}
if sampleRate <= 0 {
return nil, errors.New("LPCM to AAC transcoding requires a valid sample rate")
}
if channels <= 0 {
return nil, errors.New("LPCM to AAC transcoding requires a valid channel count")
}
return newFFmpegToAACTranscoder(inputFormat, sampleRate, channels)
}
func (t *ffmpegToAACTranscoder) Transcode(input []byte) ([]byte, error) {
if t == nil || len(input) == 0 {
return nil, nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return nil, errors.New("audio to AAC transcoder is closed")
}
if t.stdinClosed {
return nil, errors.New("audio to AAC transcoder input is closed")
}
if _, err := t.stdin.Write(input); err != nil {
return nil, err
}
// Do not block the recording loop waiting for the encoder to emit output.
// FFmpeg can buffer for a while, and polling here per RTP packet causes the
// recorder to fall behind real time and drop trailing media before close.
return t.readAvailable(), nil
}
func (t *ffmpegToAACTranscoder) Flush() ([]byte, error) {
if t == nil {
return nil, nil
}
t.mu.Lock()
if t.closed {
defer t.mu.Unlock()
return t.readAvailable(), nil
}
if !t.stdinClosed && t.stdin != nil {
if err := t.stdin.Close(); err != nil {
t.mu.Unlock()
return nil, err
}
t.stdinClosed = true
}
t.mu.Unlock()
processExited := false
readerFinished := false
deadline := time.Now().Add(15 * time.Second)
timedOut := false
for !processExited || !readerFinished {
if !processExited {
select {
case <-t.waitDone:
processExited = true
default:
}
}
if !readerFinished {
select {
case <-t.stdoutDone:
readerFinished = true
default:
}
}
if processExited && readerFinished {
break
}
if time.Now().After(deadline) {
timedOut = true
break
}
time.Sleep(15 * time.Millisecond)
}
if timedOut {
log.Log.Warning("capture.audio_to_aac: flush timed out before ffmpeg fully drained (process_exited=" + strconv.FormatBool(processExited) + ", stdout_done=" + strconv.FormatBool(readerFinished) + ", buffered=" + intToString(t.bufferedLen()) + ")")
}
if processExited && t.waitErr != nil {
return t.readAvailable(), t.waitErr
}
return t.readAvailable(), nil
}
func (t *ffmpegToAACTranscoder) Close() {
if t == nil {
return
}
t.closeOnce.Do(func() {
t.mu.Lock()
t.closed = true
if t.stdin != nil && !t.stdinClosed {
_ = t.stdin.Close()
t.stdinClosed = true
}
t.mu.Unlock()
processExited := false
waitTimeout := 250 * time.Millisecond
if t.stdinClosed {
waitTimeout = 2 * time.Second
}
select {
case <-t.waitDone:
processExited = true
case <-time.After(waitTimeout):
}
if !processExited {
if t.stdout != nil {
_ = t.stdout.Close()
}
if t.cmd != nil && t.cmd.Process != nil {
_ = t.cmd.Process.Kill()
<-t.waitDone
}
}
if stderr := t.stderrString(); stderr != "" {
log.Log.Info("capture.audio_to_aac: ffmpeg stderr on close: " + stderr)
}
})
}
func (t *ffmpegToAACTranscoder) readAvailable() []byte {
t.outMu.Lock()
defer t.outMu.Unlock()
if t.outBuf.Len() > 0 {
t.adtsBuf = append(t.adtsBuf, t.outBuf.Bytes()...)
t.outBuf.Reset()
}
return drainCompleteADTSFrames(&t.adtsBuf)
}
func (t *ffmpegToAACTranscoder) bufferedLen() int {
t.outMu.Lock()
defer t.outMu.Unlock()
return t.outBuf.Len()
}
func (t *ffmpegToAACTranscoder) stderrString() string {
if t == nil || t.stderr == nil {
return ""
}
return strings.TrimSpace(t.stderr.String())
}
func lpcmFFmpegInputFormat(bitDepth int) (string, error) {
switch bitDepth {
case 8:
return "u8", nil
case 16:
return "s16be", nil
case 24:
return "s24be", nil
default:
return "", errors.New("unsupported LPCM bit depth: " + intToString(bitDepth))
}
}
type recordingAudioWriter struct {
mp4 *video.MP4
trackID uint32
transcoder audioToAACTranscoder
lastPTS uint64
logPrefix string
transcodedSampleRate int
aacBasePTS uint64
aacFrameCursor uint64
aacClockStarted bool
loggedAACParams bool
}
func newRecordingAudioWriter(mp4Video *video.MP4, audioCodec string, sampleRate int, channels int, bitDepth int, logPrefix string) *recordingAudioWriter {
writer := &recordingAudioWriter{
mp4: mp4Video,
logPrefix: logPrefix,
}
switch audioCodec {
case "AAC":
writer.trackID = mp4Video.AddAudioTrack("AAC")
case "PCM_MULAW":
if sampleRate <= 0 {
sampleRate = defaultPCMUSampleRate
}
if channels <= 0 {
channels = defaultPCMUChannels
}
if !PCMUToAACTranscodingAvailable() {
log.Log.Warning(logPrefix + ": ffmpeg not available, skipping PCM_MULAW audio recording.")
return writer
}
transcoder, err := NewPCMUToAACTranscoder(sampleRate, channels)
if err != nil {
log.Log.Error(logPrefix + ": failed to create PCM_MULAW to AAC transcoder: " + err.Error())
return writer
}
writer.trackID = mp4Video.AddAudioTrack("AAC")
writer.transcoder = transcoder
writer.transcodedSampleRate = sampleRate
log.Log.Info(logPrefix + ": recording PCM_MULAW audio as AAC (input_rate=" + intToString(sampleRate) + ", channels=" + intToString(channels) + ").")
case "LPCM":
transcoder, err := NewLPCMToAACTranscoder(sampleRate, channels, bitDepth)
if err != nil {
log.Log.Error(logPrefix + ": failed to create LPCM to AAC transcoder: " + err.Error())
return writer
}
writer.trackID = mp4Video.AddAudioTrack("AAC")
writer.transcoder = transcoder
writer.transcodedSampleRate = sampleRate
log.Log.Info(logPrefix + ": recording LPCM audio as AAC (input_rate=" + intToString(sampleRate) + ", channels=" + intToString(channels) + ", bit_depth=" + intToString(bitDepth) + ").")
}
return writer
}
func (w *recordingAudioWriter) TrackID() uint32 {
if w == nil {
return 0
}
return w.trackID
}
func (w *recordingAudioWriter) WritePacket(pkt packets.Packet) error {
if w == nil || w.mp4 == nil || !pkt.IsAudio || w.trackID == 0 {
return nil
}
pts := convertPTS(pkt.TimeLegacy)
if pts > 0 {
w.lastPTS = pts
}
switch pkt.Codec {
case "AAC":
return w.mp4.AddSampleToTrack(w.trackID, pkt.IsKeyFrame, pkt.Data, pts)
case "PCM_MULAW", "LPCM":
if w.transcoder == nil {
return nil
}
if !w.aacClockStarted {
w.aacBasePTS = pts
w.aacClockStarted = true
}
adts, err := w.transcoder.Transcode(pkt.Data)
if err != nil {
return err
}
if len(adts) == 0 {
return nil
}
return w.writeTranscodedADTS(adts)
default:
return nil
}
}
func (w *recordingAudioWriter) Flush() error {
if w == nil || w.transcoder == nil || w.mp4 == nil || w.trackID == 0 {
return nil
}
adts, err := w.transcoder.Flush()
if err != nil {
return err
}
if len(adts) == 0 {
return nil
}
return w.writeTranscodedADTS(adts)
}
func (w *recordingAudioWriter) Close() {
if w != nil && w.transcoder != nil {
w.transcoder.Close()
w.transcoder = nil
}
}
func (w *recordingAudioWriter) writeTranscodedADTS(adts []byte) error {
if w == nil || w.mp4 == nil || w.trackID == 0 || len(adts) == 0 {
return nil
}
if w.transcodedSampleRate <= 0 {
return errors.New("transcoded AAC sample rate is not set")
}
var writeErr error
video.SplitAACFrame(adts, func(started bool, aac []byte) {
if writeErr != nil || len(aac) < 7 {
return
}
if !w.loggedAACParams {
log.Log.Info(w.logPrefix + ": first AAC frame parameters (aac_rate=" + intToString(int(video.AACSampleRateFromADTS(aac))) + ", channels=" + intToString(int(video.AACChannelCountFromADTS(aac))) + ", samples_per_frame=" + intToString(aacSamplesPerFrame(aac)) + ").")
w.loggedAACParams = true
}
pts := w.transcodedPTS()
if err := w.mp4.AddSampleToTrack(w.trackID, false, aac, pts); err != nil {
writeErr = err
return
}
w.lastPTS = pts
w.aacFrameCursor += uint64(aacSamplesPerFrame(aac))
})
return writeErr
}
func (w *recordingAudioWriter) transcodedPTS() uint64 {
if w == nil {
return 0
}
if !w.aacClockStarted {
w.aacClockStarted = true
}
return w.aacBasePTS + (w.aacFrameCursor*1000)/uint64(w.transcodedSampleRate)
}
func aacSamplesPerFrame(aac []byte) int {
if len(aac) < 7 {
return 1024
}
rawBlocks := int(aac[6]&0x03) + 1
return rawBlocks * 1024
}
func intToString(v int) string {
return strconv.Itoa(v)
}
func drainCompleteADTSFrames(buffer *[]byte) []byte {
if buffer == nil || len(*buffer) == 0 {
return nil
}
data := *buffer
start := video.FindSyncword(data, 0)
if start < 0 {
// Keep the tail in case the syncword is split across reads.
if len(data) > 1 {
*buffer = append([]byte{}, data[len(data)-1:]...)
}
return nil
}
if start > 0 {
data = data[start:]
}
var out bytes.Buffer
offset := 0
for {
if len(data[offset:]) < 7 {
break
}
var adts video.ADTS_Frame_Header
adts.Decode(data[offset:])
frameLen := int(adts.Variable_Header.Frame_length)
if frameLen < 7 {
next := video.FindSyncword(data, offset+1)
if next < 0 {
break
}
offset = next
continue
}
if offset+frameLen > len(data) {
break
}
_, _ = out.Write(data[offset : offset+frameLen])
offset += frameLen
next := video.FindSyncword(data, offset)
if next < 0 {
break
}
if next > offset {
offset = next
}
}
if offset < len(data) {
*buffer = append([]byte{}, data[offset:]...)
} else {
*buffer = nil
}
if out.Len() == 0 {
return nil
}
return out.Bytes()
}
+3
View File
@@ -49,6 +49,9 @@ type Stream struct {
// Channels is the number of audio channels.
Channels int
// BitDepth is the number of bits per audio sample for PCM-based streams.
BitDepth int
// GopSize is the size of the GOP (Group of Pictures).
GopSize int
}
+170 -128
View File
@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/Eyevinn/mp4ff/aac"
"github.com/Eyevinn/mp4ff/avc"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/encryption"
@@ -32,24 +33,6 @@ const MacEpochOffset uint64 = 2082844800
// resulting in ~3 second fragments (assuming a typical GOP interval).
const FragmentDurationMs = 3000
// MinNormalGOPMs is the maximum spacing between two consecutive IDRs that
// we still consider an anomalous "loop/restart seam". When two keyframes
// arrive closer than this, we treat the second one as an upstream
// restart/loop-seam and force a fresh fragment so the seam IDR cannot end
// up as a mid-fragment sync sample. The check only runs when the current
// fragment has not yet reached FragmentDurationMs.
//
// This must be set well below the smallest plausible *legitimate* GOP
// length. Typical IP cameras use GOP intervals of 1000-2000 ms, and the
// arrival timing of consecutive IDRs can jitter by a few hundred ms due to
// network/RTSP buffering. A threshold close to 1 s (e.g. 950) caused
// false positives on cameras with ~1 s GOPs (warnings like
// "gap=800 ms / 300 ms / 200 ms" while the stream itself was healthy).
// 400 ms is comfortably below any realistic GOP yet still catches the
// virtual-rtsp / ffmpeg loop-seam pattern (seam IDRs typically arrive
// 100-200 ms after the prior IDR).
const MinNormalGOPMs = 400
type MP4 struct {
// FileName is the name of the file
FileName string
@@ -64,9 +47,12 @@ type MP4 struct {
SegmentCount int
SampleCount int
StartPTS uint64
MediaStartPTS uint64
VideoTotalDuration uint64
AudioTotalDuration uint64
AudioPTS uint64
AudioSampleRate uint32
AudioChannels uint16
Start bool
SPSNALUs [][]byte // SPS NALUs for H264
PPSNALUs [][]byte // PPS NALUs for H264
@@ -74,8 +60,6 @@ type MP4 struct {
FreeBoxSize int64
FragmentStartRawPTS uint64 // Raw PTS for timing when to flush fragments
FragmentStartDTS uint64 // Accumulated VideoTotalDuration at fragment start (matches tfdt)
LastKeyframeRawPTS uint64 // Raw PTS of the most recently seen keyframe (in any fragment)
LastKeyframeGapMs uint64 // Gap (ms) between the previous two consecutive keyframes
MoofBoxes int64 // Number of moof boxes in the file
MoofBoxSizes []int64 // Sizes of each moof box
SegmentDurations []uint64 // Duration of each segment in timescale units
@@ -253,24 +237,6 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
var duration uint64
if nextPTS > 0 && nextPTS > mp4.VideoFullSample.DecodeTime {
duration = nextPTS - mp4.VideoFullSample.DecodeTime
// Guard against forward PTS jumps (e.g. when looping a source MP4
// through virtual-rtsp the upstream ffmpeg may insert a large offset
// at the loop boundary, or the RTSP stream may stall briefly).
// Without this clamp the sample gets a huge duration which appears
// as a discontinuity in the trun/sidx/mvhd and causes browsers
// (Video.js / MSE) to abort playback with a "media corruption"
// error around the loop boundary.
var maxPlausible uint64 = 1000 // 1 second hard ceiling
if mp4.LastVideoSampleDTS > 0 && mp4.LastVideoSampleDTS*10 < maxPlausible {
maxPlausible = mp4.LastVideoSampleDTS * 10
}
if duration > maxPlausible {
log.Log.Warning(fmt.Sprintf("mp4.flushPendingVideoSample(): video PTS jumped forward (nextPTS=%d, prevDTS=%d, gap=%d ms) - clamping to %d ms (likely source loop/stall discontinuity)", nextPTS, mp4.VideoFullSample.DecodeTime, duration, maxPlausible))
duration = mp4.LastVideoSampleDTS
if duration == 0 {
duration = 33
}
}
} else {
// No valid nextPTS (Close case) or PTS went backwards (jitter/discontinuity)
if nextPTS > 0 {
@@ -304,6 +270,49 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
return true
}
func (mp4 *MP4) alignAudioTimeline(rawPTS uint64) {
if mp4 == nil || mp4.AudioSampleRate == 0 || mp4.AudioPTS != 0 {
return
}
if rawPTS <= mp4.MediaStartPTS {
return
}
offsetMs := rawPTS - mp4.MediaStartPTS
mp4.AudioPTS = (offsetMs*uint64(mp4.AudioSampleRate) + 500) / 1000
}
func (mp4 *MP4) appendPendingAudioSample(trackID uint32) {
if mp4 == nil || mp4.AudioFullSample == nil {
return
}
SplitAACFrame(mp4.AudioFullSample.Data, func(started bool, aac []byte) {
sampleToAdd := *mp4.AudioFullSample
dts := aacFrameDurationSamples(aac)
if dts == 0 {
dts = mp4.LastAudioSampleDTS
if dts == 0 {
dts = 1024
}
}
mp4.alignAudioTimeline(sampleToAdd.DecodeTime)
mp4.LastAudioSampleDTS = dts
sampleToAdd.Data = aac[7:]
sampleToAdd.DecodeTime = mp4.AudioPTS
sampleToAdd.Sample.Dur = uint32(dts)
sampleToAdd.Sample.Size = uint32(len(aac[7:]))
mp4.AudioTotalDuration += dts
mp4.AudioPTS += dts
err := mp4.MultiTrackFragment.AddFullSampleToTrack(sampleToAdd, trackID)
if err != nil {
log.Log.Error("mp4.appendPendingAudioSample(): error adding sample to track " + fmt.Sprintf("%d: %v", trackID, err))
}
})
}
func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, pts uint64) error {
if isKeyframe && trackID == uint32(mp4.VideoTrack) {
@@ -327,37 +336,6 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
}
shouldFlush := !mp4.Start || elapsed >= FragmentDurationMs
// Detect upstream source-loop / restart discontinuity. When an MP4 is
// looped through virtual-rtsp (ffmpeg `-stream_loop -1 -re`) the loop
// seam emits a fresh IDR much sooner than a normal GOP would. PTS keeps
// growing monotonically, so the timing-only `elapsed` check above does
// not catch it and the seam IDR ends up as a mid-fragment sync sample.
// MSE-based players (Video.js / Chromium / Firefox) reject the resulting
// fragment with a "media corruption" error because the inner IDR resets
// frame_num/POC inside what they expect to be a single GOP. Force a
// fragment boundary whenever two consecutive keyframes arrive much
// closer than a normal GOP.
//
// We only flag this as a seam when it is a *sudden* anomaly: the
// previous keyframe gap must have been healthy (>= MinNormalGOPMs).
// This avoids false positives on cameras that legitimately emit
// short-interval IDRs (short GOP, motion-triggered recovery IDRs,
// all-intra streams) where every keyframe would otherwise be flagged
// in a cascade, producing many tiny fragments and log spam.
if trackID == uint32(mp4.VideoTrack) && mp4.Start &&
mp4.LastKeyframeRawPTS > 0 && pts > mp4.LastKeyframeRawPTS {
gap := pts - mp4.LastKeyframeRawPTS
if !shouldFlush && gap < MinNormalGOPMs &&
(mp4.LastKeyframeGapMs == 0 || mp4.LastKeyframeGapMs >= MinNormalGOPMs) {
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): forcing fragment flush at unexpectedly close keyframe (gap=%d ms, fragment elapsed=%d ms) - likely upstream loop/restart discontinuity", gap, elapsed))
shouldFlush = true
}
mp4.LastKeyframeGapMs = gap
}
if trackID == uint32(mp4.VideoTrack) {
mp4.LastKeyframeRawPTS = pts
}
if shouldFlush {
// Write the previous segment to the file
if mp4.Start {
@@ -389,6 +367,9 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
// Increment the segment count
mp4.SegmentCount = mp4.SegmentCount + 1
if mp4.MediaStartPTS == 0 {
mp4.MediaStartPTS = pts
}
// Create a new media segment
seg := mp4ff.NewMediaSegment()
@@ -452,37 +433,7 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
}
} else if trackID == uint32(mp4.AudioTrack) {
if mp4.AudioFullSample != nil {
SplitAACFrame(mp4.AudioFullSample.Data, func(started bool, aac []byte) {
sampleToAdd := *mp4.AudioFullSample
dts := pts - mp4.AudioFullSample.DecodeTime
if pts < mp4.AudioFullSample.DecodeTime {
//log.Printf("Warning: PTS %d is less than previous sample's DecodeTime %d, resetting AudioFullSample", pts, mp4.AudioFullSample.DecodeTime)
dts = 1
}
if started {
dts = 1
}
// Guard against forward PTS jumps (e.g. virtual-rtsp loop
// boundary or upstream stalls). Without this clamp the
// audio trun would carry an enormous sample duration that
// renders the recording unplayable in browsers.
if mp4.LastAudioSampleDTS > 0 && dts > mp4.LastAudioSampleDTS*10 {
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): audio PTS jumped forward (pts=%d, prevDTS=%d, gap=%d) - clamping to last known duration", pts, mp4.AudioFullSample.DecodeTime, dts))
dts = mp4.LastAudioSampleDTS
}
mp4.LastAudioSampleDTS = dts
//fmt.Printf("Adding sample to track %d, PTS: %d, Duration: %d, size: %d\n", trackID, pts, dts, len(aac[7:]))
mp4.AudioTotalDuration += dts
mp4.AudioPTS += dts
sampleToAdd.Data = aac[7:] // Remove the ADTS header (first 7 bytes)
sampleToAdd.DecodeTime = mp4.AudioPTS - dts
sampleToAdd.Sample.Dur = uint32(dts)
sampleToAdd.Sample.Size = uint32(len(aac[7:]))
err := mp4.MultiTrackFragment.AddFullSampleToTrack(sampleToAdd, trackID)
if err != nil {
log.Log.Error("mp4.AddSampleToTrack(): error adding sample to track " + fmt.Sprintf("%d: %v", trackID, err))
}
})
mp4.appendPendingAudioSample(trackID)
}
// Set the sample data
@@ -495,6 +446,12 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
Flags: 0,
CompositionTimeOffset: 0, // No composition time offset for audio
}
if mp4.AudioSampleRate == 0 {
mp4.AudioSampleRate = aacSampleRate(data)
}
if mp4.AudioChannels == 0 {
mp4.AudioChannels = aacChannelCount(data)
}
mp4.AudioFullSample = &fullSample
mp4.SampleType = "audio"
}
@@ -524,23 +481,7 @@ func (mp4 *MP4) Close(config *models.Config) {
// Add final audio sample if pending
if mp4.AudioFullSample != nil && mp4.AudioTrack > 0 {
SplitAACFrame(mp4.AudioFullSample.Data, func(started bool, aac []byte) {
sampleToAdd := *mp4.AudioFullSample
dts := mp4.LastAudioSampleDTS
if dts == 0 {
dts = 1024 // Default AAC frame duration
}
mp4.AudioTotalDuration += dts
mp4.AudioPTS += dts
sampleToAdd.Data = aac[7:]
sampleToAdd.DecodeTime = mp4.AudioPTS - dts
sampleToAdd.Sample.Dur = uint32(dts)
sampleToAdd.Sample.Size = uint32(len(aac[7:]))
err := mp4.MultiTrackFragment.AddFullSampleToTrack(sampleToAdd, uint32(mp4.AudioTrack))
if err != nil {
log.Log.Error("mp4.Close(): error adding final audio sample: " + err.Error())
}
})
mp4.appendPendingAudioSample(uint32(mp4.AudioTrack))
mp4.AudioFullSample = nil
}
}
@@ -603,15 +544,26 @@ func (mp4 *MP4) Close(config *models.Config) {
// QuickTime requires timestamps in Mac HFS format (seconds since 1904-01-01),
// so we convert from Unix epoch by adding MacEpochOffset.
videoTimescale := uint32(1000)
audioTimescale := uint32(1000)
audioTimescale := mp4.AudioSampleRate
if audioTimescale == 0 {
if config.Capture.IPCamera.SampleRate > 0 {
audioTimescale = uint32(config.Capture.IPCamera.SampleRate)
} else {
audioTimescale = uint32(1000)
}
}
macTime := mp4.StartTime + MacEpochOffset
nextTrackID := uint32(len(mp4.TrackIDs) + 1)
audioMovieDuration := mp4.AudioTotalDuration
if audioTimescale != 0 && audioTimescale != videoTimescale {
audioMovieDuration = (mp4.AudioTotalDuration*uint64(videoTimescale) + uint64(audioTimescale/2)) / uint64(audioTimescale)
}
// mvhd.Duration must be the duration of the longest track.
// Start with video; if audio is longer, we update below.
movDuration := actualVideoDuration
if mp4.AudioTotalDuration > movDuration {
movDuration = mp4.AudioTotalDuration
if audioMovieDuration > movDuration {
movDuration = audioMovieDuration
}
mvhd := &mp4ff.MvhdBox{
@@ -686,16 +638,23 @@ func (mp4 *MP4) Close(config *models.Config) {
// Add an audio track to the moov box
init.AddEmptyTrack(audioTimescale, "audio", "und")
// Check if the same sample rate is set, otherwise we default to 48000
audioSampleRate := 48000
if config.Capture.IPCamera.SampleRate > 0 {
audioSampleRate = config.Capture.IPCamera.SampleRate
audioSampleRate := int(audioTimescale)
if audioSampleRate == 0 {
audioSampleRate = 48000
}
// Set the audio descriptor
err := init.Moov.Traks[1].SetAACDescriptor(29, audioSampleRate)
audioChannels := mp4.AudioChannels
if audioChannels == 0 {
if config.Capture.IPCamera.Channels > 0 {
audioChannels = uint16(config.Capture.IPCamera.Channels)
} else {
audioChannels = 1
}
}
// Set the audio descriptor to match the AAC-LC stream actually produced by ffmpeg/camera.
err := setAACLCDescriptor(init.Moov.Traks[1], audioSampleRate, audioChannels)
if err != nil {
}
init.Moov.Traks[1].Tkhd.Duration = mp4.AudioTotalDuration
init.Moov.Traks[1].Tkhd.Duration = audioMovieDuration
init.Moov.Traks[1].Tkhd.CreationTime = macTime
init.Moov.Traks[1].Tkhd.ModificationTime = macTime
init.Moov.Traks[1].Mdia.Hdlr.Name = "agent " + utils.VERSION
@@ -1338,7 +1297,7 @@ func (frame *ADTS_Frame_Header) Decode(aac []byte) {
frame.Fix_Header.Profile = aac[2] >> 6 & 0x03
frame.Fix_Header.Sampling_frequency_index = aac[2] >> 2 & 0x0F
frame.Fix_Header.Private_bit = aac[2] >> 1 & 0x01
frame.Fix_Header.Channel_configuration = (aac[2] & 0x01 << 2) | (aac[3] >> 6)
frame.Fix_Header.Channel_configuration = ((aac[2] & 0x01) << 2) | (aac[3] >> 6)
frame.Fix_Header.Originalorcopy = aac[3] >> 5 & 0x01
frame.Fix_Header.Home = aac[3] >> 4 & 0x01
frame.Variable_Header.Copyright_identification_bit = aac[3] >> 3 & 0x01
@@ -1380,6 +1339,89 @@ func AACSampleIdxToSample(idx int) int {
return AAC_Sampling_Idx[idx]
}
func aacFrameDurationSamples(aac []byte) uint64 {
if len(aac) < 7 {
return 0
}
var header ADTS_Frame_Header
header.Decode(aac)
rawBlocks := uint64(header.Variable_Header.Number_of_raw_data_blocks_in_frame) + 1
return rawBlocks * 1024
}
func aacSampleRate(aac []byte) uint32 {
if len(aac) < 7 {
return 0
}
var header ADTS_Frame_Header
header.Decode(aac)
sampleRateIdx := int(header.Fix_Header.Sampling_frequency_index)
if sampleRateIdx < 0 || sampleRateIdx >= len(AAC_Sampling_Idx) {
return 0
}
sampleRate := AACSampleIdxToSample(sampleRateIdx)
if sampleRate <= 0 {
return 0
}
return uint32(sampleRate)
}
func aacChannelCount(aacBytes []byte) uint16 {
if len(aacBytes) < 7 {
return 0
}
var header ADTS_Frame_Header
header.Decode(aacBytes)
if header.Fix_Header.Channel_configuration == 0 {
return 0
}
return uint16(header.Fix_Header.Channel_configuration)
}
func AACSampleRateFromADTS(aac []byte) uint32 {
return aacSampleRate(aac)
}
func AACChannelCountFromADTS(aac []byte) uint16 {
return aacChannelCount(aac)
}
func setAACLCDescriptor(trak *mp4ff.TrakBox, sampleRate int, channels uint16) error {
if trak == nil {
return errors.New("nil trak for AAC descriptor")
}
if sampleRate <= 0 {
return errors.New("invalid AAC sample rate")
}
if channels == 0 {
channels = 1
}
asc := &aac.AudioSpecificConfig{
ObjectType: aac.AAClc,
ChannelConfiguration: byte(channels),
SamplingFrequency: sampleRate,
}
buf := &bytes.Buffer{}
if err := asc.Encode(buf); err != nil {
return err
}
esds := mp4ff.CreateEsdsBox(buf.Bytes())
mp4a := mp4ff.CreateAudioSampleEntryBox("mp4a", channels, 16, uint16(sampleRate), esds)
trak.Mdia.Minf.Stbl.Stsd.AddChild(mp4a)
return nil
}
// +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
// | audio object type(5 bits) | sampling frequency index(4 bits) | channel configuration(4 bits) | GA framelength flag(1 bits) | GA Depends on core coder(1 bits) | GA Extension Flag(1 bits) |
// +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
-102
View File
@@ -1,102 +0,0 @@
package video
import (
"fmt"
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// TestMP4LoopSeamIsolation reproduces the loop-seam pattern from the
// failing virtual-rtsp recordings: ~1s GOPs, but at the source-MP4
// loop boundary an IDR arrives prematurely (~200-870ms after the
// previous IDR). Without the fix this seam IDR ends up bunched into
// the same fragment as the prior GOP's IDR which trips macOS
// VideoToolbox (kVTVideoDecoderBadDataErr / -12909). The fix forces
// a fragment flush whenever two IDRs arrive closer than MinNormalGOPMs.
func TestMP4LoopSeamIsolation(t *testing.T) {
tmpFile := "/tmp/test_loop_seam.mp4"
defer os.Remove(tmpFile)
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile, [][]byte{sps}, [][]byte{pps}, nil, 30)
mp4Video.SetWidth(1920)
mp4Video.SetHeight(1080)
v := mp4Video.AddVideoTrack("H264")
mk := func(k bool) []byte {
nt := byte(0x01)
if k {
nt = 0x65
}
f := []byte{0, 0, 0, 1, nt}
for i := 0; i < 200; i++ {
f = append(f, byte(i))
}
return f
}
frameDur := uint64(33)
pts := uint64(0)
emit := func(n int, gopLen int) {
for f := 0; f < n; f++ {
isKey := (f % gopLen) == 0
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts)
pts += frameDur
}
}
// 17 seconds of normal content (last "good" IDR at sec 17).
emit(17*30, 30)
// Seam: IDR arrives ~150ms after previous (vs normal ~1000ms).
// This matches the realistic virtual-rtsp / ffmpeg `-stream_loop`
// loop boundary, where the new clip's first IDR is emitted shortly
// after the previous clip's final IDR.
pts -= 820
emit(13*30, 30)
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
f, _ := os.Open(tmpFile)
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("decode: %v", err)
}
fragIdx := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
tfdt := traf.Tfdt.BaseMediaDecodeTime()
offset := uint64(0)
var keys []uint64
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
if (s.Flags>>24)&0x03 == 0x02 {
keys = append(keys, offset)
}
offset += uint64(s.Dur)
}
}
fmt.Printf("frag %d tfdt=%d samples_dur=%d keys@%v\n",
fragIdx, tfdt, offset, keys)
for i := 1; i < len(keys); i++ {
gap := keys[i] - keys[i-1]
if gap < MinNormalGOPMs {
t.Errorf("frag %d (tfdt=%d): two IDRs only %d ms apart "+
"in same fragment (< %d) - seam was not isolated",
fragIdx, tfdt, gap, MinNormalGOPMs)
}
}
fragIdx++
}
}
}
}