From e25e1cfb29004082e66e5b58d8bec35e97afbcf4 Mon Sep 17 00:00:00 2001 From: Nishant Chitkara Date: Sat, 22 Aug 2026 17:04:22 -0700 Subject: [PATCH] feat(mp4): resolve overlapping fragments during defragmentation A fragment whose tfdt re-declares an earlier decode time supersedes the earlier samples of its track (a retransmission): the fragment appearing later in the file wins, whole re-sent fragments disappear, superseded tails are trimmed at sample granularity, and the timeline continues from the declaring fragment, whether or not the re-sent bytes are identical. Non-overlapping input converts byte-identically to before. Ambiguous overlaps keep failing closed instead of being guessed at: an overlap that starts inside a sample (unless it only shrinks earlier tfdt-gap padding), any abandoned time range that no surviving later fragment declares again (whether the superseded fragment was trimmed or dropped whole, and with voided declarations not counting as coverage), and overlapping files whose fragments use absolute base data offsets are rejected, so no declared content is ever silently dropped. The payload size bound applies to the surviving samples, so re-sent declarations larger than the input file do not reject a file that resolves cleanly. The work is linear in the number of fragments. Coverage checking merges each track's surviving declaration windows once and binary searches them. dropTrackSamplesFrom walked track.frags backwards until a record ended at or below the cut; records voided by earlier cuts sit above later live records in collection order and never satisfy that test once the cut walks below them, so each later cut rescanned the whole voided tail: n one-sample fragments followed by n-1 fragments each rewinding one sample took O(n^2). A parallel live slice keeps the records with kept > 0; their sample ranges tile the samples in order, so a cut pops the suffix with firstSample >= keep and trims at most the record straddling keep. The firstSample+kept <= keep guard stays so a cut landing exactly on a record boundary inside gap padding does not advance its cutoff. Gap padding is tracked as a stack indexed by sample so truncation pops instead of scanning. Each record and padding entry leaves once, making every cut amortized O(1). --- CHANGELOG.md | 10 + mp4/defragmenter.go | 279 ++++++++++++++++++++-- mp4/defragmenter_test.go | 500 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 766 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baf24bb5..5c230b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the output ftyp. Encrypted content, unsupported edit lists, zero timescales, truncated byte ranges, and payloads larger than the input file are rejected +- `mp4.Defragment` and `mp4.DefragmentTracks` resolve overlapping fragments: + when a fragment's tfdt re-declares an earlier decode time (a + retransmission), the fragment appearing later in the file wins and the + superseded samples are dropped at sample granularity, whether or not the + re-sent bytes are identical. Every abandoned time range must be declared + again by surviving fragments, so no declared content is ever silently + dropped. Ambiguous overlaps fail closed: cuts inside a sample, abandoned + time ranges that no surviving later fragment declares again, and + overlapping files whose fragments use absolute base data offsets are + rejected - `mp4ff-defragment` command line tool exposing the defragmentation with optional track selection diff --git a/mp4/defragmenter.go b/mp4/defragmenter.go index 218e76a5..8ed647c7 100644 --- a/mp4/defragmenter.go +++ b/mp4/defragmenter.go @@ -5,6 +5,7 @@ import ( "io" "math" "math/bits" + "sort" ) // Defragment converts the fragmented file f, decoded from rs, into a @@ -16,8 +17,16 @@ import ( // rebased so that its first tfdt becomes media time zero: edit-list media // times shift along, and a track starting later than the earliest one keeps // its presentation alignment through an empty edit (with at most half a -// movie-timescale tick of rounding). Encrypted content, overlapping -// timelines, and edits that cannot be shifted exactly are rejected. +// movie-timescale tick of rounding). +// +// A fragment whose tfdt re-declares an earlier decode time supersedes the +// earlier samples (a retransmission): the fragment appearing later in the +// file wins, and the superseded samples are dropped at sample granularity, +// whether or not the re-sent bytes are identical. Every abandoned time +// range must be declared again by surviving fragments, so no declared +// content is ever silently dropped; overlaps that cannot be resolved +// exactly are rejected, as are encrypted content and edits that cannot be +// shifted. func Defragment(f *File, rs io.ReadSeeker, w io.Writer) error { d, err := newDefragmenter(f, rs, nil) if err != nil { @@ -61,6 +70,7 @@ type defragChunk struct { size uint64 ranges []defragRange // input byte ranges, coalesced offset uint64 // output chunk offset, assigned before writing + dead bool // every sample dropped by overlap resolution } func (c *defragChunk) addRange(offset, size uint64) { @@ -76,26 +86,57 @@ func (c *defragChunk) addRange(offset, size uint64) { } type defragTrack struct { - trak *TrakBox - trex *TrexBox - keep bool - samples []defragSample - chunks []*defragChunk - started bool // some traf established the timeline - origin uint64 // decode time of the first sample - endDts uint64 // decode time just after the last sample - delay uint64 // presentation delay relative to the earliest track, in movie ticks + trak *TrakBox + trex *TrexBox + keep bool + samples []defragSample + chunks []*defragChunk + started bool // some traf established the timeline + origin uint64 // decode time of the first sample + endDts uint64 // decode time just after the last sample + delay uint64 // presentation delay relative to the earliest track, in movie ticks + frags []*defragFragRec // one record per collected traf + live []*defragFragRec // the records with kept > 0, in collection order + extendedDurs []paddedDur // durations before gap padding, by increasing sample index +} + +// paddedDur remembers a sample's duration before a tfdt gap padded it. +// Pushes happen at the then-last sample, so indexes increase down the stack +// and truncation after a cut pops from the top. +type paddedDur struct { + idx int + dur uint32 +} + +// addFragRec registers a collected record for overlap accounting. Records +// keeping samples also enter the live list that cuts consume. +func (t *defragTrack) addFragRec(rec *defragFragRec) { + t.frags = append(t.frags, rec) + if rec.kept > 0 { + t.live = append(t.live, rec) + } +} + +// defragFragRec records the declared decode-time span of one collected traf +// and how many of its samples survived overlap resolution so far. +type defragFragRec struct { + start, end uint64 // declared decode-time span of the traf's samples + firstSample int // index of its first sample in track.samples + total, kept int + cutoff uint64 // first superseded decode time, set when trimmed } type defragmenter struct { - rs io.ReadSeeker - fileSize uint64 - payloadSize uint64 // total sample payload of the kept tracks - ftyp *FtypBox - moov *MoovBox - tracks []*defragTrack - byID map[uint32]*defragTrack - chunks []*defragChunk // all output chunks in output order + rs io.ReadSeeker + fileSize uint64 + payloadSize uint64 // total sample payload of the kept tracks + ftyp *FtypBox + moov *MoovBox + tracks []*defragTrack + byID map[uint32]*defragTrack + chunks []*defragChunk // all output chunks in output order + resolvedOverlap bool + sawBaseDataOffset bool } // newDefragmenter collects the sample and chunk layout of the kept tracks. @@ -174,6 +215,9 @@ func newDefragmenter(f *File, rs io.ReadSeeker, keptTrackIDs []uint32) (*defragm } } } + if err := d.finishOverlapResolution(); err != nil { + return nil, err + } for _, track := range d.tracks { // A track without samples would get a 0-entry stts, which // classifies the output as fragmented on decode. @@ -357,7 +401,9 @@ func (d *defragmenter) collectTraf(moof *MoofBox, traf *TrafBox, track *defragTr track.origin = declared track.endDts = declared case declared < track.endDts: - return fmt.Errorf("tfdt %d is before the end %d of the previous samples", declared, track.endDts) + if err := d.dropTrackSamplesFrom(track, declared); err != nil { + return err + } case declared > track.endDts: // A forward tfdt gap extends the previous sample's duration. gap := declared - track.endDts @@ -365,18 +411,24 @@ func (d *defragmenter) collectTraf(moof *MoofBox, traf *TrafBox, track *defragTr if gap > uint64(math.MaxUint32-last.dur) { return fmt.Errorf("tfdt gap %d does not fit the previous sample duration", gap) } + idx := len(track.samples) - 1 + if n := len(track.extendedDurs); n == 0 || track.extendedDurs[n-1].idx != idx { + track.extendedDurs = append(track.extendedDurs, paddedDur{idx: idx, dur: last.dur}) + } last.dur += uint32(gap) track.endDts = declared } } else if !track.started { track.started = true } + rec := &defragFragRec{start: track.endDts, firstSample: len(track.samples)} baseOffset := int64(moof.StartPos) switch { case tfhd.HasBaseDataOffset(): if tfhd.BaseDataOffset > math.MaxInt64 { return fmt.Errorf("base data offset %d too large", tfhd.BaseDataOffset) } + d.sawBaseDataOffset = true baseOffset = int64(tfhd.BaseDataOffset) case !tfhd.DefaultBaseIfMoof() && traf != moof.Trafs[0]: // ISO/IEC 14496-12 Section 8.8.7: without base-data-offset or @@ -437,6 +489,193 @@ func (d *defragmenter) collectTraf(moof *MoofBox, traf *TrafBox, track *defragTr track.chunks = append(track.chunks, chunk) d.chunks = append(d.chunks, chunk) } + rec.end = track.endDts + rec.total = int(chunk.nrSamples) + rec.kept = rec.total + track.addFragRec(rec) + return nil +} + +// dropTrackSamplesFrom drops the track's collected samples at or after decode +// time cutTime: a later fragment declares that time again and wins. The cut +// must land on a sample boundary; only a duration that was previously +// extended to bridge a forward tfdt gap may shrink back to absorb it. +func (d *defragmenter) dropTrackSamplesFrom(track *defragTrack, cutTime uint64) error { + keep := len(track.samples) + end := track.endDts + for keep > 0 { + start := end - uint64(track.samples[keep-1].dur) + if start < cutTime { + break + } + keep-- + end = start + } + // Padding entries at or above the cut belong to dropped samples. + for n := len(track.extendedDurs); n > 0 && track.extendedDurs[n-1].idx >= keep; n-- { + track.extendedDurs = track.extendedDurs[:n-1] + } + switch { + case keep == 0: + track.origin = cutTime + case end > cutTime: + last := &track.samples[keep-1] + start := end - uint64(last.dur) + var declaredDur uint32 + wasExtended := false + if n := len(track.extendedDurs); n > 0 && track.extendedDurs[n-1].idx == keep-1 { + declaredDur = track.extendedDurs[n-1].dur + wasExtended = true + } + if !wasExtended || start+uint64(declaredDur) > cutTime { + return fmt.Errorf("a later fragment starts at %d inside sample [%d,%d); "+ + "the overlap cannot be resolved at sample granularity", cutTime, start, end) + } + last.dur = uint32(cutTime - start) // shrink the gap padding, not declared sample time + if last.dur == declaredDur { + track.extendedDurs = track.extendedDurs[:len(track.extendedDurs)-1] + } + } + if dropped := len(track.samples) - keep; dropped > 0 { + d.resolvedOverlap = true + trimTrackChunksTail(track, keep) + // The live records' sample ranges tile [0, len(samples)) in order, so + // a cut voids a suffix of live and trims at most the record straddling + // keep. A record leaves live once, making each cut amortized O(1). + for n := len(track.live); n > 0 && track.live[n-1].firstSample >= keep; n-- { + rec := track.live[n-1] + rec.kept = 0 + rec.cutoff = rec.start // the whole declared window is abandoned + track.live = track.live[:n-1] + } + if n := len(track.live); n > 0 { + // A cut landing exactly on this record's boundary inside later gap + // padding must not advance its cutoff into the padding. + if rec := track.live[n-1]; rec.firstSample+rec.kept > keep { + rec.kept = keep - rec.firstSample + rec.cutoff = cutTime + } + } + track.samples = track.samples[:keep] + } + track.endDts = cutTime + return nil +} + +// trimTrackChunksTail removes the byte ranges of the samples from index +// fromIdx onward from the tail chunks of the track. A chunk that loses every +// sample is marked dead and removed from the track's chunk list. +func trimTrackChunksTail(track *defragTrack, fromIdx int) { + dropped := track.samples[fromIdx:] + di := len(dropped) - 1 + for di >= 0 { + chunk := track.chunks[len(track.chunks)-1] + for di >= 0 && chunk.nrSamples > 0 { + size := uint64(dropped[di].size) + chunk.nrSamples-- + chunk.size -= size + for size > 0 { + last := &chunk.ranges[len(chunk.ranges)-1] + if last.size > size { + last.size -= size + break + } + size -= last.size + chunk.ranges = chunk.ranges[:len(chunk.ranges)-1] + } + di-- + } + if chunk.nrSamples == 0 { + chunk.dead = true + track.chunks = track.chunks[:len(track.chunks)-1] + } + } +} + +// finishOverlapResolution applies the fail-closed guards of overlap +// resolution once every fragment is collected, and drops the emptied chunks +// from the output order. Without resolved overlaps it changes nothing. +func (d *defragmenter) finishOverlapResolution() error { + if !d.resolvedOverlap { + return nil + } + if d.sawBaseDataOffset { + return fmt.Errorf("cannot resolve overlapping fragments when fragments use absolute base data offsets") + } + for _, track := range d.tracks { + if !track.keep { + continue + } + windows := survivingWindows(track.frags) + for _, rec := range track.frags { + if rec.total == 0 || rec.kept == rec.total { + continue + } + if err := checkOverlapCoverage(rec, windows); err != nil { + return err + } + } + } + liveChunks := d.chunks[:0] + for _, chunk := range d.chunks { + if !chunk.dead { + liveChunks = append(liveChunks, chunk) + } + } + d.chunks = liveChunks + return nil +} + +// coverageWindow is a merged run of decode times declared by surviving +// samples. +type coverageWindow struct { + start, end uint64 +} + +// survivingWindows merges the declared decode-time ranges of the records' +// surviving samples into disjoint, sorted windows. A record's own and +// earlier windows are harmless when checking its coverage: they end at or +// before its start, so they can never extend coverage past its cutoff. +func survivingWindows(frags []*defragFragRec) []coverageWindow { + windows := make([]coverageWindow, 0, len(frags)) + for _, rec := range frags { + end := rec.end + if rec.kept < rec.total { + end = rec.cutoff // only the surviving head declares coverage + } + if end > rec.start { + windows = append(windows, coverageWindow{start: rec.start, end: end}) + } + } + sort.Slice(windows, func(i, j int) bool { return windows[i].start < windows[j].start }) + merged := windows[:0] + for _, window := range windows { + if n := len(merged); n > 0 && window.start <= merged[n-1].end { + if window.end > merged[n-1].end { + merged[n-1].end = window.end + } + continue + } + merged = append(merged, window) + } + return merged +} + +// checkOverlapCoverage verifies that the decode-time range a superseded +// fragment abandoned, [rec.cutoff, rec.end), lies within one merged window +// of surviving declarations. Gap padding never counts as coverage: rec.end +// was captured before any later tfdt-gap extension, and cutoffs land on +// declared sample boundaries since firstSample+kept never decreases. +func checkOverlapCoverage(rec *defragFragRec, windows []coverageWindow) error { + covered := rec.cutoff + i := sort.Search(len(windows), func(i int) bool { return windows[i].end > rec.cutoff }) + if i < len(windows) && windows[i].start <= rec.cutoff { + covered = windows[i].end + } + if covered < rec.end { + return fmt.Errorf("a later fragment supersedes [%d,%d) of an earlier fragment, "+ + "but the abandoned content past %d is never re-declared", rec.cutoff, rec.end, covered) + } return nil } diff --git a/mp4/defragmenter_test.go b/mp4/defragmenter_test.go index f0a9710e..00e05757 100644 --- a/mp4/defragmenter_test.go +++ b/mp4/defragmenter_test.go @@ -358,13 +358,15 @@ func TestDefragmentTfdtGapExtendsPreviousSampleDuration(t *testing.T) { } } -func TestDefragmentBackwardsTfdtIsError(t *testing.T) { +// TestDefragmentOverlapInsideSampleIsError pins that a backward tfdt landing +// inside a sample cannot be resolved at sample granularity and fails closed. +func TestDefragmentOverlapInsideSampleIsError(t *testing.T) { tests := []struct { name string videoTfdt []uint64 audio bool }{ - {name: "backwards tfdt", videoTfdt: []uint64{0, 256}}, + {name: "backwards tfdt inside sample", videoTfdt: []uint64{0, 256}}, {name: "repeated overlap", videoTfdt: []uint64{0, 460, 907}}, {name: "video overlap with monotone audio", videoTfdt: []uint64{0, 256}, audio: true}, } @@ -392,7 +394,7 @@ func TestDefragmentBackwardsTfdtIsError(t *testing.T) { } } _, err := defragment(t, buf.Bytes()) - if err == nil || !strings.Contains(err.Error(), "before the end") { + if err == nil || !strings.Contains(err.Error(), "inside sample") { t.Errorf("overlapping tfdt error %v", err) } }) @@ -1287,3 +1289,495 @@ func TestDefragmentResolvesFinalZeroSegmentDurationEdit(t *testing.T) { }) } } + +// defragSampleRun returns count samples of 512 ticks each from decode time +// dts, the first one sync, with payload bytes derived from tag. +func defragSampleRun(dts uint64, count int, tag byte, size int) []mp4.FullSample { + samples := make([]mp4.FullSample, 0, count) + for i := 0; i < count; i++ { + flags := defragNonSyncFlags() + if i == 0 { + flags = defragSyncFlags() + } + samples = append(samples, mp4.FullSample{ + Sample: mp4.Sample{Flags: flags, Dur: 512, Size: uint32(size)}, + DecodeTime: dts + uint64(i)*512, + Data: defragPayload(tag+byte(i), size), + }) + } + return samples +} + +// writeOverlapFile writes an init plus the given track-1 fragment sample +// runs and returns the file bytes. +func writeOverlapFile(t *testing.T, runs ...[]mp4.FullSample) []byte { + t.Helper() + var buf bytes.Buffer + init := createDefragPlainAvInit(t) + if err := init.Encode(&buf); err != nil { + t.Fatal(err) + } + for i, run := range runs { + addDefragFragment(t, &buf, uint32(i+1), 1, run) + } + return buf.Bytes() +} + +func TestDefragmentResolvesFullResend(t *testing.T) { + first := defragSampleRun(0, 4, 1, 10) + resend := defragSampleRun(0, 4, 101, 10) // same times, different payload: the later fragment wins + tail := defragSampleRun(2048, 2, 201, 10) + data := writeOverlapFile(t, first, resend, tail) + out, err := defragment(t, data, 1) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + wantData := append(concatSampleData(resend), concatSampleData(tail)...) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, wantData) { + t.Error("kept sample data must be the re-sent fragment's bytes followed by the tail") + } + if got := len(outFile.Mdat.Data); got != len(wantData) { + t.Errorf("mdat carries %d bytes, want only the %d surviving bytes", got, len(wantData)) + } + stts := outFile.Moov.Trak.Mdia.Minf.Stbl.Stts + if diff := deep.Equal(stts, &mp4.SttsBox{SampleCount: []uint32{6}, SampleTimeDelta: []uint32{512}}); diff != nil { + t.Errorf("stts after resolution: %v", diff) + } + if dur := outFile.Moov.Trak.Mdia.Mdhd.Duration; dur != 6*512 { + t.Errorf("media duration %d, want %d", dur, 6*512) + } +} + +func TestDefragmentResolvesSupersededTail(t *testing.T) { + first := defragSampleRun(0, 4, 1, 10) // [0, 2048) + second := defragSampleRun(1024, 4, 101, 10) // [1024, 3072): supersedes the last 2 samples + data := writeOverlapFile(t, first, second) + out, err := defragment(t, data, 1) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + wantData := append(concatSampleData(first[:2]), concatSampleData(second)...) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, wantData) { + t.Error("kept sample data must be the trimmed head plus the superseding fragment") + } + stbl := outFile.Moov.Trak.Mdia.Minf.Stbl + if diff := deep.Equal(stbl.Stts, &mp4.SttsBox{SampleCount: []uint32{6}, SampleTimeDelta: []uint32{512}}); diff != nil { + t.Errorf("stts after trim: %v", diff) + } + if stbl.Stss == nil || deep.Equal(stbl.Stss.SampleNumber, []uint32{1, 3}) != nil { + t.Errorf("stss %v, want sync samples 1 and 3", stbl.Stss) + } +} + +func TestDefragmentResolvesResetChain(t *testing.T) { + fragA := defragSampleRun(0, 8, 1, 10) // [0, 4096) + fragB := defragSampleRun(2048, 8, 101, 10) // [2048, 6144) + fragC := defragSampleRun(1024, 10, 201, 10) // [1024, 6144): supersedes all of B and half of A + data := writeOverlapFile(t, fragA, fragB, fragC) + out, err := defragment(t, data, 1) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + wantData := append(concatSampleData(fragA[:2]), concatSampleData(fragC)...) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, wantData) { + t.Error("kept sample data must be A's head plus all of C, with B gone") + } + stts := outFile.Moov.Trak.Mdia.Minf.Stbl.Stts + if diff := deep.Equal(stts, &mp4.SttsBox{SampleCount: []uint32{12}, SampleTimeDelta: []uint32{512}}); diff != nil { + t.Errorf("stts after reset chain: %v", diff) + } +} + +// TestDefragmentAbandonedContentIsError pins that resolution never silently +// drops declared content: every abandoned time range must be declared again +// by surviving fragments. +func TestDefragmentAbandonedContentIsError(t *testing.T) { + tests := []struct { + name string + runs [][]mp4.FullSample + }{ + {name: "shorter full resend", runs: [][]mp4.FullSample{ + defragSampleRun(0, 8, 1, 10), // [0, 4096) + defragSampleRun(0, 2, 101, 10), // [0, 1024): abandons [1024, 4096) + }}, + {name: "shortening reset chain", runs: [][]mp4.FullSample{ + defragSampleRun(0, 8, 1, 10), // [0, 4096) + defragSampleRun(2048, 8, 101, 10), // [2048, 6144) + defragSampleRun(1024, 8, 201, 10), // [1024, 5120): abandons B's [5120, 6144) + }}, + {name: "transitive abandonment", runs: [][]mp4.FullSample{ + defragSampleRun(0, 8, 1, 10), // [0, 4096) + defragSampleRun(2048, 8, 101, 10), // [2048, 6144) + // [1024, 2048): voids B, so B's window must not cover A's [1024, 4096). + defragSampleRun(1024, 2, 201, 10), + }}, + {name: "wrong-order concatenation", runs: [][]mp4.FullSample{ + defragSampleRun(2048, 4, 1, 10), // [2048, 4096) + defragSampleRun(0, 4, 101, 10), // [0, 2048): abandons all of the first fragment + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + data := writeOverlapFile(t, test.runs...) + out, err := defragment(t, data, 1) + if err == nil || !strings.Contains(err.Error(), "never re-declared") { + t.Errorf("abandoned declared content gave %v, want a coverage error", err) + } + if out.Len() != 0 { + t.Errorf("wrote %d bytes before rejecting input", out.Len()) + } + }) + } +} + +func TestDefragmentResolvesByteIdenticalResend(t *testing.T) { + first := defragSampleRun(0, 4, 1, 10) + resend := defragSampleRun(0, 4, 1, 10) // byte-identical alias of the first fragment + data := writeOverlapFile(t, first, resend) + out, err := defragment(t, data, 1) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, concatSampleData(first)) { + t.Error("kept sample data must be the payload exactly once") + } + if got := len(outFile.Mdat.Data); got != len(concatSampleData(first)) { + t.Errorf("mdat carries %d bytes, want the payload exactly once", got) + } +} + +func TestDefragmentResolutionKeepsOtherTrackVerbatim(t *testing.T) { + var buf bytes.Buffer + init := createDefragPlainAvInit(t) + if err := init.Encode(&buf); err != nil { + t.Fatal(err) + } + videoFirst := defragSampleRun(0, 4, 1, 10) + videoResend := defragSampleRun(0, 4, 101, 10) + audioSamples := []mp4.FullSample{ + {Sample: mp4.Sample{Dur: 1024, Size: 40}, DecodeTime: 0, Data: defragPayload(51, 40)}, + {Sample: mp4.Sample{Dur: 1024, Size: 40}, DecodeTime: 1024, Data: defragPayload(52, 40)}, + } + addDefragFragment(t, &buf, 1, 1, videoFirst) + addDefragFragment(t, &buf, 2, 2, audioSamples[:1]) + addDefragFragment(t, &buf, 3, 1, videoResend) + addDefragFragment(t, &buf, 4, 2, audioSamples[1:]) + out, err := defragment(t, buf.Bytes()) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, concatSampleData(videoResend)) { + t.Error("video sample data must be the re-sent fragment's bytes") + } + if got := readProgressiveSampleData(t, outFile, 2); !bytes.Equal(got, concatSampleData(audioSamples)) { + t.Error("the overlap-free audio track must ride through byte-identical") + } +} + +func TestDefragmentResolutionShrinksGapPadding(t *testing.T) { + fragA := defragSampleRun(0, 2, 1, 10) // [0, 1024) + fragB := defragSampleRun(2048, 1, 101, 10) // gap: A's last sample gets padded to end at 2048 + fragC := defragSampleRun(1024, 3, 201, 10) // rewinds to 1024, re-declaring through 2560: B goes + data := writeOverlapFile(t, fragA, fragB, fragC) + out, err := defragment(t, data, 1) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + wantData := append(concatSampleData(fragA), concatSampleData(fragC)...) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, wantData) { + t.Error("kept sample data must be A plus C with B gone") + } + stts := outFile.Moov.Trak.Mdia.Minf.Stbl.Stts + if diff := deep.Equal(stts, &mp4.SttsBox{SampleCount: []uint32{5}, SampleTimeDelta: []uint32{512}}); diff != nil { + t.Errorf("stts with shrunk gap padding: %v", diff) + } + if dur := outFile.Moov.Trak.Mdia.Mdhd.Duration; dur != 5*512 { + t.Errorf("media duration %d, want %d", dur, 5*512) + } +} + +func TestDefragmentOverlapUncoveredTrimIsError(t *testing.T) { + first := defragSampleRun(0, 8, 1, 10) // [0, 4096) + second := defragSampleRun(1024, 2, 101, 10) // [1024, 2048): abandons [2048, 4096) with no replacement + data := writeOverlapFile(t, first, second) + out, err := defragment(t, data, 1) + if err == nil || !strings.Contains(err.Error(), "past 2048 is never re-declared") { + t.Errorf("uncovered trim gave %v, want a coverage error reporting where coverage stops", err) + } + if out.Len() != 0 { + t.Errorf("wrote %d bytes before rejecting input", out.Len()) + } +} + +func TestDefragmentOverlapAbsoluteBaseOffsetIsError(t *testing.T) { + var buf bytes.Buffer + init := createDefragPlainAvInit(t) + if err := init.Encode(&buf); err != nil { + t.Fatal(err) + } + addDefragFragment(t, &buf, 1, 1, defragSampleRun(0, 4, 1, 10)) + // A superseding fragment addressing its mdat payload absolutely. + moof := &mp4.MoofBox{} + _ = moof.AddChild(mp4.CreateMfhd(2)) + traf := &mp4.TrafBox{} + _ = moof.AddChild(traf) + tfhd := mp4.CreateTfhd(1) + tfhd.Flags = mp4.TfhdBaseDataOffsetPresentFlag + _ = traf.AddChild(tfhd) + _ = traf.AddChild(mp4.CreateTfdt(1024)) + trun := mp4.CreateTrun(0) + for _, s := range defragSampleRun(1024, 2, 101, 10) { + trun.AddSample(s.Sample) + } + _ = traf.AddChild(trun) + tfhd.BaseDataOffset = uint64(buf.Len()) + moof.Size() // the mdat box start + trun.DataOffset = 8 // its payload, past the header + if err := moof.Encode(&buf); err != nil { + t.Fatal(err) + } + mdat := &mp4.MdatBox{Data: defragPayload(200, 20)} + if err := mdat.Encode(&buf); err != nil { + t.Fatal(err) + } + _, err := defragment(t, buf.Bytes(), 1) + if err == nil || !strings.Contains(err.Error(), "absolute base data offsets") { + t.Errorf("absolute base offsets with an overlap gave %v, want a fail-closed error", err) + } +} + +// TestDefragmentResolvedOverlapPassesPayloadBound pins that the payload +// bound applies to the surviving samples: the re-sent declarations exceed +// the file size before resolution, but not after. +func TestDefragmentResolvedOverlapPassesPayloadBound(t *testing.T) { + var buf bytes.Buffer + init := createDefragInit(t) + if err := init.Encode(&buf); err != nil { + t.Fatal(err) + } + const sampleSize = 3000 + addDefragFragment(t, &buf, 1, 1, []mp4.FullSample{ + {Sample: mp4.Sample{Flags: defragSyncFlags(), Dur: 512, Size: sampleSize}, + DecodeTime: 0, Data: defragPayload(1, sampleSize)}, + }) + payloadStart := uint64(buf.Len()) - sampleSize + // Three moof-only full resends of the same sample, each pointing back at + // the first fragment's payload bytes relative to its own moof start. + for seqNr := uint32(2); seqNr <= 4; seqNr++ { + moof := &mp4.MoofBox{} + _ = moof.AddChild(mp4.CreateMfhd(seqNr)) + traf := &mp4.TrafBox{} + _ = moof.AddChild(traf) + _ = traf.AddChild(mp4.CreateTfhd(1)) + _ = traf.AddChild(mp4.CreateTfdt(0)) + trun := mp4.CreateTrun(0) + trun.AddSample(mp4.Sample{Flags: defragSyncFlags(), Dur: 512, Size: sampleSize}) + _ = traf.AddChild(trun) + trun.DataOffset = int32(payloadStart) - int32(buf.Len()) + if err := moof.Encode(&buf); err != nil { + t.Fatal(err) + } + } + if uint64(4*sampleSize) <= uint64(buf.Len()) { + t.Fatal("test setup: pre-resolution payload must exceed the file size") + } + out, err := defragment(t, buf.Bytes(), 1) + if err != nil { + t.Fatal(err) + } + outFile := decodeDefragOutput(t, out.Bytes()) + if got := readProgressiveSampleData(t, outFile, 1); !bytes.Equal(got, defragPayload(1, sampleSize)) { + t.Error("kept sample data must be the payload exactly once") + } + if got := len(outFile.Mdat.Data); got != sampleSize { + t.Errorf("mdat carries %d bytes, want the surviving %d", got, sampleSize) + } +} + +// buildDefragOverlapChain writes n video fragments of two 512-tick samples +// each, where every fragment re-declares the last sample of the previous one +// (overlap) or starts exactly where it ended (control). +func buildDefragOverlapChain(tb testing.TB, n int, overlap bool) []byte { + tb.Helper() + init := mp4.CreateEmptyInit() + init.Moov.Mvhd.Timescale = 600 + trak := init.AddEmptyTrack(defragVideoTimescale, "video", "und") + sps, _ := hex.DecodeString(sps1nalu) + pps, _ := hex.DecodeString(pps1nalu) + if err := trak.SetAVCDescriptor("avc1", [][]byte{sps}, [][]byte{pps}, true); err != nil { + tb.Fatal(err) + } + var buf bytes.Buffer + if err := init.Encode(&buf); err != nil { + tb.Fatal(err) + } + step := uint64(1024) + if overlap { + step = 512 + } + data := defragPayload(1, 130) + for k := 0; k < n; k++ { + frag, err := mp4.CreateFragment(uint32(k+1), 1) + if err != nil { + tb.Fatal(err) + } + for s := uint64(0); s < 2; s++ { + frag.AddFullSample(mp4.FullSample{ + Sample: mp4.Sample{Flags: defragSyncFlags(), Dur: 512, Size: uint32(len(data))}, + DecodeTime: uint64(k)*step + s*512, Data: data, + }) + } + if err := frag.Encode(&buf); err != nil { + tb.Fatal(err) + } + } + return buf.Bytes() +} + +// buildDefragDescendingChain writes n one-sample fragments followed by n-1 +// two-sample fragments whose tfdt walks back one sample each, voiding the +// records above every cut. The abandoned tail is never re-declared, so the +// file rejects with a coverage error after all cuts are resolved. +func buildDefragDescendingChain(tb testing.TB, n int) []byte { + tb.Helper() + buf, add := startDefragChainFile(tb) + for k := 0; k < n; k++ { + add(uint64(k)*512, 1, 512) + } + for j := 1; j < n; j++ { + add(uint64(n-1-j)*512, 2, 512) + } + return buf.Bytes() +} + +// buildDefragPaddedRewind writes n one-sample fragments separated by 1-tick +// tfdt gaps (padding every boundary), then n two-sample fragments each +// rewinding one sample, so every cut runs against the padding bookkeeping. +func buildDefragPaddedRewind(tb testing.TB, n int) []byte { + tb.Helper() + buf, add := startDefragChainFile(tb) + for k := 0; k < n; k++ { + add(uint64(k)*513, 1, 512) + } + cut := uint64(n-1) * 513 + for j := 0; j < n; j++ { + add(cut, 2, 513) + cut += 513 + } + return buf.Bytes() +} + +// startDefragChainFile writes a one-video-track init and returns the buffer +// plus a function appending a fragment of count dur-tick samples at dts. +func startDefragChainFile(tb testing.TB) (*bytes.Buffer, func(dts uint64, count int, dur uint32)) { + tb.Helper() + init := mp4.CreateEmptyInit() + init.Moov.Mvhd.Timescale = 600 + trak := init.AddEmptyTrack(defragVideoTimescale, "video", "und") + sps, _ := hex.DecodeString(sps1nalu) + pps, _ := hex.DecodeString(pps1nalu) + if err := trak.SetAVCDescriptor("avc1", [][]byte{sps}, [][]byte{pps}, true); err != nil { + tb.Fatal(err) + } + var buf bytes.Buffer + if err := init.Encode(&buf); err != nil { + tb.Fatal(err) + } + seqNr := uint32(0) + data := defragPayload(1, 130) + return &buf, func(dts uint64, count int, dur uint32) { + seqNr++ + frag, err := mp4.CreateFragment(seqNr, 1) + if err != nil { + tb.Fatal(err) + } + for s := 0; s < count; s++ { + frag.AddFullSample(mp4.FullSample{ + Sample: mp4.Sample{Flags: defragSyncFlags(), Dur: dur, Size: uint32(len(data))}, + DecodeTime: dts + uint64(s)*uint64(dur), Data: data, + }) + } + if err := frag.Encode(&buf); err != nil { + tb.Fatal(err) + } + } +} + +// BenchmarkDefragmentOverlapChain guards against super-linear work on +// overlapping input: every shape must stay within the same order as the +// non-overlapping control. The descending shape ends in a coverage error +// by construction; all cuts are still resolved before it surfaces. +func BenchmarkDefragmentOverlapChain(b *testing.B) { + for _, mode := range []struct { + name string + build func(tb testing.TB, n int) []byte + errOK bool + }{ + {name: "chain", build: func(tb testing.TB, n int) []byte { return buildDefragOverlapChain(tb, n, true) }}, + {name: "control", build: func(tb testing.TB, n int) []byte { return buildDefragOverlapChain(tb, n, false) }}, + {name: "descending", build: buildDefragDescendingChain, errOK: true}, + {name: "padded-rewind", build: buildDefragPaddedRewind}, + } { + b.Run(mode.name, func(b *testing.B) { + data := mode.build(b, 5000) + b.SetBytes(int64(len(data))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rs := bytes.NewReader(data) + f, err := mp4.DecodeFile(rs, mp4.WithDecodeMode(mp4.DecModeLazyMdat)) + if err != nil { + b.Fatal(err) + } + var out bytes.Buffer + err = mp4.DefragmentTracks(f, rs, &out, 1) + if err != nil && (!mode.errOK || !strings.Contains(err.Error(), "never re-declared")) { + b.Fatal(err) + } + } + }) + } +} + +// TestDefragmentBoundaryCutInPaddingKeepsCutoff pins that a cut landing +// exactly on a record's boundary inside later gap padding does not advance +// the record's cutoff into the padding: the content its earlier trim +// abandoned is still unrecovered and the file must reject. +func TestDefragmentBoundaryCutInPaddingKeepsCutoff(t *testing.T) { + var buf bytes.Buffer + init := createDefragInit(t) + if err := init.Encode(&buf); err != nil { + t.Fatal(err) + } + // A declares [0, 4096) in 8 samples. + addDefragFragment(t, &buf, 1, 1, defragSampleRun(0, 8, 1, 10)) + // A sample-less traf rewinds to 2048: A is trimmed, cutoff 2048. + moof := &mp4.MoofBox{} + _ = moof.AddChild(mp4.CreateMfhd(2)) + traf := &mp4.TrafBox{} + _ = moof.AddChild(traf) + _ = traf.AddChild(mp4.CreateTfhd(1)) + _ = traf.AddChild(mp4.CreateTfdt(2048)) + trun := mp4.CreateTrun(0) + trun.Flags &^= mp4.TrunDataOffsetPresentFlag + _ = traf.AddChild(trun) + if err := moof.Encode(&buf); err != nil { + t.Fatal(err) + } + // B at 3072 pads A's last kept sample across [1536, 3072). + addDefragFragment(t, &buf, 3, 1, defragSampleRun(3072, 2, 101, 10)) + // C rewinds to 2560, A's boundary inside the padding, covering to 4096. + addDefragFragment(t, &buf, 4, 1, defragSampleRun(2560, 3, 201, 10)) + out, err := defragment(t, buf.Bytes(), 1) + if err == nil || !strings.Contains(err.Error(), "never re-declared") { + t.Errorf("boundary cut in padding gave %v, want a coverage error for A's [2048, 2560)", err) + } + if out.Len() != 0 { + t.Errorf("wrote %d bytes before rejecting input", out.Len()) + } +}