Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
279 changes: 259 additions & 20 deletions mp4/defragmenter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"math"
"math/bits"
"sort"
)

// Defragment converts the fragmented file f, decoded from rs, into a
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -357,26 +401,34 @@ 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
last := &track.samples[len(track.samples)-1]
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
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading