Skip to content

Do not report clipping preroll for clips that only set an end position - #3398

Open
oguzhaneksi wants to merge 1 commit into
androidx:mainfrom
oguzhaneksi:fix/clipping-media-period-preroll-flags
Open

oguzhaneksi wants to merge 1 commit into
androidx:mainfrom
oguzhaneksi:fix/clipping-media-period-preroll-flags

Conversation

@oguzhaneksi

@oguzhaneksi oguzhaneksi commented Aug 30, 2026

Copy link
Copy Markdown

Fixes #3371.

Problem

Playing a VoD playlist with HLS interstitials produces the period structure
A a B b C c D. Every return from an interstitial into content disables and
re-enables the renderers — a visible hesitation at each ad break, even when the
content resumes exactly on a segment boundary and there is nothing to skip.

Root cause

AdsMediaSource.createPeriod() wraps a content period that has a following ad
group in a ClippingMediaPeriod with enableInitialDiscontinuity = true,
startUs = 0 and an end clip. shouldKeepInitialDiscontinuity then applies a
heuristic: resumed at a non-zero position, and not all-sync-samples ⇒ assume
preroll
. For H.264 that is always true, so every clipped content period claims
preroll.

The heuristic itself is a reasonable fallback. The problem is where its answer
went. ClippingSampleStream.getFlags() wrote it onto the
SampleStream.getFlags() protocol:

if (hasPreroll) {
  flags &= ~FLAG_MAYBE_HAS_PREROLL;   // discards the child's "I don't know"
  flags |= FLAG_HAS_PREROLL;          // publishes a guess as a fact
}

So a clip overwrote whatever its child honestly reported and presented a guess
to every consumer of the protocol — including a child that had measured the
answer and reported no preroll.

The change

getFlags() adds FLAG_HAS_PREROLL only when the clipping itself introduces
preroll:

if (startUs != 0 && hasPreroll) {

With a non-zero clipping start the wrapped period genuinely cannot know about
the preroll, because sample streams provide buffers from a key-frame that may
precede the clip start. With a zero clipping start the samples delivered are
exactly the samples the wrapped period delivers, so its own report is exact and
is now passed through.

shouldKeepInitialDiscontinuity, hasPreroll, anyTrackHasPreroll and
pendingInitialDiscontinuityPositionUs are untouched, so readDiscontinuity()
behaves exactly as before.

The startUs != 0 restriction is deliberate. Removing the discontinuity for
non-zero clip starts was tried and broke 88 ClippingPlaylistPlaybackTest /
MergingPlaylistPlaybackTest cases by feeding pre-clip-start buffers into the
codec.

The default configuration is unaffected

The two channels are never both consulted:

Configuration Initial discontinuity read from
gate closed (default) MediaPeriod.readDiscontinuity()
gate open (FLAG_PER_STREAM_MEDIA_PROGRESSION) SampleStream.getFlags()

The preroll bits have exactly one consumer,
ExoPlayerImplInternal.updateRendererForTransition, reachable only via
maybeUpdateReadingRenderersPerStream(), which updatePeriods() calls only when
perStreamMediaProgressionEnabled. Every other getFlags() call site
(MediaCodecRenderer, DecoderVideoRenderer, ServerSideAdInsertionMediaSource)
reads only FLAG_STRICT_DURATION.

DefaultAnalyticsCollectorTest.adPlayback passes unedited, which is the
regression proof for the default path.

Tests

ExoPlayerAdTest gains two end-to-end reproductions and is parameterised on the
flag, following the existing pattern in ExoPlayerTest:

  • playAds_returningFromSegmentAlignedMidRoll_keepsRenderersEnabled — mid-rolls
    on segment boundaries, content resumes at a sync sample. Goes from
    [2 enables, 0 preroll rendered] to [1, 0] with the gate open. This is Returning from an interstitial playlist causes renderer disable and then reenable #3371.
  • playAds_returningFromMidSegmentMidRoll_skipsPrerollOfClippedPeriodsOnly
    mid-rolls mid-segment, content really does carry preroll. Unchanged by this
    patch, and pinned so that any future change to it is deliberate.

Their FakeSampleStream reports preroll honestly
(hasPreroll = segmentStartUs < preparePositionUs), so the segment-aligned case
reports 0 and the mid-segment case reports FLAG_HAS_PREROLL. That isolates
exactly the fabrication this patch removes: the second test's numbers do not move.

ClippingMediaPeriodTest: three startUs == 0 assertions lose the fabricated
flag, and one test is added for the newly reachable behaviour — a child reporting
FLAG_MAYBE_HAS_PREROLL under an end-only clip now has it passed through rather
than replaced by FLAG_HAS_PREROLL. That distinction matters: at
ExoPlayerImplInternal MAYBE defers the transition until the child resolves,
where HAS_PREROLL forces a renderer reset.

Full run over :lib-exoplayer, :lib-exoplayer-dash, :lib-exoplayer-hls,
:lib-exoplayer-ima and :lib-exoplayer-rtsp: 11 753 cases, 0 failures. Against
a baseline recorded on unmodified main, the only differences are in
ExoPlayerAdTest itself. No golden dump file changes.

Device verification

Demo app, emulator, bipbop-with-interstitials-boundary.m3u8 (3 midrolls with
X-SNAP="IN", resuming on a segment boundary). Same build, the flag is the only
variable, counted from EventLogger over a full ~170 s playback:

Configuration videoEnabled videoDisabled
gate closed 3 2
gate open 1 0

Negative control, bipbop-with-interstitials-unsnapped.m3u8 (midrolls
mid-segment, content genuinely carries preroll), gate open: 4 enables / 3
disables — all three ad→content transitions still reset, as they must. This patch
does not suppress a real discontinuity.

Known gap

ProgressiveMediaPeriod.getFlags() reports only the static
Format.hasPrerollSamples and never seek-induced preroll, so once the gate is
enabled, AdsMediaSource over progressive content has no source reporting the
preroll this patch stops fabricating. Filed separately as #3397, with a measured
reproduction, and flagged with a TODO at the changed line. It is not reachable
today, since the flag is off by default.

Two things noticed nearby, not changed here

Both are pre-existing and out of scope for this patch; happy to split either into
its own change if useful.

  1. shouldKeepInitialDiscontinuity calls
    MimeTypes.allSamplesAreSyncSamples(mimeType, codecs) for any track type,
    while the equivalent reasoning in SampleQueue.canDiscardAllSamplesToStartTime
    pairs it with trackType == C.TRACK_TYPE_AUDIO, on the grounds that the
    optimization is only safe where a sample's inherent duration is negligible.
  2. ClippingMediaPeriod.readDiscontinuity() calls itself where the comment says
    "read an initial discontinuity from the child". It terminates only because the
    lines above it have already cleared pendingInitialDiscontinuityPositionUs,
    so the re-entry takes the other branch. It works, but it reads as a bug.

@tonihei

tonihei commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the fix! I'll work on merging this internally, but I'll fix #3397 first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants