mirror of
https://github.com/kerberos-io/agent.git
synced 2026-09-04 17:08:34 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d60c71e4 | ||
|
|
52647d7f1d | ||
|
|
e1fa7d9d7e | ||
|
|
06e2694763 |
@@ -1,19 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/Eyevinn/mp4ff/avc"
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("usage: mp4analyze <file.mp4>")
|
||||
fromFlag := flag.Int64("from", -1, "start of the detailed inspection window (track timescale units); default auto-detects the largest keyframe gap")
|
||||
toFlag := flag.Int64("to", -1, "end of the detailed inspection window (track timescale units); default auto-detected")
|
||||
flag.Parse()
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Println("usage: mp4analyze [-from N] [-to N] <file.mp4>")
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Open(os.Args[1])
|
||||
f, err := os.Open(flag.Arg(0))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -110,7 +115,7 @@ func main() {
|
||||
tid := traf.Tfhd.TrackID
|
||||
tfdt := traf.Tfdt.BaseMediaDecodeTime()
|
||||
offset := uint64(0)
|
||||
var keys []uint64 // keyframe offset-from-tfdt
|
||||
var keys []uint64 // keyframe offset-from-tfdt
|
||||
var durs []uint64
|
||||
zeroDur := 0
|
||||
nSamples := 0
|
||||
@@ -157,16 +162,21 @@ func main() {
|
||||
if i > 0 {
|
||||
gap = int64(k) - int64(allKeyGlobal[i-1])
|
||||
}
|
||||
flag := ""
|
||||
seam := ""
|
||||
if i > 1 {
|
||||
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
|
||||
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
|
||||
flag = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
|
||||
seam = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, flag)
|
||||
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, seam)
|
||||
}
|
||||
|
||||
// Choose the detailed-inspection window. By default centre it on the largest
|
||||
// keyframe gap (the most likely artifact location); -from/-to override.
|
||||
winLo, winHi := inspectWindow(allKeyGlobal, *fromFlag, *toFlag)
|
||||
fmt.Printf("=== detailed inspection window: dts %d..%d ===\n", winLo, winHi)
|
||||
|
||||
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
|
||||
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
|
||||
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
|
||||
@@ -201,9 +211,11 @@ func main() {
|
||||
if lastDTS >= 0 && dts < lastDTS {
|
||||
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
|
||||
}
|
||||
isSync := s.Flags&0x02000000 == 0 && (s.Flags>>24)&0x03 == 0x02
|
||||
// Only print near the seam region and any anomalies, to keep output small.
|
||||
near := dts >= 7800 && dts <= 8700
|
||||
// sample_is_non_sync_sample is bit 16 (0x00010000); a sync sample
|
||||
// has it clear and sample_depends_on==2 (i.e. an I-frame).
|
||||
isSync := s.Flags&0x00010000 == 0 && (s.Flags>>24)&0x03 == 0x02
|
||||
// Only print inside the inspection window and any anomalies, to keep output small.
|
||||
near := dts >= winLo && dts <= winHi
|
||||
if near || anomaly != "" {
|
||||
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
|
||||
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
|
||||
@@ -293,10 +305,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
sliceHeaders(parsed, trex)
|
||||
sliceHeaders(parsed, trex, winLo, winHi)
|
||||
|
||||
summary(parsed, trex)
|
||||
}
|
||||
|
||||
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox, winLo, winHi int64) {
|
||||
// Build SPS/PPS maps from avcC.
|
||||
spsMap := map[uint32]*avc.SPS{}
|
||||
ppsMap := map[uint32]*avc.PPS{}
|
||||
@@ -322,7 +336,7 @@ func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== slice headers near seam (frame_num / poc / idr_pic_id) ===")
|
||||
fmt.Println("=== slice headers in inspection window (frame_num / poc / idr_pic_id) ===")
|
||||
fragIdx := 0
|
||||
sampIdx := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
@@ -334,7 +348,7 @@ func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
if dts < 6800 || dts > 9400 {
|
||||
if dts < winLo || dts > winHi {
|
||||
sampIdx++
|
||||
continue
|
||||
}
|
||||
@@ -423,3 +437,200 @@ func nalsByType(b []byte, want int) [][]byte {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inspectWindow returns the [lo,hi] decode-time range (track timescale units)
|
||||
// for which sample-level detail is printed. Explicit -from/-to win; otherwise
|
||||
// the window auto-centres on the largest gap between consecutive video
|
||||
// keyframes — the most likely location of a visible artifact — with a margin on
|
||||
// each side so the frames leading into and out of the gap are shown too.
|
||||
func inspectWindow(keyDecodeTimes []uint64, from, to int64) (int64, int64) {
|
||||
if from >= 0 || to >= 0 {
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if to < 0 {
|
||||
to = from + 2000
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
if len(keyDecodeTimes) < 2 {
|
||||
return 0, 1 << 62
|
||||
}
|
||||
worstIdx, worstGap := 1, uint64(0)
|
||||
for i := 1; i < len(keyDecodeTimes); i++ {
|
||||
if g := keyDecodeTimes[i] - keyDecodeTimes[i-1]; g > worstGap {
|
||||
worstGap = g
|
||||
worstIdx = i
|
||||
}
|
||||
}
|
||||
const margin = 500
|
||||
lo := int64(keyDecodeTimes[worstIdx-1]) - margin
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
return lo, int64(keyDecodeTimes[worstIdx]) + margin
|
||||
}
|
||||
|
||||
// summary prints a compact, generic health report so a recording can be
|
||||
// validated at a glance without reading the full per-sample dump above.
|
||||
func summary(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
fmt.Println("=== SUMMARY (health checks) ===")
|
||||
|
||||
videoTracks, audioTracks := 0, 0
|
||||
var videoTimescale uint64 = 1
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
switch trak.Mdia.Hdlr.HandlerType {
|
||||
case "vide":
|
||||
videoTracks++
|
||||
if trak.Mdia.Mdhd.Timescale != 0 {
|
||||
videoTimescale = uint64(trak.Mdia.Mdhd.Timescale)
|
||||
}
|
||||
case "soun":
|
||||
audioTracks++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" tracks: %d video, %d audio\n", videoTracks, audioTracks)
|
||||
if audioTracks == 0 {
|
||||
fmt.Println(" note: no audio track is embedded in this file")
|
||||
}
|
||||
|
||||
type fragStat struct {
|
||||
idx int
|
||||
tfdt uint64
|
||||
dur uint64
|
||||
nSamp int
|
||||
nKeys int
|
||||
zeroDur int
|
||||
fps float64
|
||||
}
|
||||
var stats []fragStat
|
||||
var keyTimes []uint64
|
||||
var fpsArr []float64
|
||||
tfdtGaps := 0
|
||||
var prevEnd uint64
|
||||
havePrev := false
|
||||
fi := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
st := fragStat{idx: fi, tfdt: traf.Tfdt.BaseMediaDecodeTime()}
|
||||
off := uint64(0)
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
st.nSamp++
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
st.nKeys++
|
||||
keyTimes = append(keyTimes, st.tfdt+off)
|
||||
}
|
||||
if s.Dur == 0 {
|
||||
st.zeroDur++
|
||||
}
|
||||
off += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
st.dur = off
|
||||
d := st.dur
|
||||
if d == 0 {
|
||||
d = 1
|
||||
}
|
||||
st.fps = float64(st.nSamp) * float64(videoTimescale) / float64(d)
|
||||
fpsArr = append(fpsArr, st.fps)
|
||||
if havePrev && st.tfdt != prevEnd {
|
||||
tfdtGaps++
|
||||
}
|
||||
prevEnd = st.tfdt + st.dur
|
||||
havePrev = true
|
||||
stats = append(stats, st)
|
||||
}
|
||||
fi++
|
||||
}
|
||||
}
|
||||
|
||||
medFps := medianFloat(fpsArr)
|
||||
fmt.Printf(" fragments: %d (video timescale=%d, median %.1f fps)\n", len(stats), videoTimescale, medFps)
|
||||
lowFps := 0
|
||||
totalZero := 0
|
||||
for _, st := range stats {
|
||||
totalZero += st.zeroDur
|
||||
flagStr := ""
|
||||
if medFps > 0 && st.fps < medFps*0.9 {
|
||||
lowFps++
|
||||
flagStr = " <== LOW FRAME RATE — likely dropped frames"
|
||||
}
|
||||
fmt.Printf(" frag%02d tfdt=%-6d dur=%-5d samples=%-3d keyframes=%d zeroDur=%d fps=%.1f%s\n",
|
||||
st.idx, st.tfdt, st.dur, st.nSamp, st.nKeys, st.zeroDur, st.fps, flagStr)
|
||||
}
|
||||
|
||||
var gaps []uint64
|
||||
for i := 1; i < len(keyTimes); i++ {
|
||||
gaps = append(gaps, keyTimes[i]-keyTimes[i-1])
|
||||
}
|
||||
irregular := 0
|
||||
if len(gaps) > 0 {
|
||||
med := medianUint(gaps)
|
||||
mn, mx := gaps[0], gaps[0]
|
||||
for _, g := range gaps {
|
||||
if g < mn {
|
||||
mn = g
|
||||
}
|
||||
if g > mx {
|
||||
mx = g
|
||||
}
|
||||
// Flag intervals that deviate by more than ~50% from the median GOP.
|
||||
if med > 0 && (g*2 > med*3 || g*2 < med) {
|
||||
irregular++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" keyframe gaps: min=%d median=%d max=%d irregular=%d/%d\n", mn, med, mx, irregular, len(gaps))
|
||||
}
|
||||
fmt.Printf(" tfdt discontinuities: %d\n", tfdtGaps)
|
||||
fmt.Printf(" zero-duration samples: %d\n", totalZero)
|
||||
|
||||
fmt.Println(" verdict:")
|
||||
clean := true
|
||||
if audioTracks == 0 {
|
||||
fmt.Println(" - no audio track (expected if this recording is video-only)")
|
||||
}
|
||||
if lowFps > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d fragment(s) have a reduced frame rate (dropped frames) — likely source of the artifacts\n", lowFps)
|
||||
}
|
||||
if irregular > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d irregular keyframe interval(s)\n", irregular)
|
||||
}
|
||||
if tfdtGaps > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d timeline (tfdt) discontinuity(ies)\n", tfdtGaps)
|
||||
}
|
||||
if totalZero > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d zero-duration sample(s)\n", totalZero)
|
||||
}
|
||||
if clean {
|
||||
fmt.Println(" - container structure looks healthy")
|
||||
}
|
||||
}
|
||||
|
||||
func medianUint(v []uint64) uint64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := append([]uint64(nil), v...)
|
||||
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
|
||||
return c[len(c)/2]
|
||||
}
|
||||
|
||||
func medianFloat(v []float64) float64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := append([]float64(nil), v...)
|
||||
sort.Float64s(c)
|
||||
return c[len(c)/2]
|
||||
}
|
||||
|
||||
@@ -33,13 +33,18 @@ const MacEpochOffset uint64 = 2082844800
|
||||
const FragmentDurationMs = 3000
|
||||
|
||||
// SeamGapDivisor controls loop-seam detection. A keyframe is treated as an
|
||||
// upstream loop/restart seam when it arrives in less than (previous keyframe
|
||||
// interval / SeamGapDivisor) — i.e. far sooner than the established keyframe
|
||||
// cadence. Comparing against the *previous* interval (rather than a fixed
|
||||
// millisecond threshold) makes the check scale automatically with the camera's
|
||||
// configured GOP size: it works the same whether keyframes are 0.5s, 1s, 2s or
|
||||
// more apart, and does not misfire on legitimately short-GOP or all-intra
|
||||
// streams (where every interval is similar, so none looks anomalously short).
|
||||
// upstream loop/restart seam when it arrives in less than (smallest normal
|
||||
// keyframe interval / SeamGapDivisor) — i.e. far sooner than the camera's
|
||||
// tightest established keyframe cadence.
|
||||
//
|
||||
// The reference is the running *minimum* keyframe interval, NOT the immediately
|
||||
// preceding one. Variable-GOP ("smart codec") cameras lengthen the GOP during
|
||||
// static scenes and shorten it again on motion, so consecutive intervals differ
|
||||
// wildly (e.g. 2000 ms then 500 ms). Comparing against the previous interval
|
||||
// then flags every normal short GOP that happens to follow a long static GOP as
|
||||
// a seam and drops healthy video. Comparing against the minimum cadence instead
|
||||
// scales with any configured GOP size (0.5s, 1s, 2s, ...) yet never mistakes the
|
||||
// camera's own normal cadence for a premature seam IDR.
|
||||
const SeamGapDivisor = 2
|
||||
|
||||
type MP4 struct {
|
||||
@@ -85,7 +90,8 @@ type MP4 struct {
|
||||
FragmentKeyframeCount int // Keyframes in the current fragment
|
||||
PendingSampleIsKeyframe bool // Whether the pending video sample is a keyframe
|
||||
LastKeyframeRawPTS uint64 // Raw PTS of the most recently seen keyframe (across fragments)
|
||||
LastKeyframeGapMs uint64 // Interval (ms) between the two most recent keyframes; reference cadence for seam detection
|
||||
LastKeyframeGapMs uint64 // Interval (ms) between the two most recent keyframes (diagnostic only)
|
||||
MinKeyframeGapMs uint64 // Smallest keyframe interval (ms) seen so far; the camera's tightest cadence and the reference for seam detection
|
||||
gopBuffer []bufferedSample // Current, not-yet-committed GOP (video frames + interleaved audio), held so a loop-seam GOP can be dropped before it reaches the file
|
||||
}
|
||||
|
||||
@@ -333,23 +339,40 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
|
||||
// buffered GOP is genuine (commit it) or the truncated tail GOP at an upstream
|
||||
// loop/restart seam (drop it).
|
||||
//
|
||||
// The GOP size is configurable per camera, so we do NOT compare against a
|
||||
// fixed millisecond threshold. Instead we compare this keyframe interval to
|
||||
// the previous one and only flag a *sudden* shortening: a seam IDR arrives in
|
||||
// less than (previous interval / SeamGapDivisor). Deriving the threshold from
|
||||
// the observed cadence keeps detection correct for any configured GOP (0.5s,
|
||||
// 1s, 2s, ...) and avoids false positives on steady short-GOP / all-intra
|
||||
// streams (where consecutive intervals are similar, so none looks anomalously
|
||||
// short). Because the reference is the immediately preceding interval, a burst
|
||||
// of close keyframes only drops a single GOP instead of cascading.
|
||||
// A genuine loop/restart seam has TWO signatures that must BOTH hold; we never
|
||||
// drop a GOP on the interval alone, because variable-GOP ("smart codec")
|
||||
// cameras legitimately shorten the GOP on motion:
|
||||
//
|
||||
// 1. The new keyframe arrives much sooner than the camera's tightest normal
|
||||
// cadence: gap*SeamGapDivisor < MinKeyframeGapMs (the running MINIMUM
|
||||
// interval). Using the minimum — not the previous interval — means a
|
||||
// normal short GOP that merely follows a long static GOP (2000 ms -> 500 ms)
|
||||
// is NOT flagged, while a true premature restart still is.
|
||||
// 2. The GOP we just buffered is actually TRUNCATED — far shorter than a full
|
||||
// GOP. A real seam cuts a GOP off mid-stream, leaving only a handful of
|
||||
// frames; a healthy GOP (even a legitimately short one) is left intact and
|
||||
// must be committed in full. We require the buffered tail to be under half
|
||||
// the minimum normal GOP length to qualify as truncated.
|
||||
//
|
||||
// Deriving both thresholds from the observed cadence keeps detection correct
|
||||
// for any configured GOP size (0.5s, 1s, 2s, ...) and stops the heuristic from
|
||||
// discarding healthy video.
|
||||
seam := false
|
||||
if mp4.LastKeyframeRawPTS > 0 && pts > mp4.LastKeyframeRawPTS {
|
||||
gap := pts - mp4.LastKeyframeRawPTS
|
||||
if mp4.LastKeyframeGapMs > 0 && gap*SeamGapDivisor < mp4.LastKeyframeGapMs {
|
||||
bufferedVideo := mp4.bufferedVideoCount()
|
||||
// Frames a full GOP at the tightest normal cadence would contain.
|
||||
fullGopFrames := mp4.expectedGopFrames(gap)
|
||||
closeKeyframe := mp4.MinKeyframeGapMs > 0 && gap*SeamGapDivisor < mp4.MinKeyframeGapMs
|
||||
truncatedTail := fullGopFrames > 0 && bufferedVideo*2 < fullGopFrames
|
||||
if closeKeyframe && truncatedTail {
|
||||
seam = true
|
||||
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): dropping truncated GOP at unexpectedly close keyframe (interval=%d ms, previous interval=%d ms, buffered samples=%d) - likely upstream loop/restart discontinuity", gap, mp4.LastKeyframeGapMs, len(mp4.gopBuffer)))
|
||||
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): dropping truncated GOP at premature keyframe (interval=%d ms, min interval=%d ms, buffered video frames=%d of ~%d) - likely upstream loop/restart discontinuity", gap, mp4.MinKeyframeGapMs, bufferedVideo, fullGopFrames))
|
||||
}
|
||||
mp4.LastKeyframeGapMs = gap
|
||||
if !seam && (mp4.MinKeyframeGapMs == 0 || gap < mp4.MinKeyframeGapMs) {
|
||||
mp4.MinKeyframeGapMs = gap
|
||||
}
|
||||
}
|
||||
mp4.LastKeyframeRawPTS = pts
|
||||
|
||||
@@ -372,6 +395,70 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
|
||||
return nil
|
||||
}
|
||||
|
||||
// bufferedVideoCount returns how many video-track samples are currently held in
|
||||
// the GOP buffer (interleaved audio samples are ignored). It measures how
|
||||
// complete the buffered GOP is, used to tell a truncated seam tail from a
|
||||
// healthy — possibly legitimately short — GOP.
|
||||
func (mp4 *MP4) bufferedVideoCount() uint64 {
|
||||
var n uint64
|
||||
for _, s := range mp4.gopBuffer {
|
||||
if s.trackID == uint32(mp4.VideoTrack) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// expectedGopFrames estimates how many video frames a full GOP at the camera's
|
||||
// tightest normal cadence (MinKeyframeGapMs) would contain, using the video
|
||||
// frame interval inferred from the buffered GOP. gap is the current keyframe
|
||||
// interval, used as a fallback frame-duration source. Returns 0 when there is
|
||||
// not yet enough information to judge (so callers must not treat a GOP as
|
||||
// truncated without a reliable estimate).
|
||||
func (mp4 *MP4) expectedGopFrames(gap uint64) uint64 {
|
||||
cadence := mp4.MinKeyframeGapMs
|
||||
if cadence == 0 {
|
||||
return 0
|
||||
}
|
||||
frameDur := mp4.bufferedVideoFrameDuration()
|
||||
if frameDur == 0 {
|
||||
// Fall back to deriving a per-frame duration from the buffered tail across
|
||||
// the current interval; if that is unavailable too, we cannot estimate.
|
||||
if n := mp4.bufferedVideoCount(); n > 0 && gap > 0 {
|
||||
frameDur = gap / n
|
||||
}
|
||||
}
|
||||
if frameDur == 0 {
|
||||
return 0
|
||||
}
|
||||
return cadence / frameDur
|
||||
}
|
||||
|
||||
// bufferedVideoFrameDuration returns the average per-frame duration (in PTS
|
||||
// units) of the video samples currently buffered, derived from the PTS deltas
|
||||
// between consecutive video frames. Returns 0 when fewer than two video frames
|
||||
// are buffered.
|
||||
func (mp4 *MP4) bufferedVideoFrameDuration() uint64 {
|
||||
var prev uint64
|
||||
havePrev := false
|
||||
var sum, count uint64
|
||||
for _, s := range mp4.gopBuffer {
|
||||
if s.trackID != uint32(mp4.VideoTrack) {
|
||||
continue
|
||||
}
|
||||
if havePrev && s.pts > prev {
|
||||
sum += s.pts - prev
|
||||
count++
|
||||
}
|
||||
prev = s.pts
|
||||
havePrev = true
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
|
||||
// commitBufferedGOP writes every sample currently held in gopBuffer to the file
|
||||
// in arrival order, then clears the buffer. Committing in arrival order
|
||||
// preserves the original audio/video interleave and lets commitSampleToTrack's
|
||||
|
||||
129
machinery/src/video/mp4_variablegop_test.go
Normal file
129
machinery/src/video/mp4_variablegop_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// TestMP4VariableGOPKeepsHealthyShortGOP reproduces the adam-drive regression:
|
||||
// a variable-GOP ("smart codec") camera lengthens its keyframe interval during a
|
||||
// static scene (e.g. 500ms -> 1500/2000ms) and then drops back to its normal
|
||||
// 500ms cadence on motion. That normal, FULL 500ms GOP arrives much sooner than
|
||||
// the immediately preceding (long, static) GOP.
|
||||
//
|
||||
// The previous heuristic compared the new keyframe interval against the *previous*
|
||||
// interval and dropped the GOP whenever gap < previousInterval/2 — so every normal
|
||||
// 500ms keyframe following a long static GOP was misclassified as a premature
|
||||
// loop/restart seam and a whole healthy GOP (~15 frames) was discarded. In the
|
||||
// field this silently deleted ~0.5s of video on virtually every recording from
|
||||
// such cameras, producing a freeze/jump artifact.
|
||||
//
|
||||
// After the fix the seam check compares against the running MINIMUM cadence and
|
||||
// additionally requires the buffered GOP to be genuinely truncated, so a full
|
||||
// healthy GOP is always kept regardless of how long the preceding GOP was. This
|
||||
// test asserts that NO frames are dropped for a pure variable-GOP stream.
|
||||
func TestMP4VariableGOPKeepsHealthyShortGOP(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "test_variable_gop_*.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
pps := []byte{0x68, 0xce, 0x38, 0x80}
|
||||
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
|
||||
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
|
||||
}
|
||||
|
||||
const frameDur = uint64(33)
|
||||
pts := uint64(0)
|
||||
emitFrame := func(isKey bool) {
|
||||
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
|
||||
pts += frameDur
|
||||
}
|
||||
// emitGOP emits a complete GOP of exactly frames frames: a leading keyframe
|
||||
// followed by frames-1 P-frames. Every GOP here is healthy and complete; only
|
||||
// its length varies, exactly as a smart-codec camera varies the GOP.
|
||||
emitGOP := func(frames int) {
|
||||
emitFrame(true)
|
||||
for i := 0; i < frames-1; i++ {
|
||||
emitFrame(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Normal cadence is 15 frames (~500ms). The camera then lengthens the GOP for
|
||||
// several static scenes (45 and 60 frames, ~1500ms and ~2000ms) before
|
||||
// dropping back to the normal 15-frame GOP on motion — the transition the old
|
||||
// heuristic wrongly treated as a seam. The whole sequence is then repeated to
|
||||
// cover multiple long->short transitions.
|
||||
gopLengths := []int{15, 15, 45, 15, 60, 15, 15, 45, 15, 15, 60, 15}
|
||||
totalEmittedFrames := 0
|
||||
emittedKeyframes := 0
|
||||
for _, n := range gopLengths {
|
||||
emitGOP(n)
|
||||
totalEmittedFrames += n
|
||||
emittedKeyframes++
|
||||
}
|
||||
|
||||
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
|
||||
|
||||
f, err := os.Open(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
totalSamples := 0
|
||||
totalSync := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
totalSamples++
|
||||
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
totalSync++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every GOP is healthy, so nothing must be dropped: all keyframes and all
|
||||
// frames must survive. A shortfall means a normal variable-GOP keyframe was
|
||||
// misclassified as a seam.
|
||||
if totalSync != emittedKeyframes {
|
||||
t.Errorf("got %d keyframes in output, want %d - a healthy variable-GOP keyframe was wrongly dropped as a seam",
|
||||
totalSync, emittedKeyframes)
|
||||
}
|
||||
if totalSamples != totalEmittedFrames {
|
||||
t.Errorf("got %d video samples in output, want %d - a healthy variable-GOP GOP was wrongly dropped as a seam",
|
||||
totalSamples, totalEmittedFrames)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user