From 06e2694763c08ed715e7266f928f91f043d24996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Mon, 15 Jun 2026 14:44:05 +0200 Subject: [PATCH] Improve seam detection and add analysis tools Refine loop/restart (seam) detection and enhance the mp4 analysis tooling. - mp4: Replace previous previous-interval-based seam heuristic with a safer approach that (1) tracks the running minimum keyframe interval (MinKeyframeGapMs) as the reference cadence and (2) requires the buffered GOP to be genuinely truncated before dropping it. This avoids false positives on variable-GOP (smart-codec) cameras. Added fields, logging updates, and helper methods: bufferedVideoCount, expectedGopFrames, bufferedVideoFrameDuration. LastKeyframeGapMs is now diagnostic only. - cmd/mp4analyze: Add -from/-to flags and auto-select a detailed inspection window centred on the largest keyframe gap. Limit per-sample printing to that window (and anomalies), fix sync-sample bit detection, pass window to sliceHeaders, and add a compact SUMMARY health report with medians and checks. Added inspectWindow and median helpers. - tests: Add mp4_variablegop_test.go to verify variable-GOP streams keep healthy short GOPs and that no frames are dropped by the improved seam logic. These changes prevent healthy GOPs from being discarded on normal short GOPs that follow long static GOPs and add better diagnostics for debugging artifacts. --- machinery/cmd/mp4analyze/main.go | 239 ++++++++++++++++++-- machinery/src/video/mp4.go | 125 ++++++++-- machinery/src/video/mp4_variablegop_test.go | 129 +++++++++++ 3 files changed, 460 insertions(+), 33 deletions(-) create mode 100644 machinery/src/video/mp4_variablegop_test.go diff --git a/machinery/cmd/mp4analyze/main.go b/machinery/cmd/mp4analyze/main.go index e51689a..576cb20 100644 --- a/machinery/cmd/mp4analyze/main.go +++ b/machinery/cmd/mp4analyze/main.go @@ -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 ") + 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] ") 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] +} diff --git a/machinery/src/video/mp4.go b/machinery/src/video/mp4.go index 4718c52..81f913e 100644 --- a/machinery/src/video/mp4.go +++ b/machinery/src/video/mp4.go @@ -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 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 median-ish 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 diff --git a/machinery/src/video/mp4_variablegop_test.go b/machinery/src/video/mp4_variablegop_test.go new file mode 100644 index 0000000..20a9342 --- /dev/null +++ b/machinery/src/video/mp4_variablegop_test.go @@ -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) + } +}