Commit 2480e0a4 by tonihei Committed by Ian Baker

Add option to align durations of MergingMediaSource.

Without this feature it's impossible to nicely merge multiple sources
with different durations if these durations are not known exactly
before the start of playback.

Issue: #8422
PiperOrigin-RevId: 350567625
parent 1ef06b8d
...@@ -42,6 +42,9 @@ ...@@ -42,6 +42,9 @@
* Populate codecs string for H.264/AVC in MP4, Matroska and FLV streams to * Populate codecs string for H.264/AVC in MP4, Matroska and FLV streams to
allow decoder capability checks based on codec profile/level allow decoder capability checks based on codec profile/level
([#8393](https://github.com/google/ExoPlayer/issues/8393)). ([#8393](https://github.com/google/ExoPlayer/issues/8393)).
* Add option to `MergingMediaSource` to clip the durations of all sources
to have the same length
([#8422](https://github.com/google/ExoPlayer/issues/8422)).
* Track selection: * Track selection:
* Allow parallel adaptation for video and audio * Allow parallel adaptation for video and audio
([#5111](https://github.com/google/ExoPlayer/issues/5111)). ([#5111](https://github.com/google/ExoPlayer/issues/5111)).
......
...@@ -15,12 +15,18 @@ ...@@ -15,12 +15,18 @@
*/ */
package com.google.android.exoplayer2.source; package com.google.android.exoplayer2.source;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static java.lang.Math.min;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.common.collect.Multimap;
import com.google.common.collect.MultimapBuilder;
import java.io.IOException; import java.io.IOException;
import java.lang.annotation.Documented; import java.lang.annotation.Documented;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
...@@ -28,6 +34,8 @@ import java.lang.annotation.RetentionPolicy; ...@@ -28,6 +34,8 @@ import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/** /**
* Merges multiple {@link MediaSource}s. * Merges multiple {@link MediaSource}s.
...@@ -70,21 +78,26 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> { ...@@ -70,21 +78,26 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
new MediaItem.Builder().setMediaId("MergingMediaSource").build(); new MediaItem.Builder().setMediaId("MergingMediaSource").build();
private final boolean adjustPeriodTimeOffsets; private final boolean adjustPeriodTimeOffsets;
private final boolean clipDurations;
private final MediaSource[] mediaSources; private final MediaSource[] mediaSources;
private final Timeline[] timelines; private final Timeline[] timelines;
private final ArrayList<MediaSource> pendingTimelineSources; private final ArrayList<MediaSource> pendingTimelineSources;
private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
private final Map<Object, Long> clippedDurationsUs;
private final Multimap<Object, ClippingMediaPeriod> clippedMediaPeriods;
private int periodCount; private int periodCount;
private long[][] periodTimeOffsetsUs; private long[][] periodTimeOffsetsUs;
@Nullable private IllegalMergeException mergeError; @Nullable private IllegalMergeException mergeError;
/** /**
* Creates a merging media source. * Creates a merging media source.
* *
* <p>Offsets between the timestamps in the media sources will not be adjusted. * <p>Neither offsets between the timestamps in the media sources nor the durations of the media
* sources will be adjusted.
* *
* @param mediaSources The {@link MediaSource}s to merge. * @param mediaSources The {@link MediaSource MediaSources} to merge.
*/ */
public MergingMediaSource(MediaSource... mediaSources) { public MergingMediaSource(MediaSource... mediaSources) {
this(/* adjustPeriodTimeOffsets= */ false, mediaSources); this(/* adjustPeriodTimeOffsets= */ false, mediaSources);
...@@ -93,12 +106,32 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> { ...@@ -93,12 +106,32 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
/** /**
* Creates a merging media source. * Creates a merging media source.
* *
* <p>Durations of the media sources will not be adjusted.
*
* @param adjustPeriodTimeOffsets Whether to adjust timestamps of the merged media sources to all * @param adjustPeriodTimeOffsets Whether to adjust timestamps of the merged media sources to all
* start at the same time. * start at the same time.
* @param mediaSources The {@link MediaSource}s to merge. * @param mediaSources The {@link MediaSource MediaSources} to merge.
*/ */
public MergingMediaSource(boolean adjustPeriodTimeOffsets, MediaSource... mediaSources) { public MergingMediaSource(boolean adjustPeriodTimeOffsets, MediaSource... mediaSources) {
this(adjustPeriodTimeOffsets, new DefaultCompositeSequenceableLoaderFactory(), mediaSources); this(adjustPeriodTimeOffsets, /* clipDurations= */ false, mediaSources);
}
/**
* Creates a merging media source.
*
* @param adjustPeriodTimeOffsets Whether to adjust timestamps of the merged media sources to all
* start at the same time.
* @param clipDurations Whether to clip the durations of the media sources to match the shortest
* duration.
* @param mediaSources The {@link MediaSource MediaSources} to merge.
*/
public MergingMediaSource(
boolean adjustPeriodTimeOffsets, boolean clipDurations, MediaSource... mediaSources) {
this(
adjustPeriodTimeOffsets,
clipDurations,
new DefaultCompositeSequenceableLoaderFactory(),
mediaSources);
} }
/** /**
...@@ -106,22 +139,28 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> { ...@@ -106,22 +139,28 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
* *
* @param adjustPeriodTimeOffsets Whether to adjust timestamps of the merged media sources to all * @param adjustPeriodTimeOffsets Whether to adjust timestamps of the merged media sources to all
* start at the same time. * start at the same time.
* @param clipDurations Whether to clip the durations of the media sources to match the shortest
* duration.
* @param compositeSequenceableLoaderFactory A factory to create composite {@link * @param compositeSequenceableLoaderFactory A factory to create composite {@link
* SequenceableLoader}s for when this media source loads data from multiple streams (video, * SequenceableLoader}s for when this media source loads data from multiple streams (video,
* audio etc...). * audio etc...).
* @param mediaSources The {@link MediaSource}s to merge. * @param mediaSources The {@link MediaSource MediaSources} to merge.
*/ */
public MergingMediaSource( public MergingMediaSource(
boolean adjustPeriodTimeOffsets, boolean adjustPeriodTimeOffsets,
boolean clipDurations,
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
MediaSource... mediaSources) { MediaSource... mediaSources) {
this.adjustPeriodTimeOffsets = adjustPeriodTimeOffsets; this.adjustPeriodTimeOffsets = adjustPeriodTimeOffsets;
this.clipDurations = clipDurations;
this.mediaSources = mediaSources; this.mediaSources = mediaSources;
this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
pendingTimelineSources = new ArrayList<>(Arrays.asList(mediaSources)); pendingTimelineSources = new ArrayList<>(Arrays.asList(mediaSources));
periodCount = PERIOD_COUNT_UNSET; periodCount = PERIOD_COUNT_UNSET;
timelines = new Timeline[mediaSources.length]; timelines = new Timeline[mediaSources.length];
periodTimeOffsetsUs = new long[0][]; periodTimeOffsetsUs = new long[0][];
clippedDurationsUs = new HashMap<>();
clippedMediaPeriods = MultimapBuilder.hashKeys().arrayListValues().build();
} }
/** /**
...@@ -167,12 +206,33 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> { ...@@ -167,12 +206,33 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
mediaSources[i].createPeriod( mediaSources[i].createPeriod(
childMediaPeriodId, allocator, startPositionUs - periodTimeOffsetsUs[periodIndex][i]); childMediaPeriodId, allocator, startPositionUs - periodTimeOffsetsUs[periodIndex][i]);
} }
return new MergingMediaPeriod( MediaPeriod mediaPeriod =
new MergingMediaPeriod(
compositeSequenceableLoaderFactory, periodTimeOffsetsUs[periodIndex], periods); compositeSequenceableLoaderFactory, periodTimeOffsetsUs[periodIndex], periods);
if (clipDurations) {
mediaPeriod =
new ClippingMediaPeriod(
mediaPeriod,
/* enableInitialDiscontinuity= */ true,
/* startUs= */ 0,
/* endUs= */ checkNotNull(clippedDurationsUs.get(id.periodUid)));
clippedMediaPeriods.put(id.periodUid, (ClippingMediaPeriod) mediaPeriod);
}
return mediaPeriod;
} }
@Override @Override
public void releasePeriod(MediaPeriod mediaPeriod) { public void releasePeriod(MediaPeriod mediaPeriod) {
if (clipDurations) {
ClippingMediaPeriod clippingMediaPeriod = (ClippingMediaPeriod) mediaPeriod;
for (Map.Entry<Object, ClippingMediaPeriod> entry : clippedMediaPeriods.entries()) {
if (entry.getValue().equals(clippingMediaPeriod)) {
clippedMediaPeriods.remove(entry.getKey(), entry.getValue());
break;
}
}
mediaPeriod = clippingMediaPeriod.mediaPeriod;
}
MergingMediaPeriod mergingPeriod = (MergingMediaPeriod) mediaPeriod; MergingMediaPeriod mergingPeriod = (MergingMediaPeriod) mediaPeriod;
for (int i = 0; i < mediaSources.length; i++) { for (int i = 0; i < mediaSources.length; i++) {
mediaSources[i].releasePeriod(mergingPeriod.getChildPeriod(i)); mediaSources[i].releasePeriod(mergingPeriod.getChildPeriod(i));
...@@ -210,7 +270,12 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> { ...@@ -210,7 +270,12 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
if (adjustPeriodTimeOffsets) { if (adjustPeriodTimeOffsets) {
computePeriodTimeOffsets(); computePeriodTimeOffsets();
} }
refreshSourceInfo(timelines[0]); Timeline mergedTimeline = timelines[0];
if (clipDurations) {
updateClippedDuration();
mergedTimeline = new ClippedTimeline(mergedTimeline, clippedDurationsUs);
}
refreshSourceInfo(mergedTimeline);
} }
} }
...@@ -234,4 +299,72 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> { ...@@ -234,4 +299,72 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
} }
} }
} }
private void updateClippedDuration() {
Timeline.Period period = new Timeline.Period();
for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) {
long minDurationUs = C.TIME_END_OF_SOURCE;
for (int timelineIndex = 0; timelineIndex < timelines.length; timelineIndex++) {
long durationUs = timelines[timelineIndex].getPeriod(periodIndex, period).getDurationUs();
if (durationUs == C.TIME_UNSET) {
continue;
}
long adjustedDurationUs = durationUs + periodTimeOffsetsUs[periodIndex][timelineIndex];
if (minDurationUs == C.TIME_END_OF_SOURCE || adjustedDurationUs < minDurationUs) {
minDurationUs = adjustedDurationUs;
}
}
Object periodUid = timelines[0].getUidOfPeriod(periodIndex);
clippedDurationsUs.put(periodUid, minDurationUs);
for (ClippingMediaPeriod clippingMediaPeriod : clippedMediaPeriods.get(periodUid)) {
clippingMediaPeriod.updateClipping(/* startUs= */ 0, /* endUs= */ minDurationUs);
}
}
}
private static final class ClippedTimeline extends ForwardingTimeline {
private final long[] periodDurationsUs;
private final long[] windowDurationsUs;
public ClippedTimeline(Timeline timeline, Map<Object, Long> clippedDurationsUs) {
super(timeline);
int windowCount = timeline.getWindowCount();
windowDurationsUs = new long[timeline.getWindowCount()];
Window window = new Window();
for (int i = 0; i < windowCount; i++) {
windowDurationsUs[i] = timeline.getWindow(i, window).durationUs;
}
int periodCount = timeline.getPeriodCount();
periodDurationsUs = new long[periodCount];
Period period = new Period();
for (int i = 0; i < periodCount; i++) {
timeline.getPeriod(i, period, /* setIds= */ true);
long clippedDurationUs = checkNotNull(clippedDurationsUs.get(period.uid));
periodDurationsUs[i] =
clippedDurationUs != C.TIME_END_OF_SOURCE ? clippedDurationUs : period.durationUs;
if (period.durationUs != C.TIME_UNSET) {
windowDurationsUs[period.windowIndex] -= period.durationUs - periodDurationsUs[i];
}
}
}
@Override
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
super.getWindow(windowIndex, window, defaultPositionProjectionUs);
window.durationUs = windowDurationsUs[windowIndex];
window.defaultPositionUs =
window.durationUs == C.TIME_UNSET || window.defaultPositionUs == C.TIME_UNSET
? window.defaultPositionUs
: min(window.defaultPositionUs, window.durationUs);
return window;
}
@Override
public Period getPeriod(int periodIndex, Period period, boolean setIds) {
super.getPeriod(periodIndex, period, setIds);
period.durationUs = periodDurationsUs[periodIndex];
return period;
}
}
} }
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
package com.google.android.exoplayer2.source; package com.google.android.exoplayer2.source;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail; import static org.junit.Assert.assertThrows;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
...@@ -35,35 +35,65 @@ import org.junit.runner.RunWith; ...@@ -35,35 +35,65 @@ import org.junit.runner.RunWith;
public class MergingMediaSourceTest { public class MergingMediaSourceTest {
@Test @Test
public void mergingDynamicTimelines() throws IOException { public void prepare_withoutDurationClipping_usesTimelineOfFirstSource() throws IOException {
FakeTimeline firstTimeline = FakeTimeline timeline1 =
new FakeTimeline(new TimelineWindowDefinition(true, true, C.TIME_UNSET)); new FakeTimeline(
FakeTimeline secondTimeline = new TimelineWindowDefinition(
new FakeTimeline(new TimelineWindowDefinition(true, true, C.TIME_UNSET)); /* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ 30));
testMergingMediaSourcePrepare(firstTimeline, secondTimeline); FakeTimeline timeline2 =
new FakeTimeline(
new TimelineWindowDefinition(
/* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ C.TIME_UNSET));
FakeTimeline timeline3 =
new FakeTimeline(
new TimelineWindowDefinition(
/* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ 10));
Timeline mergedTimeline =
prepareMergingMediaSource(/* clipDurations= */ false, timeline1, timeline2, timeline3);
assertThat(mergedTimeline).isEqualTo(timeline1);
} }
@Test @Test
public void mergingStaticTimelines() throws IOException { public void prepare_withDurationClipping_usesDurationOfShortestSource() throws IOException {
FakeTimeline firstTimeline = new FakeTimeline(new TimelineWindowDefinition(true, false, 20)); FakeTimeline timeline1 =
FakeTimeline secondTimeline = new FakeTimeline(new TimelineWindowDefinition(true, false, 10)); new FakeTimeline(
testMergingMediaSourcePrepare(firstTimeline, secondTimeline); new TimelineWindowDefinition(
/* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ 30));
FakeTimeline timeline2 =
new FakeTimeline(
new TimelineWindowDefinition(
/* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ C.TIME_UNSET));
FakeTimeline timeline3 =
new FakeTimeline(
new TimelineWindowDefinition(
/* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ 10));
Timeline mergedTimeline =
prepareMergingMediaSource(/* clipDurations= */ true, timeline1, timeline2, timeline3);
assertThat(mergedTimeline).isEqualTo(timeline3);
} }
@Test @Test
public void mergingTimelinesWithDifferentPeriodCounts() throws IOException { public void prepare_differentPeriodCounts_fails() throws IOException {
FakeTimeline firstTimeline = new FakeTimeline(new TimelineWindowDefinition(1, null)); FakeTimeline firstTimeline =
FakeTimeline secondTimeline = new FakeTimeline(new TimelineWindowDefinition(2, null)); new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 1));
try { FakeTimeline secondTimeline =
testMergingMediaSourcePrepare(firstTimeline, secondTimeline); new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 2));
fail("Expected merging to fail.");
} catch (IllegalMergeException e) { IllegalMergeException exception =
assertThat(e.reason).isEqualTo(IllegalMergeException.REASON_PERIOD_COUNT_MISMATCH); assertThrows(
} IllegalMergeException.class,
() ->
prepareMergingMediaSource(
/* clipDurations= */ false, firstTimeline, secondTimeline));
assertThat(exception.reason).isEqualTo(IllegalMergeException.REASON_PERIOD_COUNT_MISMATCH);
} }
@Test @Test
public void mergingMediaSourcePeriodCreation() throws Exception { public void createPeriod_createsChildPeriods() throws Exception {
FakeMediaSource[] mediaSources = new FakeMediaSource[2]; FakeMediaSource[] mediaSources = new FakeMediaSource[2];
for (int i = 0; i < mediaSources.length; i++) { for (int i = 0; i < mediaSources.length; i++) {
mediaSources[i] = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 2)); mediaSources[i] = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 2));
...@@ -83,24 +113,26 @@ public class MergingMediaSourceTest { ...@@ -83,24 +113,26 @@ public class MergingMediaSourceTest {
} }
/** /**
* Wraps the specified timelines in a {@link MergingMediaSource}, prepares it and checks that it * Wraps the specified timelines in a {@link MergingMediaSource}, prepares it and returns the
* forwards the first of the wrapped timelines. * merged timeline.
*/ */
private static void testMergingMediaSourcePrepare(Timeline... timelines) throws IOException { private static Timeline prepareMergingMediaSource(boolean clipDurations, Timeline... timelines)
throws IOException {
FakeMediaSource[] mediaSources = new FakeMediaSource[timelines.length]; FakeMediaSource[] mediaSources = new FakeMediaSource[timelines.length];
for (int i = 0; i < timelines.length; i++) { for (int i = 0; i < timelines.length; i++) {
mediaSources[i] = new FakeMediaSource(timelines[i]); mediaSources[i] = new FakeMediaSource(timelines[i]);
} }
MergingMediaSource mergingMediaSource = new MergingMediaSource(mediaSources); MergingMediaSource mergingMediaSource =
MediaSourceTestRunner testRunner = new MediaSourceTestRunner(mergingMediaSource, null); new MergingMediaSource(/* adjustPeriodTimeOffsets= */ false, clipDurations, mediaSources);
MediaSourceTestRunner testRunner =
new MediaSourceTestRunner(mergingMediaSource, /* allocator= */ null);
try { try {
Timeline timeline = testRunner.prepareSource(); Timeline timeline = testRunner.prepareSource();
// The merged timeline should always be the one from the first child.
assertThat(timeline).isEqualTo(timelines[0]);
testRunner.releaseSource(); testRunner.releaseSource();
for (FakeMediaSource mediaSource : mediaSources) { for (FakeMediaSource mediaSource : mediaSources) {
mediaSource.assertReleased(); mediaSource.assertReleased();
} }
return timeline;
} finally { } finally {
testRunner.release(); testRunner.release();
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment