Allow the number of ad groups to grow with AdsMediaSource

This enables `AdsMediaSource` to be used with a live media
source that has a growing `AdPlaybackState` to which ad groups
can be appended.

Before this change, `AdsMediaSource` asserted that the number
of ad groups was kept the same, else an exception was thrown.
After this change, the assertion checks the validity of the
update and throws in case the update isn't considered valid.

An update is valid if ad groups are appended to the existing
`AdPlaybackState` or ads are appended to existing ad groups.
Further the `adGroupIndex` and `timeUs`of an existing ad
group can not be changed and once a media item is set for a
given ad, that media item can't be changed either.

PiperOrigin-RevId: 707244455
This commit is contained in:
bachinger 2024-12-17 14:07:43 -08:00 committed by Copybara-Service
parent aa2ee8f702
commit d4f4a2c1d4
3 changed files with 311 additions and 11 deletions

View File

@ -40,6 +40,9 @@
* Disable use of asynchronous decryption in MediaCodec to avoid reported
codec timeout issues with this platform API
([#1641](https://github.com/androidx/media/issues/1641)).
* Change `AdsMediaSource` to allow the `AdPlaybackStates` to grow by
appending ad groups. Invalid modifications are detected and throw an
exception.
* Transformer:
* Update parameters of `VideoFrameProcessor.registerInputStream` and
`VideoFrameProcessor.Listener.onInputStreamRegistered` to use `Format`.

View File

@ -25,6 +25,7 @@ import android.os.SystemClock;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.AdPlaybackState.AdGroup;
import androidx.media3.common.AdViewProvider;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
@ -54,6 +55,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* A {@link MediaSource} that inserts ads linearly into a provided content media source.
@ -352,16 +354,61 @@ public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
private void onAdPlaybackState(AdPlaybackState adPlaybackState) {
if (this.adPlaybackState == null) {
adMediaSourceHolders = new AdMediaSourceHolder[adPlaybackState.adGroupCount][];
int playableAdGroupCount =
adPlaybackState.adGroupCount
- (adPlaybackState.endsWithLivePostrollPlaceHolder() ? 1 : 0);
adMediaSourceHolders = new AdMediaSourceHolder[playableAdGroupCount][];
Arrays.fill(adMediaSourceHolders, new AdMediaSourceHolder[0]);
} else {
checkState(adPlaybackState.adGroupCount == this.adPlaybackState.adGroupCount);
int adGroupInsertionCount =
checkValidAdPlaybackStateUpdate(this.adPlaybackState, adPlaybackState);
if (adGroupInsertionCount > 0) {
adMediaSourceHolders =
growAdMediaSourceHolderGrid(adMediaSourceHolders, adGroupInsertionCount);
}
}
this.adPlaybackState = adPlaybackState;
maybeUpdateAdMediaSources();
maybeUpdateSourceInfo();
}
private static int checkValidAdPlaybackStateUpdate(
AdPlaybackState oldAdPlaybackState, AdPlaybackState newAdPlaybackState) {
checkState(
oldAdPlaybackState.endsWithLivePostrollPlaceHolder()
== newAdPlaybackState.endsWithLivePostrollPlaceHolder());
int insertionCount = newAdPlaybackState.adGroupCount - oldAdPlaybackState.adGroupCount;
checkState(insertionCount >= 0);
for (int i = newAdPlaybackState.removedAdGroupCount; i < oldAdPlaybackState.adGroupCount; i++) {
AdGroup oldAdGroup = oldAdPlaybackState.getAdGroup(i);
if (oldAdGroup.isLivePostrollPlaceholder()) {
// Post-roll placeholder must be at the last index.
checkState(i == oldAdPlaybackState.adGroupCount - 1);
break;
}
AdGroup newAdGroup = newAdPlaybackState.getAdGroup(i);
checkState(oldAdGroup.count <= newAdGroup.count);
checkState(oldAdGroup.timeUs == newAdGroup.timeUs);
for (int j = 0; j < oldAdGroup.count; j++) {
if (oldAdGroup.mediaItems[j] != null) {
checkState(oldAdGroup.mediaItems[j].equals(newAdGroup.mediaItems[j]));
}
}
}
return insertionCount;
}
private static @NullableType AdMediaSourceHolder[][] growAdMediaSourceHolderGrid(
@NullableType AdMediaSourceHolder[][] grid, int insertionCount) {
@NullableType
AdMediaSourceHolder[][] grownGrid = new AdMediaSourceHolder[grid.length + insertionCount][];
System.arraycopy(grid, 0, grownGrid, 0, grid.length);
for (int i = grid.length; i < grownGrid.length; i++) {
grownGrid[i] = new AdMediaSourceHolder[0];
}
return grownGrid;
}
/**
* Initializes any {@link AdMediaSourceHolder AdMediaSourceHolders} where the ad media URI is
* newly known.
@ -378,7 +425,7 @@ public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
@Nullable
AdMediaSourceHolder adMediaSourceHolder =
this.adMediaSourceHolders[adGroupIndex][adIndexInAdGroup];
AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
if (adMediaSourceHolder != null
&& !adMediaSourceHolder.hasMediaSource()
&& adIndexInAdGroup < adGroup.mediaItems.length) {
@ -409,8 +456,12 @@ public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
}
}
@RequiresNonNull("adPlaybackState")
private long[][] getAdDurationsUs() {
long[][] adDurationsUs = new long[adMediaSourceHolders.length][];
boolean hasPostRollPlaceholder =
checkNotNull(adPlaybackState).endsWithLivePostrollPlaceHolder();
int adGroupCount = adMediaSourceHolders.length + (hasPostRollPlaceholder ? 1 : 0);
long[][] adDurationsUs = new long[adGroupCount][];
for (int i = 0; i < adMediaSourceHolders.length; i++) {
adDurationsUs[i] = new long[adMediaSourceHolders[i].length];
for (int j = 0; j < adMediaSourceHolders[i].length; j++) {
@ -418,6 +469,10 @@ public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
adDurationsUs[i][j] = holder == null ? C.TIME_UNSET : holder.getDurationUs();
}
}
if (hasPostRollPlaceholder) {
// Set the pseudo-durations of the placeholder that is not represented by the holders.
adDurationsUs[adGroupCount - 1] = new long[0];
}
return adDurationsUs;
}

View File

@ -17,6 +17,7 @@ package androidx.media3.exoplayer.source.ads;
import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
@ -100,7 +101,7 @@ public final class AdsMediaSourceTest {
private static final Object CONTENT_PERIOD_UID =
CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0);
private static final AdPlaybackState AD_PLAYBACK_STATE =
private static final AdPlaybackState PREROLL_AD_PLAYBACK_STATE =
new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0)
.withContentDurationUs(CONTENT_DURATION_US)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
@ -121,6 +122,7 @@ public final class AdsMediaSourceTest {
private FakeMediaSource prerollAdMediaSource;
@Mock private MediaSourceCaller mockMediaSourceCaller;
private AdsMediaSource adsMediaSource;
private EventListener adsLoaderEventListener;
@Before
public void setUp() {
@ -156,15 +158,17 @@ public final class AdsMediaSourceTest {
eq(TEST_ADS_ID),
eq(mockAdViewProvider),
eventListenerArgumentCaptor.capture());
adsLoaderEventListener = eventListenerArgumentCaptor.getValue();
}
// Simulate loading a preroll ad.
AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue();
adsLoaderEventListener.onAdPlaybackState(AD_PLAYBACK_STATE);
private void setAdPlaybackState(AdPlaybackState adPlaybackState) {
adsLoaderEventListener.onAdPlaybackState(adPlaybackState);
shadowOf(Looper.getMainLooper()).idle();
}
@Test
public void createPeriod_forPreroll_preparesChildAdMediaSourceAndRefreshesSourceInfo() {
setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE);
// This should be unused if we only create the preroll period.
contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
adsMediaSource.createPeriod(
@ -181,12 +185,13 @@ public final class AdsMediaSourceTest {
verify(mockMediaSourceCaller)
.onSourceInfoRefreshed(
adsMediaSource,
new SinglePeriodAdTimeline(PLACEHOLDER_CONTENT_TIMELINE, AD_PLAYBACK_STATE));
new SinglePeriodAdTimeline(PLACEHOLDER_CONTENT_TIMELINE, PREROLL_AD_PLAYBACK_STATE));
}
@Test
public void
createPeriod_forPreroll_preparesChildAdMediaSourceAndRefreshesSourceInfoWithAdMediaSourceInfo() {
setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE);
// This should be unused if we only create the preroll period.
contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
adsMediaSource.createPeriod(
@ -205,11 +210,13 @@ public final class AdsMediaSourceTest {
adsMediaSource,
new SinglePeriodAdTimeline(
PLACEHOLDER_CONTENT_TIMELINE,
AD_PLAYBACK_STATE.withAdDurationsUs(new long[][] {{PREROLL_AD_DURATION_US}})));
PREROLL_AD_PLAYBACK_STATE.withAdDurationsUs(
new long[][] {{PREROLL_AD_DURATION_US}})));
}
@Test
public void createPeriod_forPreroll_createsChildPrerollAdMediaPeriod() {
setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE);
adsMediaSource.createPeriod(
new MediaPeriodId(
CONTENT_PERIOD_UID,
@ -227,6 +234,7 @@ public final class AdsMediaSourceTest {
@Test
public void createPeriod_forContent_createsChildContentMediaPeriodAndLoadsContentTimeline() {
setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE);
contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
shadowOf(Looper.getMainLooper()).idle();
adsMediaSource.createPeriod(
@ -241,11 +249,12 @@ public final class AdsMediaSourceTest {
.onSourceInfoRefreshed(eq(adsMediaSource), adsTimelineCaptor.capture());
TestUtil.timelinesAreSame(
adsTimelineCaptor.getValue(),
new SinglePeriodAdTimeline(CONTENT_TIMELINE, AD_PLAYBACK_STATE));
new SinglePeriodAdTimeline(CONTENT_TIMELINE, PREROLL_AD_PLAYBACK_STATE));
}
@Test
public void releasePeriod_releasesChildMediaPeriodsAndSources() {
setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE);
contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
MediaPeriod prerollAdMediaPeriod =
adsMediaSource.createPeriod(
@ -692,6 +701,239 @@ public final class AdsMediaSourceTest {
.isEqualTo(133_000_000); // Overridden by AdsMediaSource with the actual source duration.
}
@Test
public void onAdPlaybackState_correctAdPlaybackStateInTimeline() {
ArgumentCaptor<Timeline> timelineCaptor = ArgumentCaptor.forClass(Timeline.class);
setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE);
verify(mockMediaSourceCaller).onSourceInfoRefreshed(any(), timelineCaptor.capture());
assertThat(
timelineCaptor
.getValue()
.getPeriod(/* periodIndex= */ 0, new Timeline.Period())
.adPlaybackState)
.isEqualTo(PREROLL_AD_PLAYBACK_STATE);
}
@Test
public void onAdPlaybackState_growingLiveAdPlaybackState_correctAdPlaybackStateInTimeline() {
AdPlaybackState initialLiveAdPlaybackState =
new AdPlaybackState("adsId")
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false);
AdPlaybackState singleAdInFirstAdGroup =
initialLiveAdPlaybackState
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-0"));
AdPlaybackState twoAdsInFirstAdGroup =
singleAdInFirstAdGroup
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L, 2_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 1,
MediaItem.fromUri("https://example.com/ad0-1"));
AdPlaybackState singleAdInSecondAdGroup =
twoAdsInFirstAdGroup
.withNewAdGroup(/* adGroupIndex= */ 1, /* adGroupTimeUs= */ 10_000L)
.withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 1, 10_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 1, 10_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad1-0"));
AdPlaybackState twoAdsInSecondAdGroup =
singleAdInSecondAdGroup
.withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 2)
.withAdDurationsUs(/* adGroupIndex= */ 1, 10_000L, 20_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 1, 30_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 1,
MediaItem.fromUri("https://example.com/ad1-1"));
ArgumentCaptor<Timeline> timelineCaptor = ArgumentCaptor.forClass(Timeline.class);
setAdPlaybackState(initialLiveAdPlaybackState);
setAdPlaybackState(singleAdInFirstAdGroup);
setAdPlaybackState(twoAdsInFirstAdGroup);
setAdPlaybackState(singleAdInSecondAdGroup);
setAdPlaybackState(twoAdsInSecondAdGroup);
verify(mockMediaSourceCaller, times(5)).onSourceInfoRefreshed(any(), timelineCaptor.capture());
assertThat(
timelineCaptor
.getAllValues()
.get(0)
.getPeriod(0, new Timeline.Period())
.adPlaybackState)
.isEqualTo(initialLiveAdPlaybackState);
assertThat(
timelineCaptor
.getAllValues()
.get(1)
.getPeriod(0, new Timeline.Period())
.adPlaybackState)
.isEqualTo(
singleAdInFirstAdGroup.withAdDurationsUs(
/* adGroupIndex= */ 0,
/* adDurationsUs...= */ C.TIME_UNSET)); // durations are overridden by ads source
assertThat(
timelineCaptor
.getAllValues()
.get(2)
.getPeriod(0, new Timeline.Period())
.adPlaybackState)
.isEqualTo(
twoAdsInFirstAdGroup.withAdDurationsUs(
/* adGroupIndex= */ 0, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET));
assertThat(
timelineCaptor
.getAllValues()
.get(3)
.getPeriod(0, new Timeline.Period())
.adPlaybackState)
.isEqualTo(
singleAdInSecondAdGroup
.withAdDurationsUs(
/* adGroupIndex= */ 0, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET)
.withAdDurationsUs(/* adGroupIndex= */ 1, /* adDurationsUs...= */ C.TIME_UNSET));
assertThat(
timelineCaptor
.getAllValues()
.get(4)
.getPeriod(0, new Timeline.Period())
.adPlaybackState)
.isEqualTo(
twoAdsInSecondAdGroup
.withAdDurationsUs(
/* adGroupIndex= */ 0, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET)
.withAdDurationsUs(
/* adGroupIndex= */ 1, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET));
}
@Test
public void
onAdPlaybackState_shrinkingAdPlaybackStateForLiveStream_throwsIllegalStateException() {
AdPlaybackState initialLiveAdPlaybackState =
new AdPlaybackState("adsId")
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-0"));
setAdPlaybackState(initialLiveAdPlaybackState);
assertThrows(
IllegalStateException.class,
() ->
setAdPlaybackState(
new AdPlaybackState("adsId")
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)));
}
@Test
public void onAdPlaybackState_timeUsOfAdGroupChanged_throwsIllegalStateException() {
AdPlaybackState initialLiveAdPlaybackState =
new AdPlaybackState("adsId")
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-0"));
setAdPlaybackState(initialLiveAdPlaybackState);
assertThrows(
IllegalStateException.class,
() ->
setAdPlaybackState(
initialLiveAdPlaybackState.withAdGroupTimeUs(
/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 1234L)));
}
@Test
public void onAdPlaybackState_mediaItemOfAdChanged_throwsIllegalStateException() {
AdPlaybackState initialLiveAdPlaybackState =
new AdPlaybackState("adsId")
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false)
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-0"));
setAdPlaybackState(initialLiveAdPlaybackState);
assertThrows(
IllegalStateException.class,
() ->
setAdPlaybackState(
initialLiveAdPlaybackState.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-1"))));
}
@Test
public void onAdPlaybackState_postRollAdded_throwsIllegalStateException() {
AdPlaybackState withoutLivePostRollPlaceholder =
new AdPlaybackState("adsId")
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-0"));
setAdPlaybackState(withoutLivePostRollPlaceholder);
assertThrows(
IllegalStateException.class,
() ->
setAdPlaybackState(
withoutLivePostRollPlaceholder.withLivePostrollPlaceholderAppended(
/* isServerSideInserted= */ false)));
}
@Test
public void onAdPlaybackState_postRollRemoved_throwsIllegalStateException() {
AdPlaybackState withoutLivePostRollPlaceholder =
new AdPlaybackState("adsId")
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L)
.withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("https://example.com/ad0-0"));
setAdPlaybackState(
withoutLivePostRollPlaceholder.withLivePostrollPlaceholderAppended(
/* isServerSideInserted= */ false));
assertThrows(
IllegalStateException.class, () -> setAdPlaybackState(withoutLivePostRollPlaceholder));
}
private static class NoOpAdsLoader implements AdsLoader {
@Override