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()) + } +}