Skip to content

feat(mp4): resolve overlapping fragments during defragmentation - #560

Merged
tobbee merged 1 commit into
Eyevinn:masterfrom
nchitkara-xai:defragment-overlaps
Sep 7, 2026
Merged

feat(mp4): resolve overlapping fragments during defragmentation#560
tobbee merged 1 commit into
Eyevinn:masterfrom
nchitkara-xai:defragment-overlaps

Conversation

@nchitkara-xai

@nchitkara-xai nchitkara-xai commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Fragmented files from segmented capture or upload pipelines can re-declare
time ranges: a fragment is re-sent after a partial write, or a restart
re-declares part of the previous timeline with a backward tfdt. #557 rejects
such files outright, which makes the converter useless for exactly the files
that most need converting to a clean progressive form.

Fix

Overlap resolution as the default behavior, with no new API surface: a
fragment appearing later in the file whose tfdt re-declares an earlier decode
time wins (retransmission semantics). Whole re-sent fragments disappear,
superseded tails are trimmed at sample granularity, and the timeline
continues from the surviving declaration.

The guarantee that keeps this fail-closed in spirit: no declared time range
is ever silently dropped
. Every abandoned range must be re-declared by
surviving fragments — a shorter resend, a transitively voided declaration, or
wrong-order concatenation (corruption rather than retransmission) all reject
with clear errors. Cuts landing inside a sample reject too, unless they only
shrink duration padding that an earlier tfdt gap introduced. Inputs that #557
already accepted convert byte-identically, since resolution only activates on
backward tfdts.

The declared-payload bound from #557 now applies after resolution, so a
legitimately re-sent fragment does not count twice against the input size.

Tests

Full resend, superseded tail, reset chains (covered resolves, shortening
rejects), byte-identical resend, multi-track with one overlapping track
(other track byte-verbatim), gap-padding shrink, mid-sample cuts, abandoned
ranges, wrong-order concatenation, and the payload-bound interaction. All
fragmented testdata files convert byte-identically to #557 output. go test ./..., go vet, gofmt, golangci-lint pass.

@tobbee

tobbee commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Review of the second commit (0e17771), now that the base commit has landed
on master as ec2f82c. This review was done by Claude (Claude Code), driven
by me, running against the branch in my checkout.

The overlap semantics hold up well. The one thing worth fixing before merge
is a quadratic blowup on crafted input; everything else I threw at it came
back correct.


Verification

Built the branch, go vet and the full test suite green.

Beyond the included tests:

  • Real retransmission. Rebuilt v300_multiple_segments.mp4 with each of
    its four segments duplicated in turn (init + seg1 + seg2 + seg2 + seg3…).
    All four convert byte-identically to the conversion of the un-duplicated
    original, ffmpeg decodes each without error, and the elementary-stream
    md5 matches the fragmented source exactly.
  • Multi-trun trafs, which no test in the PR builds. Constructed by hand,
    including a reversed payload layout so the chunk carries two
    non-contiguous input ranges. Whole-trun supersession and cuts landing
    inside a trun both produce exactly the right bytes.
  • Zero-size samples in and at the trimmed tail: correct. An overlap that
    empties a track: clean coverage error, no panic.

I also worked through whether synthetic tfdt-gap padding can ever be counted
as coverage. It cannot, and the reason is subtle enough to be worth writing
down somewhere: rec.end is captured before any later gap extension, and a
trimmed record's cutoff is always a declared sample boundary, because
firstSample+kept is non-decreasing across fragments. That is the right
design and the tests pin the cases that matter.

1. Quadratic blowup on crafted overlapping input

Two loops each rescan every fragment record per overlap:

  • checkOverlapCoverage (defragmenter.go:591, :610) rebuilds and
    re-sorts
    the entire suffix window list for every trimmed fragment — a
    fresh make at :614 and a sort.Slice at :624 on each call.
  • dropTrackSamplesFrom (defragmenter.go:517) scans every record on every
    cut.

With n fragments each overlapping the previous by one sample, every fragment
but the last is trimmed, so both loops run n times over O(n) records:

input overlapping chain non-overlapping control
1.0 MB 169 ms 8 ms
2.1 MB 540 ms 14 ms
4.2 MB 2.06 s 26 ms
8.4 MB 8.27 s 47 ms

A clean 4x per doubling against a linear baseline — about 175x on the same
file size at 8 MB, extrapolating to roughly 14 minutes for an 85 MB file.
The fragments are tiny, so such a file is cheap to produce. pprof puts the
time in checkOverlapCoverage and its sort. Everything else in the pipeline
is linear, so this is the only place where an attacker-shaped file buys
super-linear work.

Both are fixable without any behaviour change:

  • Build the merged union of the track's fragment windows once and binary
    search it. Restricting to frags[i+1:] is equivalent to using all
    fragments: if fragment i triggered a cut, every earlier record's
    surviving window ends at or before rec_i.start <= rec_i.cutoff, so an
    earlier window can never extend covered.
  • Walk track.frags backwards and break once
    rec.firstSample+rec.kept <= keep. That quantity is non-decreasing, since
    firstSample_r + kept_r <= firstSample_r + total_r = firstSample_{r+1}.

I tried both: 8.4 MB drops from 8.27 s to 60 ms, i.e. linear and matching
the non-overlap baseline, with your whole suite still passing — including
all four TestDefragmentAbandonedContentIsError cases and
TestDefragmentOverlapUncoveredTrimIsError — and the real-file resend above
still converting byte-identically.

Nit

defragmenter.go:636 prints rec.end twice ("supersedes [%d,%d) … the
abandoned content through %d"). Printing covered instead would say where
coverage actually ran out, which is the number you want when diagnosing a
rejected file.

@tobbee

tobbee commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Re-checked 1f01977 — again with Claude (Claude Code), same setup as before.

Most of it is fixed. survivingWindows (defragmenter.go:620) plus the
sort.Search in checkOverlapCoverage (:650) fully closes that half: it
no longer shows up in a CPU profile at all. The nit is fixed too, and
BenchmarkDefragmentOverlapChain is a good addition. Build, go vet and the
full suite are green, the real-file duplicated-segment conversion is still
byte-identical, and my original chain shape is now linear — 8.4 MB goes from
8.27 s to 48 ms, matching the non-overlap control.

The dropTrackSamplesFrom half is not fully closed, though.

The backwards break does not cover every shape

The break at defragmenter.go:521 works when the cut point moves forward.
But a record that is already voided above the cut still has
firstSample+kept > keep, since its firstSample alone exceeds keep. So
the scan walks over the whole voided tail and cannot break, even though
every one of those updates is a no-op (kept is already 0 and cutoff is
already rec.start).

A file whose cut point descends one sample at a time hits exactly that: n
one-sample fragments building a deep timeline, then n-1 fragments whose tfdt
walks the cut back down one sample per fragment.

input descending cuts
3.6 MB 276 ms
7.2 MB 1.04 s
14.3 MB 4.23 s

A clean 4x per doubling, with 85% of the time in dropTrackSamplesFrom per
pprof. BenchmarkDefragmentOverlapChain uses the chain shape only, so it
stays flat and does not catch this.

Suggested fix

Keep a live []*defragFragRec subsequence of the records that still have
surviving samples, alongside frags:

  • append a record to live when it is collected with kept > 0;
  • in dropTrackSamplesFrom, scan back over live rather than frags,
    break as soon as a record comes out only partially trimmed (newKept > 0),
    and truncate live to drop the records voided by this cut.

That is correct for the same reason your current break is:
firstSample+kept is non-decreasing along live too, since it is a
subsequence of frags and firstSample is non-decreasing along any
subsequence. Once a record comes out partially trimmed, every earlier live
record satisfies firstSample+kept <= firstSample_rec < keep, so breaking
there is safe. Each record is voided at most once and then never revisited,
so the total scanning work is linear.

I tried it on top of your commit: the descending shape drops from 4.23 s to
86 ms and becomes linear, the chain and control shapes are unchanged, the
full suite stays green, and the real-file resend still converts
byte-identically. Extending the benchmark with a descending variant next to
the chain one would keep it from regressing.

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).
@tobbee

tobbee commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the update. Looks good!

@tobbee
tobbee merged commit eac43c8 into Eyevinn:master Sep 7, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants