Publish CompositionPlayer
for playing compositions
This class is not ready for production app usage yet, so it is still marked `@RestrictTo(LIBRARY_GROUP)` for now. Apps can experiment with it in a non-prod context by suppressing the associated lint error. * Issue: androidx/media#1014 * Issue: androidx/media#1185 * Issue: androidx/media#816 PiperOrigin-RevId: 633921353
This commit is contained in:
parent
67554395cb
commit
0e5a5e0294
6
demos/composition/README.md
Normal file
6
demos/composition/README.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Composition demo
|
||||||
|
|
||||||
|
This app is an experimental demo app to demonstrate how to use Composition and CompositionPlayer APIs.
|
||||||
|
|
||||||
|
See the [demos README](../README.md) for instructions on how to build and run
|
||||||
|
this demo.
|
63
demos/composition/build.gradle
Normal file
63
demos/composition/build.gradle
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
apply from: '../../constants.gradle'
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'androidx.media3.demo.composition'
|
||||||
|
|
||||||
|
compileSdk project.ext.compileSdkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
versionName project.ext.releaseVersion
|
||||||
|
versionCode project.ext.releaseVersionCode
|
||||||
|
minSdkVersion 21
|
||||||
|
targetSdkVersion project.ext.appTargetSdkVersion
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
shrinkResources true
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt'
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
// This demo app isn't indexed and doesn't have translations.
|
||||||
|
disable 'GoogleAppIndexingWarning','MissingTranslation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:' + androidxMaterialVersion
|
||||||
|
implementation project(modulePrefix + 'lib-effect')
|
||||||
|
implementation project(modulePrefix + 'lib-exoplayer')
|
||||||
|
implementation project(modulePrefix + 'lib-exoplayer-dash')
|
||||||
|
implementation project(modulePrefix + 'lib-transformer')
|
||||||
|
implementation project(modulePrefix + 'lib-ui')
|
||||||
|
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
|
||||||
|
implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
||||||
|
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
|
||||||
|
}
|
1
demos/composition/proguard-rules.txt
Normal file
1
demos/composition/proguard-rules.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Proguard rules specific to the composition demo app.
|
51
demos/composition/src/main/AndroidManifest.xml
Normal file
51
demos/composition/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="androidx.media3.demo.composition">
|
||||||
|
|
||||||
|
<uses-sdk />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
tools:targetApi="29"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/Theme.AppCompat" >
|
||||||
|
|
||||||
|
<activity android:name=".CompositionPreviewActivity"
|
||||||
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.demo.composition;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A {@link RecyclerView.Adapter} that displays assets in a sequence in a {@link RecyclerView}. */
|
||||||
|
public final class AssetItemAdapter extends RecyclerView.Adapter<AssetItemAdapter.ViewHolder> {
|
||||||
|
private static final String TAG = "AssetItemAdapter";
|
||||||
|
|
||||||
|
private final List<String> data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance
|
||||||
|
*
|
||||||
|
* @param data A list of items to populate RecyclerView with.
|
||||||
|
*/
|
||||||
|
public AssetItemAdapter(List<String> data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.preset_item, parent, false);
|
||||||
|
return new ViewHolder(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||||
|
holder.getTextView().setText(data.get(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return data.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A {@link RecyclerView.ViewHolder} used to build {@link AssetItemAdapter}. */
|
||||||
|
public static final class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
private final TextView textView;
|
||||||
|
|
||||||
|
private ViewHolder(View view) {
|
||||||
|
super(view);
|
||||||
|
textView = view.findViewById(R.id.preset_name_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextView getTextView() {
|
||||||
|
return textView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,354 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.demo.composition;
|
||||||
|
|
||||||
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkState;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
|
import static androidx.media3.transformer.ImageUtil.getCommonImageMimeTypeFromExtension;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.DialogInterface;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Toast;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.appcompat.widget.AppCompatButton;
|
||||||
|
import androidx.appcompat.widget.AppCompatTextView;
|
||||||
|
import androidx.media3.common.Effect;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
import androidx.media3.common.PlaybackException;
|
||||||
|
import androidx.media3.common.Player;
|
||||||
|
import androidx.media3.common.audio.SonicAudioProcessor;
|
||||||
|
import androidx.media3.common.util.Clock;
|
||||||
|
import androidx.media3.common.util.Log;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.effect.RgbFilter;
|
||||||
|
import androidx.media3.transformer.Composition;
|
||||||
|
import androidx.media3.transformer.CompositionPlayer;
|
||||||
|
import androidx.media3.transformer.EditedMediaItem;
|
||||||
|
import androidx.media3.transformer.EditedMediaItemSequence;
|
||||||
|
import androidx.media3.transformer.Effects;
|
||||||
|
import androidx.media3.transformer.ExportException;
|
||||||
|
import androidx.media3.transformer.ExportResult;
|
||||||
|
import androidx.media3.transformer.JsonUtil;
|
||||||
|
import androidx.media3.transformer.Transformer;
|
||||||
|
import androidx.media3.ui.PlayerView;
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
import com.google.common.base.Stopwatch;
|
||||||
|
import com.google.common.base.Ticker;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link Activity} that previews compositions, using {@link
|
||||||
|
* androidx.media3.transformer.CompositionPlayer}.
|
||||||
|
*/
|
||||||
|
public final class CompositionPreviewActivity extends AppCompatActivity {
|
||||||
|
private static final String TAG = "CompPreviewActivity";
|
||||||
|
|
||||||
|
private final ArrayList<String> sequenceAssetTitles = new ArrayList<>();
|
||||||
|
|
||||||
|
@Nullable private boolean[] selectedMediaItems = null;
|
||||||
|
private String[] presetFileDescriptions = new String[0];
|
||||||
|
@Nullable private AssetItemAdapter assetItemAdapter;
|
||||||
|
@Nullable private CompositionPlayer compositionPlayer;
|
||||||
|
@Nullable private Transformer transformer;
|
||||||
|
@Nullable private File outputFile;
|
||||||
|
private @MonotonicNonNull PlayerView playerView;
|
||||||
|
private @MonotonicNonNull AppCompatButton exportButton;
|
||||||
|
private @MonotonicNonNull AppCompatTextView exportInformationTextView;
|
||||||
|
private @MonotonicNonNull Stopwatch exportStopwatch;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.composition_preview_activity);
|
||||||
|
playerView = findViewById(R.id.composition_player_view);
|
||||||
|
|
||||||
|
findViewById(R.id.preview_button).setOnClickListener(this::previewComposition);
|
||||||
|
findViewById(R.id.edit_sequence_button).setOnClickListener(this::selectPresetFile);
|
||||||
|
RecyclerView presetList = findViewById(R.id.composition_preset_list);
|
||||||
|
presetList.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
|
||||||
|
LinearLayoutManager layoutManager =
|
||||||
|
new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, /* reverseLayout= */ false);
|
||||||
|
presetList.setLayoutManager(layoutManager);
|
||||||
|
|
||||||
|
exportInformationTextView = findViewById(R.id.export_information_text);
|
||||||
|
exportButton = findViewById(R.id.composition_export_button);
|
||||||
|
exportButton.setOnClickListener(this::exportComposition);
|
||||||
|
|
||||||
|
presetFileDescriptions = getResources().getStringArray(R.array.preset_descriptions);
|
||||||
|
// Select two media items by default.
|
||||||
|
selectedMediaItems = new boolean[presetFileDescriptions.length];
|
||||||
|
selectedMediaItems[0] = true;
|
||||||
|
selectedMediaItems[2] = true;
|
||||||
|
for (int i = 0; i < checkNotNull(selectedMediaItems).length; i++) {
|
||||||
|
if (checkNotNull(selectedMediaItems)[i]) {
|
||||||
|
sequenceAssetTitles.add(presetFileDescriptions[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assetItemAdapter = new AssetItemAdapter(sequenceAssetTitles);
|
||||||
|
presetList.setAdapter(assetItemAdapter);
|
||||||
|
|
||||||
|
exportStopwatch =
|
||||||
|
Stopwatch.createUnstarted(
|
||||||
|
new Ticker() {
|
||||||
|
@Override
|
||||||
|
public long read() {
|
||||||
|
return android.os.SystemClock.elapsedRealtimeNanos();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStart() {
|
||||||
|
super.onStart();
|
||||||
|
checkStateNotNull(playerView).onResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStop() {
|
||||||
|
super.onStop();
|
||||||
|
checkStateNotNull(playerView).onPause();
|
||||||
|
releasePlayer();
|
||||||
|
cancelExport();
|
||||||
|
checkStateNotNull(exportStopwatch).reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Composition prepareComposition() {
|
||||||
|
// Reading from resources here does not create a performance bottleneck, this
|
||||||
|
// method is called as part of more expensive operations.
|
||||||
|
String[] presetFileUris = getResources().getStringArray(/* id= */ R.array.preset_uris);
|
||||||
|
checkState(
|
||||||
|
/* expression= */ checkStateNotNull(presetFileUris).length == presetFileDescriptions.length,
|
||||||
|
/* errorMessage= */ "Unexpected array length "
|
||||||
|
+ getResources().getResourceName(R.array.preset_uris));
|
||||||
|
int[] presetDurationsUs = getResources().getIntArray(/* id= */ R.array.preset_durations);
|
||||||
|
checkState(
|
||||||
|
/* expression= */ checkStateNotNull(presetDurationsUs).length
|
||||||
|
== presetFileDescriptions.length,
|
||||||
|
/* errorMessage= */ "Unexpected array length "
|
||||||
|
+ getResources().getResourceName(R.array.preset_durations));
|
||||||
|
|
||||||
|
List<EditedMediaItem> mediaItems = new ArrayList<>();
|
||||||
|
ImmutableList<Effect> effects =
|
||||||
|
ImmutableList.of(
|
||||||
|
MatrixTransformationFactory.createDizzyCropEffect(), RgbFilter.createGrayscaleFilter());
|
||||||
|
for (int i = 0; i < checkNotNull(selectedMediaItems).length; i++) {
|
||||||
|
if (checkNotNull(selectedMediaItems)[i]) {
|
||||||
|
Uri uri = Uri.parse(presetFileUris[i]);
|
||||||
|
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder().setUri(uri);
|
||||||
|
if (MimeTypes.isImage(getCommonImageMimeTypeFromExtension(uri))) {
|
||||||
|
mediaItemBuilder.setImageDurationMs(Util.usToMs(presetDurationsUs[i]));
|
||||||
|
}
|
||||||
|
MediaItem mediaItem = mediaItemBuilder.build();
|
||||||
|
SonicAudioProcessor pitchChanger = new SonicAudioProcessor();
|
||||||
|
pitchChanger.setPitch(mediaItems.size() % 2 == 0 ? 2f : 0.2f);
|
||||||
|
EditedMediaItem.Builder itemBuilder =
|
||||||
|
new EditedMediaItem.Builder(mediaItem)
|
||||||
|
.setEffects(
|
||||||
|
new Effects(
|
||||||
|
/* audioProcessors= */ ImmutableList.of(pitchChanger),
|
||||||
|
/* videoEffects= */ effects))
|
||||||
|
.setDurationUs(presetDurationsUs[i]);
|
||||||
|
mediaItems.add(itemBuilder.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditedMediaItemSequence videoSequence = new EditedMediaItemSequence(mediaItems);
|
||||||
|
SonicAudioProcessor sampleRateChanger = new SonicAudioProcessor();
|
||||||
|
sampleRateChanger.setOutputSampleRateHz(8_000);
|
||||||
|
return new Composition.Builder(/* sequences= */ ImmutableList.of(videoSequence))
|
||||||
|
.setEffects(
|
||||||
|
new Effects(
|
||||||
|
/* audioProcessors= */ ImmutableList.of(sampleRateChanger),
|
||||||
|
/* videoEffects= */ ImmutableList.of()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void previewComposition(View view) {
|
||||||
|
releasePlayer();
|
||||||
|
Composition composition = prepareComposition();
|
||||||
|
checkStateNotNull(playerView).setPlayer(null);
|
||||||
|
|
||||||
|
CompositionPlayer player = new CompositionPlayer.Builder(getApplicationContext()).build();
|
||||||
|
this.compositionPlayer = player;
|
||||||
|
checkStateNotNull(playerView).setPlayer(compositionPlayer);
|
||||||
|
checkStateNotNull(playerView).setControllerAutoShow(false);
|
||||||
|
player.addListener(
|
||||||
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onPlayerError(PlaybackException error) {
|
||||||
|
Toast.makeText(getApplicationContext(), "Preview error: " + error, Toast.LENGTH_LONG)
|
||||||
|
.show();
|
||||||
|
Log.e(TAG, "Preview error", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectPresetFile(View view) {
|
||||||
|
new AlertDialog.Builder(/* context= */ this)
|
||||||
|
.setTitle(R.string.select_preset_file_title)
|
||||||
|
.setMultiChoiceItems(
|
||||||
|
presetFileDescriptions,
|
||||||
|
checkNotNull(selectedMediaItems),
|
||||||
|
this::selectPresetFileInDialog)
|
||||||
|
.setPositiveButton(android.R.string.ok, /* listener= */ null)
|
||||||
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectPresetFileInDialog(DialogInterface dialog, int which, boolean isChecked) {
|
||||||
|
if (selectedMediaItems == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedMediaItems[which] = isChecked;
|
||||||
|
// The items will be added to a the sequence in the order they were selected.
|
||||||
|
if (isChecked) {
|
||||||
|
sequenceAssetTitles.add(presetFileDescriptions[which]);
|
||||||
|
checkNotNull(assetItemAdapter).notifyItemInserted(sequenceAssetTitles.size() - 1);
|
||||||
|
} else {
|
||||||
|
int index = sequenceAssetTitles.indexOf(presetFileDescriptions[which]);
|
||||||
|
sequenceAssetTitles.remove(presetFileDescriptions[which]);
|
||||||
|
checkNotNull(assetItemAdapter).notifyItemRemoved(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void exportComposition(View view) {
|
||||||
|
// Cancel and clean up files from any ongoing export.
|
||||||
|
cancelExport();
|
||||||
|
|
||||||
|
Composition composition = prepareComposition();
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputFile =
|
||||||
|
createExternalCacheFile(
|
||||||
|
"composition-preview-" + Clock.DEFAULT.elapsedRealtime() + ".mp4");
|
||||||
|
} catch (IOException e) {
|
||||||
|
Toast.makeText(
|
||||||
|
getApplicationContext(),
|
||||||
|
"Aborting export! Unable to create output file: " + e,
|
||||||
|
Toast.LENGTH_LONG)
|
||||||
|
.show();
|
||||||
|
Log.e(TAG, "Aborting export! Unable to create output file: " + e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String filePath = outputFile.getAbsolutePath();
|
||||||
|
|
||||||
|
transformer =
|
||||||
|
new Transformer.Builder(this)
|
||||||
|
.addListener(
|
||||||
|
new Transformer.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onCompleted(Composition composition, ExportResult exportResult) {
|
||||||
|
checkStateNotNull(exportStopwatch).stop();
|
||||||
|
long elapsedTimeMs = exportStopwatch.elapsed(TimeUnit.MILLISECONDS);
|
||||||
|
String details =
|
||||||
|
getString(R.string.export_completed, elapsedTimeMs / 1000.f, filePath);
|
||||||
|
Log.i(TAG, details);
|
||||||
|
checkStateNotNull(exportInformationTextView).setText(details);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSONObject resultJson =
|
||||||
|
JsonUtil.exportResultAsJsonObject(exportResult)
|
||||||
|
.put("elapsedTimeMs", elapsedTimeMs)
|
||||||
|
.put("device", JsonUtil.getDeviceDetailsAsJsonObject());
|
||||||
|
for (String line : Util.split(resultJson.toString(2), "\n")) {
|
||||||
|
Log.i(TAG, line);
|
||||||
|
}
|
||||||
|
} catch (JSONException e) {
|
||||||
|
Log.w(TAG, "Unable to convert exportResult to JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(
|
||||||
|
Composition composition,
|
||||||
|
ExportResult exportResult,
|
||||||
|
ExportException exportException) {
|
||||||
|
checkStateNotNull(exportStopwatch).stop();
|
||||||
|
Toast.makeText(
|
||||||
|
getApplicationContext(),
|
||||||
|
"Export error: " + exportException,
|
||||||
|
Toast.LENGTH_LONG)
|
||||||
|
.show();
|
||||||
|
Log.e(TAG, "Export error", exportException);
|
||||||
|
checkStateNotNull(exportInformationTextView).setText(R.string.export_error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
checkStateNotNull(exportInformationTextView).setText(R.string.export_started);
|
||||||
|
checkStateNotNull(exportStopwatch).reset();
|
||||||
|
exportStopwatch.start();
|
||||||
|
checkStateNotNull(transformer).start(composition, filePath);
|
||||||
|
Log.i(TAG, "Export started");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releasePlayer() {
|
||||||
|
if (compositionPlayer != null) {
|
||||||
|
compositionPlayer.release();
|
||||||
|
compositionPlayer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels any ongoing export operation, and deletes output file contents. */
|
||||||
|
private void cancelExport() {
|
||||||
|
if (transformer != null) {
|
||||||
|
transformer.cancel();
|
||||||
|
transformer = null;
|
||||||
|
}
|
||||||
|
if (outputFile != null) {
|
||||||
|
outputFile.delete();
|
||||||
|
outputFile = null;
|
||||||
|
}
|
||||||
|
checkStateNotNull(exportInformationTextView).setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link File} of the {@code fileName} in the application cache directory.
|
||||||
|
*
|
||||||
|
* <p>If a file of that name already exists, it is overwritten.
|
||||||
|
*/
|
||||||
|
// TODO: b/320636291 - Refactor duplicate createExternalCacheFile functions.
|
||||||
|
private File createExternalCacheFile(String fileName) throws IOException {
|
||||||
|
File file = new File(getExternalCacheDir(), fileName);
|
||||||
|
if (file.exists() && !file.delete()) {
|
||||||
|
throw new IOException("Could not delete file: " + file.getAbsolutePath());
|
||||||
|
}
|
||||||
|
if (!file.createNewFile()) {
|
||||||
|
throw new IOException("Could not create file: " + file.getAbsolutePath());
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.demo.composition;
|
||||||
|
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.effect.GlMatrixTransformation;
|
||||||
|
import androidx.media3.effect.MatrixTransformation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for {@link GlMatrixTransformation GlMatrixTransformations} and {@link
|
||||||
|
* MatrixTransformation MatrixTransformations} that create video effects by applying transformation
|
||||||
|
* matrices to the individual video frames.
|
||||||
|
*/
|
||||||
|
/* package */ final class MatrixTransformationFactory {
|
||||||
|
/**
|
||||||
|
* Returns a {@link MatrixTransformation} that rescales the frames over the first {@link
|
||||||
|
* #ZOOM_DURATION_SECONDS} seconds, such that the rectangle filled with the input frame increases
|
||||||
|
* linearly in size from a single point to filling the full output frame.
|
||||||
|
*/
|
||||||
|
public static MatrixTransformation createZoomInTransition() {
|
||||||
|
return MatrixTransformationFactory::calculateZoomInTransitionMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link MatrixTransformation} that crops frames to a rectangle that moves on an
|
||||||
|
* ellipse.
|
||||||
|
*/
|
||||||
|
public static MatrixTransformation createDizzyCropEffect() {
|
||||||
|
return MatrixTransformationFactory::calculateDizzyCropMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link GlMatrixTransformation} that rotates a frame in 3D around the y-axis and
|
||||||
|
* applies perspective projection to 2D.
|
||||||
|
*/
|
||||||
|
public static GlMatrixTransformation createSpin3dEffect() {
|
||||||
|
return MatrixTransformationFactory::calculate3dSpinMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final float ZOOM_DURATION_SECONDS = 2f;
|
||||||
|
private static final float DIZZY_CROP_ROTATION_PERIOD_US = 5_000_000f;
|
||||||
|
|
||||||
|
private static Matrix calculateZoomInTransitionMatrix(long presentationTimeUs) {
|
||||||
|
Matrix transformationMatrix = new Matrix();
|
||||||
|
float scale = Math.min(1, presentationTimeUs / (C.MICROS_PER_SECOND * ZOOM_DURATION_SECONDS));
|
||||||
|
transformationMatrix.postScale(/* sx= */ scale, /* sy= */ scale);
|
||||||
|
return transformationMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static android.graphics.Matrix calculateDizzyCropMatrix(long presentationTimeUs) {
|
||||||
|
double theta = presentationTimeUs * 2 * Math.PI / DIZZY_CROP_ROTATION_PERIOD_US;
|
||||||
|
float centerX = 0.5f * (float) Math.cos(theta);
|
||||||
|
float centerY = 0.5f * (float) Math.sin(theta);
|
||||||
|
android.graphics.Matrix transformationMatrix = new android.graphics.Matrix();
|
||||||
|
transformationMatrix.postTranslate(/* dx= */ centerX, /* dy= */ centerY);
|
||||||
|
transformationMatrix.postScale(/* sx= */ 2f, /* sy= */ 2f);
|
||||||
|
return transformationMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] calculate3dSpinMatrix(long presentationTimeUs) {
|
||||||
|
float[] transformationMatrix = new float[16];
|
||||||
|
android.opengl.Matrix.frustumM(
|
||||||
|
transformationMatrix,
|
||||||
|
/* offset= */ 0,
|
||||||
|
/* left= */ -1f,
|
||||||
|
/* right= */ 1f,
|
||||||
|
/* bottom= */ -1f,
|
||||||
|
/* top= */ 1f,
|
||||||
|
/* near= */ 3f,
|
||||||
|
/* far= */ 5f);
|
||||||
|
android.opengl.Matrix.translateM(
|
||||||
|
transformationMatrix, /* mOffset= */ 0, /* x= */ 0f, /* y= */ 0f, /* z= */ -4f);
|
||||||
|
float theta = Util.usToMs(presentationTimeUs) / 10f;
|
||||||
|
android.opengl.Matrix.rotateM(
|
||||||
|
transformationMatrix, /* mOffset= */ 0, theta, /* x= */ 0f, /* y= */ 1f, /* z= */ 0f);
|
||||||
|
return transformationMatrix;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
@NonNullApi
|
||||||
|
@OptIn(markerClass = UnstableApi.class)
|
||||||
|
package androidx.media3.demo.composition;
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn;
|
||||||
|
import androidx.media3.common.util.NonNullApi;
|
||||||
|
import androidx.media3.common.util.UnstableApi;
|
@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/composition_preview_card_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" >
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/input_text_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
||||||
|
android:text="@string/preview_single_sequence" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="200dp" >
|
||||||
|
|
||||||
|
<androidx.media3.ui.PlayerView
|
||||||
|
android:id="@+id/composition_player_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/sequence_header_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/single_sequence_items"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/composition_preview_card_view"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/composition_preset_list"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/edit_sequence_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||||
|
android:text="@string/edit"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/composition_preview_card_view"/>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/composition_preset_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/edit_sequence_button"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/export_information_text"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/export_information_text"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/composition_export_button"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/composition_export_button"
|
||||||
|
android:text="@string/export"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/preview_button"/>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
|
android:id="@+id/preview_button"
|
||||||
|
android:text="@string/preview"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
30
demos/composition/src/main/res/layout/preset_item.xml
Normal file
30
demos/composition/src/main/res/layout/preset_item.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/preset_name_text"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_weight="1"/>
|
||||||
|
|
||||||
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
BIN
demos/composition/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
demos/composition/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
BIN
demos/composition/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
demos/composition/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
demos/composition/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
demos/composition/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
BIN
demos/composition/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
demos/composition/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
demos/composition/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
demos/composition/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
31
demos/composition/src/main/res/values-night/themes.xml
Normal file
31
demos/composition/src/main/res/values-night/themes.xml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.Media3internal" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/purple_200</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
<item name="colorOnPrimary">@color/black</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
74
demos/composition/src/main/res/values/arrays.xml
Normal file
74
demos/composition/src/main/res/values/arrays.xml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string-array name="preset_descriptions">
|
||||||
|
<item>720p H264 video and AAC audio</item>
|
||||||
|
<item>1080p H265 video and AAC audio</item>
|
||||||
|
<item>360p H264 video and AAC audio</item>
|
||||||
|
<item>360p VP8 video and Vorbis audio</item>
|
||||||
|
<item>4K H264 video and AAC audio (portrait, no B-frames)</item>
|
||||||
|
<item>8k H265 video and AAC audio</item>
|
||||||
|
<item>Short 1080p H265 video and AAC audio</item>
|
||||||
|
<item>Long 180p H264 video and AAC audio</item>
|
||||||
|
<item>H264 video and AAC audio (portrait, H > W, 0°)</item>
|
||||||
|
<item>H264 video and AAC audio (portrait, H < W, 90°)</item>
|
||||||
|
<item>SEF slow motion with 240 fps</item>
|
||||||
|
<item>480p DASH (non-square pixels)</item>
|
||||||
|
<item>HDR (HDR10) H265 limited range video (encoding may fail)</item>
|
||||||
|
<item>HDR (HLG) H265 limited range video (encoding may fail)</item>
|
||||||
|
<item>720p H264 video with no audio</item>
|
||||||
|
<item>London JPG image (plays for 5 secs at 30 fps)</item>
|
||||||
|
<item>Tokyo JPG image (portrait, plays for 5 secs at 30 fps)</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="preset_uris">
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-10s.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-0/android-block-1080-hevc.mp4</item>
|
||||||
|
<item>https://html5demos.com/assets/dizzy.mp4</item>
|
||||||
|
<item>https://html5demos.com/assets/dizzy.webm</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/portrait_4k60.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/8k24fps_4s.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/1920w_1080h_4s.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/portrait_avc_aac.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/portrait_rotated_avc_aac.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/slow-motion/slowMotion_stopwatch_240fps_long.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/gen/screens/dash-vod-single-segment/manifest-baseline.mpd</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/samsung-s21-hdr-hdr10.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/Pixel7Pro_HLG_1080P.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/mp4/sample_video_track_only.mp4</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/jpg/london.jpg</item>
|
||||||
|
<item>https://storage.googleapis.com/exoplayer-test-media-1/jpg/tokyo.jpg</item>
|
||||||
|
</string-array>
|
||||||
|
<integer-array name="preset_durations">
|
||||||
|
<item>10024000</item>
|
||||||
|
<item>23823000</item>
|
||||||
|
<item>25000000</item>
|
||||||
|
<item>25000000</item>
|
||||||
|
<item>3745000</item>
|
||||||
|
<item>4421000</item>
|
||||||
|
<item>3923000</item>
|
||||||
|
<item>596459000</item>
|
||||||
|
<item>3687000</item>
|
||||||
|
<item>2235000</item>
|
||||||
|
<item>47987000</item>
|
||||||
|
<item>128270000</item>
|
||||||
|
<item>4236000</item>
|
||||||
|
<item>5167000</item>
|
||||||
|
<item>1001000</item>
|
||||||
|
<item>5000000</item>
|
||||||
|
<item>5000000</item>
|
||||||
|
</integer-array>
|
||||||
|
</resources>
|
24
demos/composition/src/main/res/values/colors.xml
Normal file
24
demos/composition/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
27
demos/composition/src/main/res/values/strings.xml
Normal file
27
demos/composition/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Composition Demo</string>
|
||||||
|
<string name="edit">Edit</string>
|
||||||
|
<string name="preview" translatable="false">Preview</string>
|
||||||
|
<string name="preview_single_sequence" translatable="false">Single sequence preview</string>
|
||||||
|
<string name="single_sequence_items" translatable="false">Single sequence items:</string>
|
||||||
|
<string name="select_preset_file_title" translatable="false">Choose preset file</string>
|
||||||
|
<string name="export" translatable="false">Export</string>
|
||||||
|
<string name="export_completed" translatable="false">Export completed in %.3f seconds.\nOutput: %s</string>
|
||||||
|
<string name="export_error" translatable="false">Export error</string>
|
||||||
|
<string name="export_started" translatable="false">Export started</string>
|
||||||
|
</resources>
|
31
demos/composition/src/main/res/values/themes.xml
Normal file
31
demos/composition/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2024 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.Media3internal" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/purple_500</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
<item name="colorOnPrimary">@color/white</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
@ -0,0 +1,529 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.common.PlaybackException.ERROR_CODE_DECODER_INIT_FAILED;
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static com.google.common.util.concurrent.Futures.immediateFuture;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
|
import android.app.Instrumentation;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.view.SurfaceHolder;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import android.view.TextureView;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.ColorInfo;
|
||||||
|
import androidx.media3.common.DebugViewProvider;
|
||||||
|
import androidx.media3.common.Effect;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
import androidx.media3.common.PlaybackException;
|
||||||
|
import androidx.media3.common.PreviewingVideoGraph;
|
||||||
|
import androidx.media3.common.SurfaceInfo;
|
||||||
|
import androidx.media3.common.VideoFrameProcessingException;
|
||||||
|
import androidx.media3.common.VideoFrameProcessor;
|
||||||
|
import androidx.media3.common.VideoGraph;
|
||||||
|
import androidx.media3.common.util.SystemClock;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.datasource.AssetDataSource;
|
||||||
|
import androidx.media3.datasource.DataSourceUtil;
|
||||||
|
import androidx.media3.datasource.DataSpec;
|
||||||
|
import androidx.media3.effect.PreviewingSingleInputVideoGraph;
|
||||||
|
import androidx.media3.exoplayer.RendererCapabilities;
|
||||||
|
import androidx.media3.exoplayer.image.BitmapFactoryImageDecoder;
|
||||||
|
import androidx.media3.exoplayer.image.ImageDecoder;
|
||||||
|
import androidx.media3.exoplayer.image.ImageDecoderException;
|
||||||
|
import androidx.media3.exoplayer.source.ExternalLoader;
|
||||||
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
|
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/** Instrumentation tests for {@link CompositionPlayer} */
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class CompositionPlayerTest {
|
||||||
|
|
||||||
|
private static final long TEST_TIMEOUT_MS = 10_000;
|
||||||
|
private static final String MP4_ASSET = "asset:///media/mp4/sample.mp4";
|
||||||
|
private static final String IMAGE_ASSET = "asset:///media/jpeg/white-1x1.jpg";
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public ActivityScenarioRule<SurfaceTestActivity> rule =
|
||||||
|
new ActivityScenarioRule<>(SurfaceTestActivity.class);
|
||||||
|
|
||||||
|
private final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||||
|
private final Context applicationContext = instrumentation.getContext().getApplicationContext();
|
||||||
|
|
||||||
|
private CompositionPlayer compositionPlayer;
|
||||||
|
private SurfaceView surfaceView;
|
||||||
|
private SurfaceHolder surfaceHolder;
|
||||||
|
private TextureView textureView;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setupSurfaces() {
|
||||||
|
rule.getScenario()
|
||||||
|
.onActivity(
|
||||||
|
activity -> {
|
||||||
|
surfaceView = activity.getSurfaceView();
|
||||||
|
textureView = activity.getTextureView();
|
||||||
|
});
|
||||||
|
surfaceHolder = surfaceView.getHolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void closeActivity() {
|
||||||
|
rule.getScenario().close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void releasePlayer() {
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
if (compositionPlayer != null) {
|
||||||
|
compositionPlayer.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoSurfaceView_beforeSettingComposition_surfaceViewIsPassed() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET))
|
||||||
|
.setDurationUs(1_000_000)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilFirstFrameRendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoSurfaceView_afterSettingComposition_surfaceViewIsPassed() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET))
|
||||||
|
.setDurationUs(1_000_000)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilFirstFrameRendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoSurfaceHolder_beforeSettingComposition_surfaceHolderIsPassed()
|
||||||
|
throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
compositionPlayer.setVideoSurfaceHolder(surfaceHolder);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET))
|
||||||
|
.setDurationUs(1_000_000)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilFirstFrameRendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoSurfaceHolder_afterSettingComposition_surfaceHolderIsPassed()
|
||||||
|
throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET))
|
||||||
|
.setDurationUs(1_000_000)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
compositionPlayer.setVideoSurfaceHolder(surfaceHolder);
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilFirstFrameRendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoTextureView_throws() {
|
||||||
|
AtomicReference<UnsupportedOperationException> exception = new AtomicReference<>();
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
try {
|
||||||
|
compositionPlayer.setVideoTextureView(textureView);
|
||||||
|
} catch (UnsupportedOperationException e) {
|
||||||
|
exception.set(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(exception.get()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void imagePreview_imagePlaysForSetDuration() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(IMAGE_ASSET)
|
||||||
|
.setImageDurationMs(1_000)
|
||||||
|
.build())
|
||||||
|
.setDurationUs(1_000_000)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilFirstFrameRendered();
|
||||||
|
listener.waitUntilPlayerReady();
|
||||||
|
long playbackStartTimeMs = SystemClock.DEFAULT.elapsedRealtime();
|
||||||
|
instrumentation.runOnMainSync(() -> compositionPlayer.play());
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
long playbackRealTimeMs = SystemClock.DEFAULT.elapsedRealtime() - playbackStartTimeMs;
|
||||||
|
|
||||||
|
assertThat(playbackRealTimeMs).isAtLeast(1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void imagePreview_externallyLoadedImage() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
ExternalLoader externalImageLoader =
|
||||||
|
loadRequest -> immediateFuture(Util.getUtf8Bytes(loadRequest.uri.toString()));
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer =
|
||||||
|
new CompositionPlayer.Builder(applicationContext)
|
||||||
|
.setExternalImageLoader(externalImageLoader)
|
||||||
|
.setImageDecoderFactory(new TestImageDecoderFactory())
|
||||||
|
.build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(IMAGE_ASSET)
|
||||||
|
.setMimeType(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE)
|
||||||
|
.setImageDurationMs(1_000)
|
||||||
|
.build())
|
||||||
|
.setDurationUs(1_000_000)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilFirstFrameRendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void imagePreview_twoImages() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
EditedMediaItem image =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder().setUri(IMAGE_ASSET).setImageDurationMs(500).build())
|
||||||
|
.setDurationUs(500_000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(image, image)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void composition_imageThenVideo() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
EditedMediaItem image =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder().setUri(IMAGE_ASSET).setImageDurationMs(500).build())
|
||||||
|
.setDurationUs(500_000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
EditedMediaItem video =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET)).setDurationUs(1_000_000).build();
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(image, video)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void composition_videoThenImage() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
EditedMediaItem video =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET)).setDurationUs(1_000_000).build();
|
||||||
|
EditedMediaItem image =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder().setUri(IMAGE_ASSET).setImageDurationMs(500).build())
|
||||||
|
.setDurationUs(500_000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(video, image)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playback_videoSinkProviderFails_playerRaisesError() {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
EditedMediaItem video =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET)).setDurationUs(1_000_000).build();
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer =
|
||||||
|
new CompositionPlayer.Builder(applicationContext)
|
||||||
|
.setPreviewingVideoGraphFactory(
|
||||||
|
(context,
|
||||||
|
outputColorInfo,
|
||||||
|
debugViewProvider,
|
||||||
|
graphListener,
|
||||||
|
listenerExecutor,
|
||||||
|
compositionEffects,
|
||||||
|
initialTimestampOffsetUs) -> {
|
||||||
|
throw new VideoFrameProcessingException(
|
||||||
|
"Test video graph failed to initialize");
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(video)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
PlaybackException thrownException =
|
||||||
|
assertThrows(PlaybackException.class, listener::waitUntilPlayerEnded);
|
||||||
|
assertThat(thrownException.errorCode).isEqualTo(ERROR_CODE_DECODER_INIT_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void release_videoSinkProviderFailsDuringRelease_playerDoesNotRaiseError()
|
||||||
|
throws Exception {
|
||||||
|
PlayerTestListener playerTestListener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
EditedMediaItem video =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET)).setDurationUs(1_000_000).build();
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer =
|
||||||
|
new CompositionPlayer.Builder(applicationContext)
|
||||||
|
.setPreviewingVideoGraphFactory(FailingReleaseVideoGraph::new)
|
||||||
|
.build();
|
||||||
|
compositionPlayer.addListener(playerTestListener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(video)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
// Wait until the player is ended to make sure the VideoGraph has been created.
|
||||||
|
playerTestListener.waitUntilPlayerEnded();
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(compositionPlayer::release);
|
||||||
|
|
||||||
|
playerTestListener.waitUntilPlayerIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestImageDecoderFactory implements ImageDecoder.Factory {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @RendererCapabilities.Capabilities int supportsFormat(Format format) {
|
||||||
|
return format.sampleMimeType != null
|
||||||
|
&& format.sampleMimeType.equals(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE)
|
||||||
|
? RendererCapabilities.create(C.FORMAT_HANDLED)
|
||||||
|
: RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageDecoder createImageDecoder() {
|
||||||
|
return new BitmapFactoryImageDecoder.Factory(
|
||||||
|
/* bitmapDecoder= */ (data, length) -> {
|
||||||
|
try {
|
||||||
|
// The test serializes the image URI string to a byte array.
|
||||||
|
String assetPath = new String(data);
|
||||||
|
AssetDataSource assetDataSource =
|
||||||
|
new AssetDataSource(ApplicationProvider.getApplicationContext());
|
||||||
|
assetDataSource.open(new DataSpec.Builder().setUri(assetPath).build());
|
||||||
|
byte[] imageData = DataSourceUtil.readToEnd(assetDataSource);
|
||||||
|
return BitmapFactory.decodeByteArray(imageData, 0, imageData.length);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ImageDecoderException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.createImageDecoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FailingReleaseVideoGraph extends ForwardingVideoGraph {
|
||||||
|
public FailingReleaseVideoGraph(
|
||||||
|
Context context,
|
||||||
|
ColorInfo outputColorInfo,
|
||||||
|
DebugViewProvider debugViewProvider,
|
||||||
|
VideoGraph.Listener listener,
|
||||||
|
Executor listenerExecutor,
|
||||||
|
List<Effect> compositionEffects,
|
||||||
|
long initialTimestampOffsetUs) {
|
||||||
|
super(
|
||||||
|
new PreviewingSingleInputVideoGraph.Factory()
|
||||||
|
.create(
|
||||||
|
context,
|
||||||
|
outputColorInfo,
|
||||||
|
debugViewProvider,
|
||||||
|
listener,
|
||||||
|
listenerExecutor,
|
||||||
|
compositionEffects,
|
||||||
|
initialTimestampOffsetUs));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
throw new RuntimeException("VideoGraph release error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ForwardingVideoGraph implements PreviewingVideoGraph {
|
||||||
|
|
||||||
|
private final PreviewingVideoGraph videoGraph;
|
||||||
|
|
||||||
|
public ForwardingVideoGraph(PreviewingVideoGraph videoGraph) {
|
||||||
|
this.videoGraph = videoGraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() throws VideoFrameProcessingException {
|
||||||
|
videoGraph.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerInput(int inputIndex) throws VideoFrameProcessingException {
|
||||||
|
videoGraph.registerInput(inputIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VideoFrameProcessor getProcessor(int inputId) {
|
||||||
|
return videoGraph.getProcessor(inputId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setOutputSurfaceInfo(@Nullable SurfaceInfo outputSurfaceInfo) {
|
||||||
|
videoGraph.setOutputSurfaceInfo(outputSurfaceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasProducedFrameWithTimestampZero() {
|
||||||
|
return videoGraph.hasProducedFrameWithTimestampZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
videoGraph.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderOutputFrame(long renderTimeNs) {
|
||||||
|
videoGraph.renderOutputFrame(renderTimeNs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.media3.transformer.mh.performance;
|
||||||
|
|
||||||
|
import static androidx.media3.common.MimeTypes.VIDEO_H264;
|
||||||
|
import static androidx.media3.test.utils.BitmapPixelTestUtil.MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE;
|
||||||
|
import static androidx.media3.test.utils.BitmapPixelTestUtil.createArgb8888BitmapFromRgba8888Image;
|
||||||
|
import static androidx.media3.test.utils.BitmapPixelTestUtil.getBitmapAveragePixelAbsoluteDifferenceArgb8888;
|
||||||
|
import static androidx.media3.test.utils.BitmapPixelTestUtil.readBitmap;
|
||||||
|
import static androidx.media3.transformer.mh.performance.PlaybackTestUtil.createTimestampOverlay;
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static com.google.common.truth.Truth.assertWithMessage;
|
||||||
|
|
||||||
|
import android.app.Instrumentation;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.PixelFormat;
|
||||||
|
import android.media.Image;
|
||||||
|
import android.media.ImageReader;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.util.ConditionVariable;
|
||||||
|
import androidx.media3.common.util.Size;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.transformer.Composition;
|
||||||
|
import androidx.media3.transformer.CompositionPlayer;
|
||||||
|
import androidx.media3.transformer.EditedMediaItem;
|
||||||
|
import androidx.media3.transformer.EditedMediaItemSequence;
|
||||||
|
import androidx.media3.transformer.Effects;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TestName;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/** Playback tests for {@link CompositionPlayer} */
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class CompositionPlaybackTest {
|
||||||
|
|
||||||
|
private static final String TEST_DIRECTORY = "test-generated-goldens/ExoPlayerPlaybackTest";
|
||||||
|
private static final String MP4_ASSET_URI_STRING = "asset:///media/mp4/sample.mp4";
|
||||||
|
private static final Format MP4_ASSET_FORMAT =
|
||||||
|
new Format.Builder()
|
||||||
|
.setSampleMimeType(VIDEO_H264)
|
||||||
|
.setWidth(1080)
|
||||||
|
.setHeight(720)
|
||||||
|
.setFrameRate(29.97f)
|
||||||
|
.setCodecs("avc1.64001F")
|
||||||
|
.build();
|
||||||
|
private static final Size MP4_ASSET_VIDEO_SIZE =
|
||||||
|
new Size(MP4_ASSET_FORMAT.width, MP4_ASSET_FORMAT.height);
|
||||||
|
private static final long TEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
@Rule public final TestName testName = new TestName();
|
||||||
|
|
||||||
|
private final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||||
|
private @MonotonicNonNull CompositionPlayer player;
|
||||||
|
private @MonotonicNonNull ImageReader outputImageReader;
|
||||||
|
private String testId;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUpTestId() {
|
||||||
|
testId = testName.getMethodName();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void tearDown() {
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
if (player != null) {
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
if (outputImageReader != null) {
|
||||||
|
outputImageReader.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void compositionPlayerPreviewTest_ensuresFirstFrameRenderedCorrectly() throws Exception {
|
||||||
|
AtomicReference<Bitmap> renderedFirstFrameBitmap = new AtomicReference<>();
|
||||||
|
ConditionVariable hasRenderedFirstFrameCondition = new ConditionVariable();
|
||||||
|
outputImageReader =
|
||||||
|
ImageReader.newInstance(
|
||||||
|
MP4_ASSET_VIDEO_SIZE.getWidth(),
|
||||||
|
MP4_ASSET_VIDEO_SIZE.getHeight(),
|
||||||
|
PixelFormat.RGBA_8888,
|
||||||
|
/* maxImages= */ 1);
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
player = new CompositionPlayer.Builder(instrumentation.getContext()).build();
|
||||||
|
outputImageReader.setOnImageAvailableListener(
|
||||||
|
imageReader -> {
|
||||||
|
try (Image image = imageReader.acquireLatestImage()) {
|
||||||
|
renderedFirstFrameBitmap.set(createArgb8888BitmapFromRgba8888Image(image));
|
||||||
|
}
|
||||||
|
hasRenderedFirstFrameCondition.open();
|
||||||
|
},
|
||||||
|
Util.createHandlerForCurrentOrMainLooper());
|
||||||
|
|
||||||
|
player.setVideoSurface(outputImageReader.getSurface(), MP4_ASSET_VIDEO_SIZE);
|
||||||
|
player.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET_URI_STRING))
|
||||||
|
.setEffects(
|
||||||
|
new Effects(
|
||||||
|
/* audioProcessors= */ ImmutableList.of(),
|
||||||
|
/* videoEffects= */ ImmutableList.of(
|
||||||
|
createTimestampOverlay())))
|
||||||
|
.setDurationUs(1_024_000L)
|
||||||
|
.build()))
|
||||||
|
.build());
|
||||||
|
player.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasRenderedFirstFrameCondition.block(TEST_TIMEOUT_MS)) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
Util.formatInvariant("First frame not rendered in %d ms.", TEST_TIMEOUT_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertWithMessage("First frame is not rendered.")
|
||||||
|
.that(renderedFirstFrameBitmap.get())
|
||||||
|
.isNotNull();
|
||||||
|
float averagePixelAbsoluteDifference =
|
||||||
|
getBitmapAveragePixelAbsoluteDifferenceArgb8888(
|
||||||
|
/* expected= */ readBitmap(TEST_DIRECTORY + "/first_frame.png"),
|
||||||
|
/* actual= */ renderedFirstFrameBitmap.get(),
|
||||||
|
testId);
|
||||||
|
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
|
||||||
|
// TODO: b/315800590 - Verify onFirstFrameRendered is invoked only once.
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,380 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.media3.transformer.mh.performance;
|
||||||
|
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
|
||||||
|
import android.app.Instrumentation;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import androidx.media3.common.Effect;
|
||||||
|
import androidx.media3.common.GlObjectsProvider;
|
||||||
|
import androidx.media3.common.GlTextureInfo;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.effect.GlEffect;
|
||||||
|
import androidx.media3.effect.PassthroughShaderProgram;
|
||||||
|
import androidx.media3.transformer.Composition;
|
||||||
|
import androidx.media3.transformer.CompositionPlayer;
|
||||||
|
import androidx.media3.transformer.EditedMediaItem;
|
||||||
|
import androidx.media3.transformer.EditedMediaItemSequence;
|
||||||
|
import androidx.media3.transformer.Effects;
|
||||||
|
import androidx.media3.transformer.PlayerTestListener;
|
||||||
|
import androidx.media3.transformer.SurfaceTestActivity;
|
||||||
|
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumentation tests for {@link CompositionPlayer} {@linkplain CompositionPlayer#seekTo
|
||||||
|
* seeking}.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class CompositionPlayerSeekTest {
|
||||||
|
|
||||||
|
private static final long TEST_TIMEOUT_MS = 10_000;
|
||||||
|
private static final String MP4_ASSET = "asset:///media/mp4/sample.mp4";
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public ActivityScenarioRule<SurfaceTestActivity> rule =
|
||||||
|
new ActivityScenarioRule<>(SurfaceTestActivity.class);
|
||||||
|
|
||||||
|
private final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||||
|
private final Context applicationContext = instrumentation.getContext().getApplicationContext();
|
||||||
|
|
||||||
|
private CompositionPlayer compositionPlayer;
|
||||||
|
private SurfaceView surfaceView;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setupSurfaces() {
|
||||||
|
rule.getScenario().onActivity(activity -> surfaceView = activity.getSurfaceView());
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void closeActivity() {
|
||||||
|
rule.getScenario().close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void releasePlayer() {
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
if (compositionPlayer != null) {
|
||||||
|
compositionPlayer.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: b/320244483 - Add tests that seek into the middle of the sequence.
|
||||||
|
@Test
|
||||||
|
public void seekToZero_singleSequenceOfTwoVideos() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS * 1000);
|
||||||
|
InputTimestampRecordingShaderProgram inputTimestampRecordingShaderProgram =
|
||||||
|
new InputTimestampRecordingShaderProgram();
|
||||||
|
EditedMediaItem video =
|
||||||
|
createEditedMediaItem(
|
||||||
|
/* videoEffects= */ ImmutableList.of(
|
||||||
|
(GlEffect) (context, useHdr) -> inputTimestampRecordingShaderProgram));
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(video, video)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
listener.resetStatus();
|
||||||
|
instrumentation.runOnMainSync(() -> compositionPlayer.seekTo(0));
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
|
||||||
|
ImmutableList<Long> timestampsUsOfOneSequence =
|
||||||
|
ImmutableList.of(
|
||||||
|
1000000000000L,
|
||||||
|
1000000033366L,
|
||||||
|
1000000066733L,
|
||||||
|
1000000100100L,
|
||||||
|
1000000133466L,
|
||||||
|
1000000166833L,
|
||||||
|
1000000200200L,
|
||||||
|
1000000233566L,
|
||||||
|
1000000266933L,
|
||||||
|
1000000300300L,
|
||||||
|
1000000333666L,
|
||||||
|
1000000367033L,
|
||||||
|
1000000400400L,
|
||||||
|
1000000433766L,
|
||||||
|
1000000467133L,
|
||||||
|
1000000500500L,
|
||||||
|
1000000533866L,
|
||||||
|
1000000567233L,
|
||||||
|
1000000600600L,
|
||||||
|
1000000633966L,
|
||||||
|
1000000667333L,
|
||||||
|
1000000700700L,
|
||||||
|
1000000734066L,
|
||||||
|
1000000767433L,
|
||||||
|
1000000800800L,
|
||||||
|
1000000834166L,
|
||||||
|
1000000867533L,
|
||||||
|
1000000900900L,
|
||||||
|
1000000934266L,
|
||||||
|
1000000967633L,
|
||||||
|
// Second video starts here.
|
||||||
|
1000001024000L,
|
||||||
|
1000001057366L,
|
||||||
|
1000001090733L,
|
||||||
|
1000001124100L,
|
||||||
|
1000001157466L,
|
||||||
|
1000001190833L,
|
||||||
|
1000001224200L,
|
||||||
|
1000001257566L,
|
||||||
|
1000001290933L,
|
||||||
|
1000001324300L,
|
||||||
|
1000001357666L,
|
||||||
|
1000001391033L,
|
||||||
|
1000001424400L,
|
||||||
|
1000001457766L,
|
||||||
|
1000001491133L,
|
||||||
|
1000001524500L,
|
||||||
|
1000001557866L,
|
||||||
|
1000001591233L,
|
||||||
|
1000001624600L,
|
||||||
|
1000001657966L,
|
||||||
|
1000001691333L,
|
||||||
|
1000001724700L,
|
||||||
|
1000001758066L,
|
||||||
|
1000001791433L,
|
||||||
|
1000001824800L,
|
||||||
|
1000001858166L,
|
||||||
|
1000001891533L,
|
||||||
|
1000001924900L,
|
||||||
|
1000001958266L,
|
||||||
|
1000001991633L);
|
||||||
|
|
||||||
|
assertThat(inputTimestampRecordingShaderProgram.timestampsUs)
|
||||||
|
// Seeked after the first playback ends, so the timestamps are repeated twice.
|
||||||
|
.containsExactlyElementsIn(
|
||||||
|
new ImmutableList.Builder<Long>()
|
||||||
|
.addAll(timestampsUsOfOneSequence)
|
||||||
|
.addAll(timestampsUsOfOneSequence)
|
||||||
|
.build())
|
||||||
|
.inOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void seekToZero_after15framesInSingleSequenceOfTwoVideos() throws Exception {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS * 1000);
|
||||||
|
ResettableCountDownLatch framesReceivedLatch = new ResettableCountDownLatch(15);
|
||||||
|
AtomicBoolean shaderProgramShouldBlockInput = new AtomicBoolean();
|
||||||
|
|
||||||
|
InputTimestampRecordingShaderProgram inputTimestampRecordingShaderProgram =
|
||||||
|
new InputTimestampRecordingShaderProgram() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void queueInputFrame(
|
||||||
|
GlObjectsProvider glObjectsProvider,
|
||||||
|
GlTextureInfo inputTexture,
|
||||||
|
long presentationTimeUs) {
|
||||||
|
super.queueInputFrame(glObjectsProvider, inputTexture, presentationTimeUs);
|
||||||
|
framesReceivedLatch.countDown();
|
||||||
|
if (framesReceivedLatch.getCount() == 0) {
|
||||||
|
shaderProgramShouldBlockInput.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseOutputFrame(GlTextureInfo outputTexture) {
|
||||||
|
// The input listener capacity is reported in the super method, block input by skip
|
||||||
|
// reporting input capacity.
|
||||||
|
if (shaderProgramShouldBlockInput.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.releaseOutputFrame(outputTexture);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void flush() {
|
||||||
|
super.flush();
|
||||||
|
shaderProgramShouldBlockInput.set(false);
|
||||||
|
framesReceivedLatch.reset(Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
EditedMediaItem video =
|
||||||
|
createEditedMediaItem(
|
||||||
|
/* videoEffects= */ ImmutableList.of(
|
||||||
|
(GlEffect) (context, useHdr) -> inputTimestampRecordingShaderProgram));
|
||||||
|
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
compositionPlayer = new CompositionPlayer.Builder(applicationContext).build();
|
||||||
|
// Set a surface on the player even though there is no UI on this test. We need a surface
|
||||||
|
// otherwise the player will skip/drop video frames.
|
||||||
|
compositionPlayer.setVideoSurfaceView(surfaceView);
|
||||||
|
compositionPlayer.addListener(listener);
|
||||||
|
compositionPlayer.setComposition(
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(video, video)).build());
|
||||||
|
compositionPlayer.prepare();
|
||||||
|
compositionPlayer.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait until the number of frames are received, block further input on the shader program.
|
||||||
|
framesReceivedLatch.await();
|
||||||
|
instrumentation.runOnMainSync(() -> compositionPlayer.seekTo(0));
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
|
||||||
|
ImmutableList<Long> expectedTimestampsUs =
|
||||||
|
ImmutableList.of(
|
||||||
|
1000000000000L,
|
||||||
|
1000000033366L,
|
||||||
|
1000000066733L,
|
||||||
|
1000000100100L,
|
||||||
|
1000000133466L,
|
||||||
|
1000000166833L,
|
||||||
|
1000000200200L,
|
||||||
|
1000000233566L,
|
||||||
|
1000000266933L,
|
||||||
|
1000000300300L,
|
||||||
|
1000000333666L,
|
||||||
|
1000000367033L,
|
||||||
|
1000000400400L,
|
||||||
|
1000000433766L,
|
||||||
|
1000000467133L,
|
||||||
|
// 15 frames, seek
|
||||||
|
1000000000000L,
|
||||||
|
1000000033366L,
|
||||||
|
1000000066733L,
|
||||||
|
1000000100100L,
|
||||||
|
1000000133466L,
|
||||||
|
1000000166833L,
|
||||||
|
1000000200200L,
|
||||||
|
1000000233566L,
|
||||||
|
1000000266933L,
|
||||||
|
1000000300300L,
|
||||||
|
1000000333666L,
|
||||||
|
1000000367033L,
|
||||||
|
1000000400400L,
|
||||||
|
1000000433766L,
|
||||||
|
1000000467133L,
|
||||||
|
1000000500500L,
|
||||||
|
1000000533866L,
|
||||||
|
1000000567233L,
|
||||||
|
1000000600600L,
|
||||||
|
1000000633966L,
|
||||||
|
1000000667333L,
|
||||||
|
1000000700700L,
|
||||||
|
1000000734066L,
|
||||||
|
1000000767433L,
|
||||||
|
1000000800800L,
|
||||||
|
1000000834166L,
|
||||||
|
1000000867533L,
|
||||||
|
1000000900900L,
|
||||||
|
1000000934266L,
|
||||||
|
1000000967633L,
|
||||||
|
// Second video starts here.
|
||||||
|
1000001024000L,
|
||||||
|
1000001057366L,
|
||||||
|
1000001090733L,
|
||||||
|
1000001124100L,
|
||||||
|
1000001157466L,
|
||||||
|
1000001190833L,
|
||||||
|
1000001224200L,
|
||||||
|
1000001257566L,
|
||||||
|
1000001290933L,
|
||||||
|
1000001324300L,
|
||||||
|
1000001357666L,
|
||||||
|
1000001391033L,
|
||||||
|
1000001424400L,
|
||||||
|
1000001457766L,
|
||||||
|
1000001491133L,
|
||||||
|
1000001524500L,
|
||||||
|
1000001557866L,
|
||||||
|
1000001591233L,
|
||||||
|
1000001624600L,
|
||||||
|
1000001657966L,
|
||||||
|
1000001691333L,
|
||||||
|
1000001724700L,
|
||||||
|
1000001758066L,
|
||||||
|
1000001791433L,
|
||||||
|
1000001824800L,
|
||||||
|
1000001858166L,
|
||||||
|
1000001891533L,
|
||||||
|
1000001924900L,
|
||||||
|
1000001958266L,
|
||||||
|
1000001991633L);
|
||||||
|
|
||||||
|
assertThat(inputTimestampRecordingShaderProgram.timestampsUs)
|
||||||
|
.containsExactlyElementsIn(expectedTimestampsUs)
|
||||||
|
.inOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EditedMediaItem createEditedMediaItem(List<Effect> videoEffects) {
|
||||||
|
return new EditedMediaItem.Builder(MediaItem.fromUri(MP4_ASSET))
|
||||||
|
.setDurationUs(1_024_000)
|
||||||
|
.setEffects(new Effects(/* audioProcessors= */ ImmutableList.of(), videoEffects))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class InputTimestampRecordingShaderProgram extends PassthroughShaderProgram {
|
||||||
|
public final ArrayList<Long> timestampsUs = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void queueInputFrame(
|
||||||
|
GlObjectsProvider glObjectsProvider, GlTextureInfo inputTexture, long presentationTimeUs) {
|
||||||
|
super.queueInputFrame(glObjectsProvider, inputTexture, presentationTimeUs);
|
||||||
|
timestampsUs.add(presentationTimeUs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ResettableCountDownLatch {
|
||||||
|
private CountDownLatch latch;
|
||||||
|
|
||||||
|
public ResettableCountDownLatch(int count) {
|
||||||
|
latch = new CountDownLatch(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void await() throws InterruptedException {
|
||||||
|
latch.await();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void countDown() {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCount() {
|
||||||
|
return latch.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset(int count) {
|
||||||
|
latch = new CountDownLatch(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.media3.transformer.mh.performance;
|
||||||
|
|
||||||
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
|
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING;
|
||||||
|
import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
|
||||||
|
import android.app.Instrumentation;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import androidx.media3.common.Effect;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.PlaybackException;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.effect.Contrast;
|
||||||
|
import androidx.media3.transformer.Composition;
|
||||||
|
import androidx.media3.transformer.CompositionPlayer;
|
||||||
|
import androidx.media3.transformer.EditedMediaItem;
|
||||||
|
import androidx.media3.transformer.EditedMediaItemSequence;
|
||||||
|
import androidx.media3.transformer.Effects;
|
||||||
|
import androidx.media3.transformer.PlayerTestListener;
|
||||||
|
import androidx.media3.transformer.SurfaceTestActivity;
|
||||||
|
import androidx.test.ext.junit.rules.ActivityScenarioRule;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.Range;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/** Performance tests for the composition previewing pipeline in {@link CompositionPlayer}. */
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class VideoCompositionPreviewPerformanceTest {
|
||||||
|
|
||||||
|
private static final long TEST_TIMEOUT_MS = 10_000;
|
||||||
|
private static final long MEDIA_ITEM_CLIP_DURATION_MS = 500;
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public ActivityScenarioRule<SurfaceTestActivity> rule =
|
||||||
|
new ActivityScenarioRule<>(SurfaceTestActivity.class);
|
||||||
|
|
||||||
|
private final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||||
|
private SurfaceView surfaceView;
|
||||||
|
private @MonotonicNonNull CompositionPlayer player;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUpSurface() {
|
||||||
|
rule.getScenario().onActivity(activity -> surfaceView = activity.getSurfaceView());
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void tearDown() {
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
if (player != null) {
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rule.getScenario().close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test guards against performance regressions in the effects preview pipeline that format
|
||||||
|
* switches do not cause the player to stall.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compositionPlayerCompositionPreviewTest() throws PlaybackException, TimeoutException {
|
||||||
|
PlayerTestListener listener = new PlayerTestListener(TEST_TIMEOUT_MS);
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
player = new CompositionPlayer.Builder(getApplicationContext()).build();
|
||||||
|
player.setVideoSurfaceView(surfaceView);
|
||||||
|
player.setPlayWhenReady(false);
|
||||||
|
player.addListener(listener);
|
||||||
|
player.setComposition(
|
||||||
|
new Composition.Builder(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
getClippedEditedMediaItem(MP4_ASSET_URI_STRING, new Contrast(.2f)),
|
||||||
|
getClippedEditedMediaItem(MP4_ASSET_URI_STRING, new Contrast(-.2f))))
|
||||||
|
.build());
|
||||||
|
player.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilPlayerReady();
|
||||||
|
|
||||||
|
AtomicLong playbackStartTimeMs = new AtomicLong();
|
||||||
|
instrumentation.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
playbackStartTimeMs.set(SystemClock.elapsedRealtime());
|
||||||
|
checkNotNull(player).play();
|
||||||
|
});
|
||||||
|
|
||||||
|
listener.waitUntilPlayerEnded();
|
||||||
|
long compositionDurationMs = MEDIA_ITEM_CLIP_DURATION_MS * 2;
|
||||||
|
long playbackDurationMs = SystemClock.elapsedRealtime() - playbackStartTimeMs.get();
|
||||||
|
|
||||||
|
assertThat(playbackDurationMs)
|
||||||
|
.isIn(Range.closed(compositionDurationMs, compositionDurationMs + 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EditedMediaItem getClippedEditedMediaItem(String uri, Effect effect) {
|
||||||
|
MediaItem mediaItem =
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(uri)
|
||||||
|
.setClippingConfiguration(
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setEndPositionMs(MEDIA_ITEM_CLIP_DURATION_MS)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new EditedMediaItem.Builder(mediaItem)
|
||||||
|
.setEffects(
|
||||||
|
new Effects(
|
||||||
|
/* audioProcessors= */ ImmutableList.of(),
|
||||||
|
/* videoEffects= */ ImmutableList.of(effect)))
|
||||||
|
.setDurationUs(Util.msToUs(MEDIA_ITEM_CLIP_DURATION_MS))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,298 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.common.audio.AudioProcessor.EMPTY_BUFFER;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkArgument;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkState;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.media3.common.AudioAttributes;
|
||||||
|
import androidx.media3.common.AuxEffectInfo;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
import androidx.media3.common.PlaybackParameters;
|
||||||
|
import androidx.media3.decoder.DecoderInputBuffer;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link AudioSink} implementation that feeds an {@link AudioGraphInput}.
|
||||||
|
*
|
||||||
|
* <p>Should be used by {@link PreviewAudioPipeline}.
|
||||||
|
*/
|
||||||
|
/* package */ final class AudioGraphInputAudioSink implements AudioSink {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for {@link AudioGraphInputAudioSink}.
|
||||||
|
*
|
||||||
|
* <p>All methods will be called on the playback thread of the ExoPlayer instance writing to this
|
||||||
|
* sink.
|
||||||
|
*/
|
||||||
|
public interface Controller {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the {@link AudioGraphInput} instance associated with this {@linkplain
|
||||||
|
* AudioGraphInputAudioSink sink}.
|
||||||
|
*
|
||||||
|
* <p>Data {@linkplain #handleBuffer written} to the sink will be {@linkplain
|
||||||
|
* AudioGraphInput#queueInputBuffer() queued} to the {@link AudioGraphInput}.
|
||||||
|
*
|
||||||
|
* @param editedMediaItem The first {@link EditedMediaItem} queued to the {@link
|
||||||
|
* AudioGraphInput}.
|
||||||
|
* @param format The {@link Format} used to {@linkplain AudioGraphInputAudioSink#configure
|
||||||
|
* configure} the {@linkplain AudioGraphInputAudioSink sink}.
|
||||||
|
* @return The {@link AudioGraphInput}.
|
||||||
|
* @throws ExportException If there is a problem initializing the {@linkplain AudioGraphInput
|
||||||
|
* input}.
|
||||||
|
*/
|
||||||
|
AudioGraphInput getAudioGraphInput(EditedMediaItem editedMediaItem, Format format)
|
||||||
|
throws ExportException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the position (in microseconds) that should be {@linkplain
|
||||||
|
* AudioSink#getCurrentPositionUs returned} by this sink.
|
||||||
|
*/
|
||||||
|
long getCurrentPositionUs();
|
||||||
|
|
||||||
|
/** Returns whether the controller is ended. */
|
||||||
|
boolean isEnded();
|
||||||
|
|
||||||
|
/** See {@link #play()}. */
|
||||||
|
default void onPlay() {}
|
||||||
|
|
||||||
|
/** See {@link #pause()}. */
|
||||||
|
default void onPause() {}
|
||||||
|
|
||||||
|
/** See {@link #reset()}. */
|
||||||
|
default void onReset() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Controller controller;
|
||||||
|
|
||||||
|
@Nullable private AudioGraphInput outputGraphInput;
|
||||||
|
@Nullable private Format currentInputFormat;
|
||||||
|
private boolean inputStreamEnded;
|
||||||
|
private boolean signalledEndOfStream;
|
||||||
|
@Nullable private EditedMediaItemInfo currentEditedMediaItemInfo;
|
||||||
|
private long offsetToCompositionTimeUs;
|
||||||
|
|
||||||
|
public AudioGraphInputAudioSink(Controller controller) {
|
||||||
|
this.controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informs the audio sink there is a change on the {@link EditedMediaItem} currently rendered by
|
||||||
|
* the renderer.
|
||||||
|
*
|
||||||
|
* @param editedMediaItem The {@link EditedMediaItem}.
|
||||||
|
* @param offsetToCompositionTimeUs The offset to add to the audio buffer timestamps to convert
|
||||||
|
* them to the composition time, in microseconds.
|
||||||
|
* @param isLastInSequence Whether this is the last item in the sequence.
|
||||||
|
*/
|
||||||
|
public void onMediaItemChanged(
|
||||||
|
EditedMediaItem editedMediaItem, long offsetToCompositionTimeUs, boolean isLastInSequence) {
|
||||||
|
currentEditedMediaItemInfo = new EditedMediaItemInfo(editedMediaItem, isLastInSequence);
|
||||||
|
this.offsetToCompositionTimeUs = offsetToCompositionTimeUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioSink methods
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels)
|
||||||
|
throws ConfigurationException {
|
||||||
|
checkArgument(supportsFormat(inputFormat));
|
||||||
|
EditedMediaItem editedMediaItem = checkStateNotNull(currentEditedMediaItemInfo).editedMediaItem;
|
||||||
|
// TODO(b/303029969): Evaluate throwing vs ignoring for null outputChannels.
|
||||||
|
checkArgument(outputChannels == null);
|
||||||
|
this.currentInputFormat = inputFormat;
|
||||||
|
if (outputGraphInput == null) {
|
||||||
|
try {
|
||||||
|
outputGraphInput = controller.getAudioGraphInput(editedMediaItem, currentInputFormat);
|
||||||
|
} catch (ExportException e) {
|
||||||
|
throw new ConfigurationException(e, currentInputFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputGraphInput.onMediaItemChanged(
|
||||||
|
editedMediaItem, editedMediaItem.durationUs, currentInputFormat, /* isLast= */ false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnded() {
|
||||||
|
// If we are playing the last media item in the sequence, we must also check that the controller
|
||||||
|
// is ended.
|
||||||
|
return inputStreamEnded
|
||||||
|
&& (!checkStateNotNull(currentEditedMediaItemInfo).isLastInSequence
|
||||||
|
|| controller.isEnded());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean handleBuffer(
|
||||||
|
ByteBuffer buffer, long presentationTimeUs, int encodedAccessUnitCount) {
|
||||||
|
checkState(!inputStreamEnded);
|
||||||
|
return handleBufferInternal(buffer, presentationTimeUs, /* flags= */ 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void playToEndOfStream() {
|
||||||
|
inputStreamEnded = true;
|
||||||
|
// Queue end-of-stream only if playing the last media item in the sequence.
|
||||||
|
if (!signalledEndOfStream && checkStateNotNull(currentEditedMediaItemInfo).isLastInSequence) {
|
||||||
|
signalledEndOfStream =
|
||||||
|
handleBufferInternal(
|
||||||
|
EMPTY_BUFFER, C.TIME_END_OF_SOURCE, /* flags= */ C.BUFFER_FLAG_END_OF_STREAM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @SinkFormatSupport int getFormatSupport(Format format) {
|
||||||
|
if (Objects.equals(format.sampleMimeType, MimeTypes.AUDIO_RAW)
|
||||||
|
&& format.pcmEncoding == C.ENCODING_PCM_16BIT) {
|
||||||
|
return SINK_FORMAT_SUPPORTED_DIRECTLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SINK_FORMAT_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsFormat(Format format) {
|
||||||
|
return getFormatSupport(format) == SINK_FORMAT_SUPPORTED_DIRECTLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasPendingData() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getCurrentPositionUs(boolean sourceEnded) {
|
||||||
|
long currentPositionUs = controller.getCurrentPositionUs();
|
||||||
|
if (currentPositionUs != CURRENT_POSITION_NOT_SET) {
|
||||||
|
// Reset the position to the one expected by the player.
|
||||||
|
currentPositionUs -= offsetToCompositionTimeUs;
|
||||||
|
}
|
||||||
|
return currentPositionUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void play() {
|
||||||
|
controller.onPlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void pause() {
|
||||||
|
controller.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void flush() {
|
||||||
|
inputStreamEnded = false;
|
||||||
|
signalledEndOfStream = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset() {
|
||||||
|
flush();
|
||||||
|
currentInputFormat = null;
|
||||||
|
currentEditedMediaItemInfo = null;
|
||||||
|
controller.onReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported interface functionality.
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setListener(AudioSink.Listener listener) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleDiscontinuity() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setAudioAttributes(AudioAttributes audioAttributes) {}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public AudioAttributes getAudioAttributes() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setPlaybackParameters(PlaybackParameters playbackParameters) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PlaybackParameters getPlaybackParameters() {
|
||||||
|
return PlaybackParameters.DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void enableTunnelingV21() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void disableTunneling() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setSkipSilenceEnabled(boolean skipSilenceEnabled) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getSkipSilenceEnabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setAudioSessionId(int audioSessionId) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setAuxEffectInfo(AuxEffectInfo auxEffectInfo) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setVolume(float volume) {}
|
||||||
|
|
||||||
|
// Internal methods
|
||||||
|
|
||||||
|
private boolean handleBufferInternal(ByteBuffer buffer, long presentationTimeUs, int flags) {
|
||||||
|
checkStateNotNull(currentInputFormat);
|
||||||
|
checkState(!signalledEndOfStream);
|
||||||
|
AudioGraphInput outputGraphInput = checkNotNull(this.outputGraphInput);
|
||||||
|
|
||||||
|
@Nullable DecoderInputBuffer outputBuffer = outputGraphInput.getInputBuffer();
|
||||||
|
if (outputBuffer == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
outputBuffer.ensureSpaceForWrite(buffer.remaining());
|
||||||
|
checkNotNull(outputBuffer.data).put(buffer).flip();
|
||||||
|
outputBuffer.timeUs =
|
||||||
|
presentationTimeUs == C.TIME_END_OF_SOURCE
|
||||||
|
? C.TIME_END_OF_SOURCE
|
||||||
|
: presentationTimeUs + offsetToCompositionTimeUs;
|
||||||
|
outputBuffer.setFlags(flags);
|
||||||
|
|
||||||
|
return outputGraphInput.queueInputBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class EditedMediaItemInfo {
|
||||||
|
public final EditedMediaItem editedMediaItem;
|
||||||
|
public final boolean isLastInSequence;
|
||||||
|
|
||||||
|
public EditedMediaItemInfo(EditedMediaItem editedMediaItem, boolean isLastInSequence) {
|
||||||
|
this.editedMediaItem = editedMediaItem;
|
||||||
|
this.isLastInSequence = isLastInSequence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,955 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkArgument;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkState;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
|
import static androidx.media3.common.util.Util.usToMs;
|
||||||
|
import static java.lang.Math.min;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.Process;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.view.Surface;
|
||||||
|
import android.view.SurfaceHolder;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.RestrictTo;
|
||||||
|
import androidx.annotation.VisibleForTesting;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
import androidx.media3.common.PlaybackException;
|
||||||
|
import androidx.media3.common.Player;
|
||||||
|
import androidx.media3.common.PreviewingVideoGraph;
|
||||||
|
import androidx.media3.common.SimpleBasePlayer;
|
||||||
|
import androidx.media3.common.VideoFrameProcessingException;
|
||||||
|
import androidx.media3.common.VideoSize;
|
||||||
|
import androidx.media3.common.util.Clock;
|
||||||
|
import androidx.media3.common.util.HandlerWrapper;
|
||||||
|
import androidx.media3.common.util.Log;
|
||||||
|
import androidx.media3.common.util.Size;
|
||||||
|
import androidx.media3.common.util.UnstableApi;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.effect.PreviewingSingleInputVideoGraph;
|
||||||
|
import androidx.media3.exoplayer.ExoPlaybackException;
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer;
|
||||||
|
import androidx.media3.exoplayer.RendererCapabilities;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink;
|
||||||
|
import androidx.media3.exoplayer.image.ImageDecoder;
|
||||||
|
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2;
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
|
||||||
|
import androidx.media3.exoplayer.source.ExternalLoader;
|
||||||
|
import androidx.media3.exoplayer.source.MergingMediaSource;
|
||||||
|
import androidx.media3.exoplayer.source.SilenceMediaSource;
|
||||||
|
import androidx.media3.exoplayer.source.TrackGroupArray;
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
|
||||||
|
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||||
|
import androidx.media3.exoplayer.util.EventLogger;
|
||||||
|
import androidx.media3.exoplayer.video.CompositingVideoSinkProvider;
|
||||||
|
import androidx.media3.exoplayer.video.VideoFrameReleaseControl;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link Player} implementation that plays {@linkplain Composition compositions} of media assets.
|
||||||
|
* The {@link Composition} specifies how the assets should be arranged, and the audio and video
|
||||||
|
* effects to apply to them.
|
||||||
|
*
|
||||||
|
* <p>CompositionPlayer instances must be accessed from a single application thread. For the vast
|
||||||
|
* majority of cases this should be the application's main thread. The thread on which a
|
||||||
|
* CompositionPlayer instance must be accessed can be explicitly specified by passing a {@link
|
||||||
|
* Looper} when creating the player. If no {@link Looper} is specified, then the {@link Looper} of
|
||||||
|
* the thread that the player is created on is used, or if that thread does not have a {@link
|
||||||
|
* Looper}, the {@link Looper} of the application's main thread is used. In all cases the {@link
|
||||||
|
* Looper} of the thread from which the player must be accessed can be queried using {@link
|
||||||
|
* #getApplicationLooper()}.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
@RestrictTo(LIBRARY_GROUP)
|
||||||
|
public final class CompositionPlayer extends SimpleBasePlayer
|
||||||
|
implements CompositionPlayerInternal.Listener,
|
||||||
|
CompositingVideoSinkProvider.Listener,
|
||||||
|
SurfaceHolder.Callback {
|
||||||
|
|
||||||
|
/** A builder for {@link CompositionPlayer} instances. */
|
||||||
|
public static final class Builder {
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
private @MonotonicNonNull Looper looper;
|
||||||
|
private @MonotonicNonNull AudioSink audioSink;
|
||||||
|
private @MonotonicNonNull ExternalLoader externalImageLoader;
|
||||||
|
private ImageDecoder.Factory imageDecoderFactory;
|
||||||
|
private Clock clock;
|
||||||
|
private PreviewingVideoGraph.@MonotonicNonNull Factory previewingVideoGraphFactory;
|
||||||
|
private boolean built;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
*/
|
||||||
|
public Builder(Context context) {
|
||||||
|
this.context = context.getApplicationContext();
|
||||||
|
imageDecoderFactory = ImageDecoder.Factory.DEFAULT;
|
||||||
|
clock = Clock.DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link Looper} from which the player can be accessed and {@link Player.Listener}
|
||||||
|
* callbacks are dispatched too.
|
||||||
|
*
|
||||||
|
* <p>By default, the builder uses the looper of the thread that calls {@link #build()}.
|
||||||
|
*
|
||||||
|
* @param looper The {@link Looper}.
|
||||||
|
* @return This builder, for convenience.
|
||||||
|
*/
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public Builder setLooper(Looper looper) {
|
||||||
|
this.looper = looper;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link AudioSink} that will be used to play out audio.
|
||||||
|
*
|
||||||
|
* <p>By default, a {@link DefaultAudioSink} with its default configuration is used.
|
||||||
|
*
|
||||||
|
* @param audioSink The {@link AudioSink}.
|
||||||
|
* @return This builder, for convenience.
|
||||||
|
*/
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public Builder setAudioSink(AudioSink audioSink) {
|
||||||
|
this.audioSink = audioSink;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link ExternalLoader} for loading image media items with MIME type set to {@link
|
||||||
|
* MimeTypes#APPLICATION_EXTERNALLY_LOADED_IMAGE}. When setting an external loader, also set an
|
||||||
|
* {@link ImageDecoder.Factory} with {@link #setImageDecoderFactory(ImageDecoder.Factory)}.
|
||||||
|
*
|
||||||
|
* <p>By default, the player will not be able to load images with media type of {@link
|
||||||
|
* androidx.media3.common.MimeTypes#APPLICATION_EXTERNALLY_LOADED_IMAGE}.
|
||||||
|
*
|
||||||
|
* @param externalImageLoader The {@link ExternalLoader}.
|
||||||
|
* @return This builder, for convenience.
|
||||||
|
* @see DefaultMediaSourceFactory#setExternalImageLoader(ExternalLoader)
|
||||||
|
*/
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public Builder setExternalImageLoader(ExternalLoader externalImageLoader) {
|
||||||
|
this.externalImageLoader = externalImageLoader;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets an {@link ImageDecoder.Factory} that will create the {@link ImageDecoder} instances to
|
||||||
|
* decode images.
|
||||||
|
*
|
||||||
|
* <p>By default, {@link ImageDecoder.Factory#DEFAULT} is used.
|
||||||
|
*
|
||||||
|
* @param imageDecoderFactory The {@link ImageDecoder.Factory}.
|
||||||
|
* @return This builder, for convenience.
|
||||||
|
*/
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public Builder setImageDecoderFactory(ImageDecoder.Factory imageDecoderFactory) {
|
||||||
|
this.imageDecoderFactory = imageDecoderFactory;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link Clock} that will be used by the player.
|
||||||
|
*
|
||||||
|
* <p>By default, {@link Clock#DEFAULT} is used.
|
||||||
|
*
|
||||||
|
* @param clock The {@link Clock}.
|
||||||
|
* @return This builder, for convenience.
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public Builder setClock(Clock clock) {
|
||||||
|
this.clock = clock;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link PreviewingVideoGraph.Factory} that will be used by the player.
|
||||||
|
*
|
||||||
|
* <p>By default, a {@link PreviewingSingleInputVideoGraph.Factory} is used.
|
||||||
|
*
|
||||||
|
* @param previewingVideoGraphFactory The {@link PreviewingVideoGraph.Factory}.
|
||||||
|
* @return This builder, for convenience.
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public Builder setPreviewingVideoGraphFactory(
|
||||||
|
PreviewingVideoGraph.Factory previewingVideoGraphFactory) {
|
||||||
|
this.previewingVideoGraphFactory = previewingVideoGraphFactory;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the {@link CompositionPlayer} instance. Must be called at most once.
|
||||||
|
*
|
||||||
|
* <p>If no {@link Looper} has been called with {@link #setLooper(Looper)}, then this method
|
||||||
|
* must be called within a {@link Looper} thread which is the thread that can access the player
|
||||||
|
* instance and where {@link Player.Listener} callbacks are dispatched.
|
||||||
|
*/
|
||||||
|
public CompositionPlayer build() {
|
||||||
|
checkState(!built);
|
||||||
|
if (looper == null) {
|
||||||
|
looper = checkStateNotNull(Looper.myLooper());
|
||||||
|
}
|
||||||
|
if (audioSink == null) {
|
||||||
|
audioSink = new DefaultAudioSink.Builder(context).build();
|
||||||
|
}
|
||||||
|
if (previewingVideoGraphFactory == null) {
|
||||||
|
previewingVideoGraphFactory = new PreviewingSingleInputVideoGraph.Factory();
|
||||||
|
}
|
||||||
|
CompositionPlayer compositionPlayer = new CompositionPlayer(this);
|
||||||
|
built = true;
|
||||||
|
return compositionPlayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Commands AVAILABLE_COMMANDS =
|
||||||
|
new Commands.Builder()
|
||||||
|
.addAll(
|
||||||
|
COMMAND_PLAY_PAUSE,
|
||||||
|
COMMAND_PREPARE,
|
||||||
|
COMMAND_STOP,
|
||||||
|
COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
||||||
|
COMMAND_SEEK_BACK,
|
||||||
|
COMMAND_SEEK_FORWARD,
|
||||||
|
COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||||
|
COMMAND_GET_TIMELINE,
|
||||||
|
COMMAND_SET_VIDEO_SURFACE,
|
||||||
|
COMMAND_RELEASE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static final @Event int[] SUPPORTED_LISTENER_EVENTS =
|
||||||
|
new int[] {
|
||||||
|
EVENT_PLAYBACK_STATE_CHANGED,
|
||||||
|
EVENT_PLAY_WHEN_READY_CHANGED,
|
||||||
|
EVENT_PLAYER_ERROR,
|
||||||
|
EVENT_POSITION_DISCONTINUITY
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int MAX_SUPPORTED_SEQUENCES = 2;
|
||||||
|
|
||||||
|
private static final String TAG = "CompositionPlayer";
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final Clock clock;
|
||||||
|
private final HandlerWrapper applicationHandler;
|
||||||
|
private final List<ExoPlayer> players;
|
||||||
|
private final AudioSink finalAudioSink;
|
||||||
|
@Nullable private final ExternalLoader externalImageLoader;
|
||||||
|
private final ImageDecoder.Factory imageDecoderFactory;
|
||||||
|
private final PreviewingVideoGraph.Factory previewingVideoGraphFactory;
|
||||||
|
private final HandlerWrapper compositionInternalListenerHandler;
|
||||||
|
|
||||||
|
private @MonotonicNonNull HandlerThread playbackThread;
|
||||||
|
private @MonotonicNonNull CompositionPlayerInternal compositionPlayerInternal;
|
||||||
|
private @MonotonicNonNull ImmutableList<MediaItemData> playlist;
|
||||||
|
private @MonotonicNonNull Composition composition;
|
||||||
|
private @MonotonicNonNull Size videoOutputSize;
|
||||||
|
private long compositionDurationUs;
|
||||||
|
private boolean playWhenReady;
|
||||||
|
private @PlayWhenReadyChangeReason int playWhenReadyChangeReason;
|
||||||
|
private boolean renderedFirstFrame;
|
||||||
|
@Nullable private Object videoOutput;
|
||||||
|
@Nullable private PlaybackException playbackException;
|
||||||
|
private @Player.State int playbackState;
|
||||||
|
@Nullable private SurfaceHolder surfaceHolder;
|
||||||
|
@Nullable private Surface displaySurface;
|
||||||
|
|
||||||
|
private CompositionPlayer(Builder builder) {
|
||||||
|
super(checkNotNull(builder.looper), builder.clock);
|
||||||
|
context = builder.context;
|
||||||
|
clock = builder.clock;
|
||||||
|
applicationHandler = clock.createHandler(builder.looper, /* callback= */ null);
|
||||||
|
finalAudioSink = checkNotNull(builder.audioSink);
|
||||||
|
externalImageLoader = builder.externalImageLoader;
|
||||||
|
imageDecoderFactory = builder.imageDecoderFactory;
|
||||||
|
previewingVideoGraphFactory = checkNotNull(builder.previewingVideoGraphFactory);
|
||||||
|
compositionInternalListenerHandler = clock.createHandler(builder.looper, /* callback= */ null);
|
||||||
|
players = new ArrayList<>();
|
||||||
|
compositionDurationUs = C.TIME_UNSET;
|
||||||
|
playbackState = STATE_IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link Composition} to play.
|
||||||
|
*
|
||||||
|
* <p>This method should only be called once.
|
||||||
|
*
|
||||||
|
* @param composition The {@link Composition} to play. Every {@link EditedMediaItem} in the {@link
|
||||||
|
* Composition} must have its {@link EditedMediaItem#durationUs} set.
|
||||||
|
*/
|
||||||
|
public void setComposition(Composition composition) {
|
||||||
|
verifyApplicationThread();
|
||||||
|
checkArgument(
|
||||||
|
!composition.sequences.isEmpty()
|
||||||
|
&& composition.sequences.size() <= MAX_SUPPORTED_SEQUENCES);
|
||||||
|
checkState(this.composition == null);
|
||||||
|
|
||||||
|
setCompositionInternal(composition);
|
||||||
|
if (videoOutput != null) {
|
||||||
|
if (videoOutput instanceof SurfaceHolder) {
|
||||||
|
setVideoSurfaceHolderInternal((SurfaceHolder) videoOutput);
|
||||||
|
} else if (videoOutput instanceof SurfaceView) {
|
||||||
|
SurfaceView surfaceView = (SurfaceView) videoOutput;
|
||||||
|
setVideoSurfaceHolderInternal(surfaceView.getHolder());
|
||||||
|
} else if (videoOutput instanceof Surface) {
|
||||||
|
setVideoSurfaceInternal((Surface) videoOutput, checkNotNull(videoOutputSize));
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException(videoOutput.getClass().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update the composition field at the end after everything else has been set.
|
||||||
|
this.composition = composition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the {@link Surface} and {@link Size} to render to. */
|
||||||
|
@VisibleForTesting
|
||||||
|
public void setVideoSurface(Surface surface, Size videoOutputSize) {
|
||||||
|
videoOutput = surface;
|
||||||
|
this.videoOutputSize = videoOutputSize;
|
||||||
|
setVideoSurfaceInternal(surface, videoOutputSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositingVideoSinkProvider.Listener methods. Called on playback thread.
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFirstFrameRendered(CompositingVideoSinkProvider compositingVideoSinkProvider) {
|
||||||
|
applicationHandler.post(
|
||||||
|
() -> {
|
||||||
|
CompositionPlayer.this.renderedFirstFrame = true;
|
||||||
|
invalidateState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrameDropped(CompositingVideoSinkProvider compositingVideoSinkProvider) {
|
||||||
|
// Do not post to application thread on each dropped frame, because onFrameDropped
|
||||||
|
// may be called frequently when resources are already scarce.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onVideoSizeChanged(
|
||||||
|
CompositingVideoSinkProvider compositingVideoSinkProvider, VideoSize videoSize) {
|
||||||
|
// TODO: b/328219481 - Report video size change to app.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(
|
||||||
|
CompositingVideoSinkProvider compositingVideoSinkProvider,
|
||||||
|
VideoFrameProcessingException videoFrameProcessingException) {
|
||||||
|
// The error will also be surfaced from the underlying ExoPlayer instance via
|
||||||
|
// PlayerListener.onPlayerError, and it will arrive to the composition player twice.
|
||||||
|
applicationHandler.post(
|
||||||
|
() ->
|
||||||
|
maybeUpdatePlaybackError(
|
||||||
|
"error from video sink provider",
|
||||||
|
videoFrameProcessingException,
|
||||||
|
PlaybackException.ERROR_CODE_VIDEO_FRAME_PROCESSING_FAILED));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SurfaceHolder.Callback methods. Called on application thread.
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceCreated(SurfaceHolder holder) {
|
||||||
|
videoOutputSize = new Size(holder.getSurfaceFrame().width(), holder.getSurfaceFrame().height());
|
||||||
|
setVideoSurfaceInternal(holder.getSurface(), videoOutputSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||||
|
maybeSetOutputSurfaceInfo(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||||
|
clearVideoSurfaceInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimpleBasePlayer methods
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected State getState() {
|
||||||
|
@Player.State int oldPlaybackState = playbackState;
|
||||||
|
updatePlaybackState();
|
||||||
|
if (oldPlaybackState != STATE_READY && playbackState == STATE_READY && playWhenReady) {
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).setPlayWhenReady(true);
|
||||||
|
}
|
||||||
|
} else if (oldPlaybackState == STATE_READY
|
||||||
|
&& playWhenReady
|
||||||
|
&& playbackState == STATE_BUFFERING) {
|
||||||
|
// We were playing but a player got in buffering state, pause the players.
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).setPlayWhenReady(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: b/328219481 - Report video size change to app.
|
||||||
|
State.Builder state =
|
||||||
|
new State.Builder()
|
||||||
|
.setAvailableCommands(AVAILABLE_COMMANDS)
|
||||||
|
.setPlaybackState(playbackState)
|
||||||
|
.setPlayerError(playbackException)
|
||||||
|
.setPlayWhenReady(playWhenReady, playWhenReadyChangeReason)
|
||||||
|
.setContentPositionMs(this::getContentPositionMs)
|
||||||
|
.setContentBufferedPositionMs(this::getBufferedPositionMs)
|
||||||
|
.setTotalBufferedDurationMs(this::getTotalBufferedDurationMs)
|
||||||
|
.setNewlyRenderedFirstFrame(getRenderedFirstFrameAndReset());
|
||||||
|
if (playlist != null) {
|
||||||
|
// Update the playlist only after it has been set so that SimpleBasePlayer announces a
|
||||||
|
// timeline
|
||||||
|
// change with reason TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED.
|
||||||
|
state.setPlaylist(playlist);
|
||||||
|
}
|
||||||
|
return state.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handlePrepare() {
|
||||||
|
checkStateNotNull(composition, "No composition set");
|
||||||
|
|
||||||
|
if (playbackState != Player.STATE_IDLE) {
|
||||||
|
// The player has been prepared already.
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).prepare();
|
||||||
|
}
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
|
||||||
|
this.playWhenReady = playWhenReady;
|
||||||
|
playWhenReadyChangeReason = PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
|
||||||
|
if (playbackState == STATE_READY) {
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).setPlayWhenReady(playWhenReady);
|
||||||
|
}
|
||||||
|
} // else, wait until all players are ready.
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handleStop() {
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).stop();
|
||||||
|
}
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handleRelease() {
|
||||||
|
if (composition == null) {
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkState(checkStateNotNull(playbackThread).isAlive());
|
||||||
|
// Release the players first so that they stop rendering.
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).release();
|
||||||
|
}
|
||||||
|
checkStateNotNull(compositionPlayerInternal).release();
|
||||||
|
removeSurfaceCallbacks();
|
||||||
|
// Remove any queued callback from the internal player.
|
||||||
|
compositionInternalListenerHandler.removeCallbacksAndMessages(/* token= */ null);
|
||||||
|
displaySurface = null;
|
||||||
|
checkStateNotNull(playbackThread).quitSafely();
|
||||||
|
applicationHandler.removeCallbacksAndMessages(/* token= */ null);
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handleClearVideoOutput(@Nullable Object videoOutput) {
|
||||||
|
checkArgument(Util.areEqual(videoOutput, this.videoOutput));
|
||||||
|
|
||||||
|
this.videoOutput = null;
|
||||||
|
if (composition == null) {
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
removeSurfaceCallbacks();
|
||||||
|
clearVideoSurfaceInternal();
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handleSetVideoOutput(Object videoOutput) {
|
||||||
|
if (!(videoOutput instanceof SurfaceHolder || videoOutput instanceof SurfaceView)) {
|
||||||
|
throw new UnsupportedOperationException(videoOutput.getClass().toString());
|
||||||
|
}
|
||||||
|
this.videoOutput = videoOutput;
|
||||||
|
if (composition == null) {
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
if (videoOutput instanceof SurfaceHolder) {
|
||||||
|
setVideoSurfaceHolderInternal((SurfaceHolder) videoOutput);
|
||||||
|
} else {
|
||||||
|
setVideoSurfaceHolderInternal(((SurfaceView) videoOutput).getHolder());
|
||||||
|
}
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ListenableFuture<?> handleSeek(int mediaItemIndex, long positionMs, int seekCommand) {
|
||||||
|
CompositionPlayerInternal compositionPlayerInternal =
|
||||||
|
checkStateNotNull(this.compositionPlayerInternal);
|
||||||
|
compositionPlayerInternal.startSeek(positionMs);
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).seekTo(positionMs);
|
||||||
|
}
|
||||||
|
compositionPlayerInternal.endSeek();
|
||||||
|
return Futures.immediateVoidFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositionPlayerInternal.Listener methods
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message, Exception cause, int errorCode) {
|
||||||
|
maybeUpdatePlaybackError(message, cause, errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal methods
|
||||||
|
|
||||||
|
private void updatePlaybackState() {
|
||||||
|
if (players.isEmpty() || playbackException != null) {
|
||||||
|
playbackState = STATE_IDLE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int idleCount = 0;
|
||||||
|
int bufferingCount = 0;
|
||||||
|
int endedCount = 0;
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
@Player.State int playbackState = players.get(i).getPlaybackState();
|
||||||
|
switch (playbackState) {
|
||||||
|
case STATE_IDLE:
|
||||||
|
idleCount++;
|
||||||
|
break;
|
||||||
|
case STATE_BUFFERING:
|
||||||
|
bufferingCount++;
|
||||||
|
break;
|
||||||
|
case STATE_READY:
|
||||||
|
// ignore
|
||||||
|
break;
|
||||||
|
case STATE_ENDED:
|
||||||
|
endedCount++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalStateException(String.valueOf(playbackState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idleCount > 0) {
|
||||||
|
playbackState = STATE_IDLE;
|
||||||
|
} else if (bufferingCount > 0) {
|
||||||
|
playbackState = STATE_BUFFERING;
|
||||||
|
} else if (endedCount == players.size()) {
|
||||||
|
playbackState = STATE_ENDED;
|
||||||
|
} else {
|
||||||
|
playbackState = STATE_READY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("VisibleForTests") // Calls ExoPlayer.Builder.setClock()
|
||||||
|
private void setCompositionInternal(Composition composition) {
|
||||||
|
compositionDurationUs = getCompositionDurationUs(composition);
|
||||||
|
playbackThread = new HandlerThread("CompositionPlaybackThread", Process.THREAD_PRIORITY_AUDIO);
|
||||||
|
playbackThread.start();
|
||||||
|
// Create the audio and video composition components now in order to setup the audio and video
|
||||||
|
// pipelines. Once this method returns, further access to the audio and video pipelines must
|
||||||
|
// done on the playback thread only, to ensure related components are accessed from one thread
|
||||||
|
// only.
|
||||||
|
PreviewAudioPipeline previewAudioPipeline =
|
||||||
|
new PreviewAudioPipeline(
|
||||||
|
new DefaultAudioMixer.Factory(),
|
||||||
|
composition.effects.audioProcessors,
|
||||||
|
checkNotNull(finalAudioSink));
|
||||||
|
CompositingVideoSinkProvider compositingVideoSinkProvider =
|
||||||
|
new CompositingVideoSinkProvider.Builder(context)
|
||||||
|
.setPreviewingVideoGraphFactory(checkNotNull(previewingVideoGraphFactory))
|
||||||
|
.build();
|
||||||
|
compositingVideoSinkProvider.setVideoFrameReleaseControl(
|
||||||
|
new VideoFrameReleaseControl(
|
||||||
|
context, new CompositionFrameTimingEvaluator(), /* allowedJoiningTimeMs= */ 0));
|
||||||
|
compositingVideoSinkProvider.addListener(this);
|
||||||
|
for (int i = 0; i < composition.sequences.size(); i++) {
|
||||||
|
EditedMediaItemSequence editedMediaItemSequence = composition.sequences.get(i);
|
||||||
|
SequencePlayerRenderersWrapper playerRenderersWrapper =
|
||||||
|
i == 0
|
||||||
|
? SequencePlayerRenderersWrapper.create(
|
||||||
|
context,
|
||||||
|
editedMediaItemSequence,
|
||||||
|
previewAudioPipeline,
|
||||||
|
compositingVideoSinkProvider,
|
||||||
|
imageDecoderFactory)
|
||||||
|
: SequencePlayerRenderersWrapper.createForAudio(
|
||||||
|
context, editedMediaItemSequence, previewAudioPipeline);
|
||||||
|
ExoPlayer.Builder playerBuilder =
|
||||||
|
new ExoPlayer.Builder(context)
|
||||||
|
.setLooper(getApplicationLooper())
|
||||||
|
.setPlaybackLooper(playbackThread.getLooper())
|
||||||
|
.setRenderersFactory(playerRenderersWrapper)
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.setClock(clock);
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
playerBuilder.setTrackSelector(new CompositionTrackSelector(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
ExoPlayer player = playerBuilder.build();
|
||||||
|
player.addListener(new PlayerListener(i));
|
||||||
|
player.addAnalyticsListener(new EventLogger());
|
||||||
|
setPlayerSequence(player, editedMediaItemSequence, /* shouldGenerateSilence= */ i == 0);
|
||||||
|
players.add(player);
|
||||||
|
if (i == 0) {
|
||||||
|
// Invalidate the player state before initializing the playlist to force SimpleBasePlayer
|
||||||
|
// to collect a state while the playlist is null. Consequently, once the playlist is
|
||||||
|
// initialized, SimpleBasePlayer will raise a timeline change callback with reason
|
||||||
|
// TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED.
|
||||||
|
invalidateState();
|
||||||
|
playlist = createPlaylist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// From here after, composition player accessed the audio and video pipelines via the internal
|
||||||
|
// player. The internal player ensures access to the components is done on the playback thread.
|
||||||
|
compositionPlayerInternal =
|
||||||
|
new CompositionPlayerInternal(
|
||||||
|
playbackThread.getLooper(),
|
||||||
|
clock,
|
||||||
|
previewAudioPipeline,
|
||||||
|
compositingVideoSinkProvider,
|
||||||
|
/* listener= */ this,
|
||||||
|
compositionInternalListenerHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the {@linkplain EditedMediaItemSequence sequence} to be played by the player. */
|
||||||
|
private void setPlayerSequence(
|
||||||
|
ExoPlayer player, EditedMediaItemSequence sequence, boolean shouldGenerateSilence) {
|
||||||
|
ConcatenatingMediaSource2.Builder mediaSourceBuilder =
|
||||||
|
new ConcatenatingMediaSource2.Builder().useDefaultMediaSourceFactory(context);
|
||||||
|
|
||||||
|
for (int i = 0; i < sequence.editedMediaItems.size(); i++) {
|
||||||
|
EditedMediaItem editedMediaItem = sequence.editedMediaItems.get(i);
|
||||||
|
checkArgument(editedMediaItem.durationUs != C.TIME_UNSET);
|
||||||
|
long durationUs = editedMediaItem.getPresentationDurationUs();
|
||||||
|
|
||||||
|
if (shouldGenerateSilence) {
|
||||||
|
DefaultMediaSourceFactory defaultMediaSourceFactory =
|
||||||
|
new DefaultMediaSourceFactory(context);
|
||||||
|
if (externalImageLoader != null) {
|
||||||
|
defaultMediaSourceFactory.setExternalImageLoader(externalImageLoader);
|
||||||
|
}
|
||||||
|
mediaSourceBuilder.add(
|
||||||
|
new MergingMediaSource(
|
||||||
|
defaultMediaSourceFactory.createMediaSource(editedMediaItem.mediaItem),
|
||||||
|
// Generate silence as long as the MediaItem without clipping, because the actual
|
||||||
|
// media track starts at the clipped position. For example, if a video is 1000ms
|
||||||
|
// long and clipped 900ms from the start, its MediaSource will be enabled at 900ms
|
||||||
|
// during track selection, rather than at 0ms.
|
||||||
|
new SilenceMediaSource(editedMediaItem.durationUs)),
|
||||||
|
/* initialPlaceholderDurationMs= */ usToMs(durationUs));
|
||||||
|
} else {
|
||||||
|
mediaSourceBuilder.add(
|
||||||
|
editedMediaItem.mediaItem, /* initialPlaceholderDurationMs= */ usToMs(durationUs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.setMediaSource(mediaSourceBuilder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getContentPositionMs() {
|
||||||
|
return players.isEmpty() ? C.TIME_UNSET : players.get(0).getContentPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getBufferedPositionMs() {
|
||||||
|
if (players.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Return the minimum buffered position among players.
|
||||||
|
long minBufferedPositionMs = Integer.MAX_VALUE;
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
minBufferedPositionMs = min(minBufferedPositionMs, players.get(i).getBufferedPosition());
|
||||||
|
}
|
||||||
|
return minBufferedPositionMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getTotalBufferedDurationMs() {
|
||||||
|
if (players.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Return the minimum total buffered duration among players.
|
||||||
|
long minTotalBufferedDurationMs = Integer.MAX_VALUE;
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
minTotalBufferedDurationMs =
|
||||||
|
min(minTotalBufferedDurationMs, players.get(i).getTotalBufferedDuration());
|
||||||
|
}
|
||||||
|
return minTotalBufferedDurationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean getRenderedFirstFrameAndReset() {
|
||||||
|
boolean value = renderedFirstFrame;
|
||||||
|
renderedFirstFrame = false;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void maybeUpdatePlaybackError(
|
||||||
|
String errorMessage, Exception cause, @PlaybackException.ErrorCode int errorCode) {
|
||||||
|
if (playbackException == null) {
|
||||||
|
playbackException = new PlaybackException(errorMessage, cause, errorCode);
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
players.get(i).stop();
|
||||||
|
}
|
||||||
|
invalidateState();
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, errorMessage, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setVideoSurfaceHolderInternal(SurfaceHolder surfaceHolder) {
|
||||||
|
removeSurfaceCallbacks();
|
||||||
|
this.surfaceHolder = surfaceHolder;
|
||||||
|
surfaceHolder.addCallback(this);
|
||||||
|
Surface surface = surfaceHolder.getSurface();
|
||||||
|
if (surface != null && surface.isValid()) {
|
||||||
|
videoOutputSize =
|
||||||
|
new Size(
|
||||||
|
surfaceHolder.getSurfaceFrame().width(), surfaceHolder.getSurfaceFrame().height());
|
||||||
|
setVideoSurfaceInternal(surface, videoOutputSize);
|
||||||
|
} else {
|
||||||
|
clearVideoSurfaceInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setVideoSurfaceInternal(Surface surface, Size videoOutputSize) {
|
||||||
|
displaySurface = surface;
|
||||||
|
maybeSetOutputSurfaceInfo(videoOutputSize.getWidth(), videoOutputSize.getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void maybeSetOutputSurfaceInfo(int width, int height) {
|
||||||
|
Surface surface = displaySurface;
|
||||||
|
if (width == 0 || height == 0 || surface == null || compositionPlayerInternal == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
compositionPlayerInternal.setOutputSurfaceInfo(surface, new Size(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearVideoSurfaceInternal() {
|
||||||
|
displaySurface = null;
|
||||||
|
if (compositionPlayerInternal != null) {
|
||||||
|
compositionPlayerInternal.clearOutputSurface();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeSurfaceCallbacks() {
|
||||||
|
if (surfaceHolder != null) {
|
||||||
|
surfaceHolder.removeCallback(this);
|
||||||
|
surfaceHolder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImmutableList<MediaItemData> createPlaylist() {
|
||||||
|
checkNotNull(compositionDurationUs != C.TIME_UNSET);
|
||||||
|
return ImmutableList.of(
|
||||||
|
new MediaItemData.Builder("CompositionTimeline")
|
||||||
|
.setMediaItem(MediaItem.EMPTY)
|
||||||
|
.setDurationUs(compositionDurationUs)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long getCompositionDurationUs(Composition composition) {
|
||||||
|
checkState(!composition.sequences.isEmpty());
|
||||||
|
|
||||||
|
long compositionDurationUs = getSequenceDurationUs(composition.sequences.get(0));
|
||||||
|
for (int i = 0; i < composition.sequences.size(); i++) {
|
||||||
|
long sequenceDurationUs = getSequenceDurationUs(composition.sequences.get(i));
|
||||||
|
checkArgument(
|
||||||
|
compositionDurationUs == sequenceDurationUs,
|
||||||
|
Util.formatInvariant(
|
||||||
|
"Non-matching sequence durations. First sequence duration: %d us, sequence [%d]"
|
||||||
|
+ " duration: %d us",
|
||||||
|
compositionDurationUs, i, sequenceDurationUs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return compositionDurationUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long getSequenceDurationUs(EditedMediaItemSequence sequence) {
|
||||||
|
long compositionDurationUs = 0;
|
||||||
|
for (int i = 0; i < sequence.editedMediaItems.size(); i++) {
|
||||||
|
compositionDurationUs += sequence.editedMediaItems.get(i).getPresentationDurationUs();
|
||||||
|
}
|
||||||
|
checkState(compositionDurationUs > 0, String.valueOf(compositionDurationUs));
|
||||||
|
return compositionDurationUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link VideoFrameReleaseControl.FrameTimingEvaluator} for composition frames.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Signals to {@linkplain
|
||||||
|
* VideoFrameReleaseControl.FrameTimingEvaluator#shouldForceReleaseFrame(long, long) force
|
||||||
|
* release} a frame if the frame is late by more than {@link #FRAME_LATE_THRESHOLD_US} and
|
||||||
|
* the elapsed time since the previous frame release is greater than {@link
|
||||||
|
* #FRAME_RELEASE_THRESHOLD_US}.
|
||||||
|
* <li>Signals to {@linkplain
|
||||||
|
* VideoFrameReleaseControl.FrameTimingEvaluator#shouldDropFrame(long, long, boolean) drop a
|
||||||
|
* frame} if the frame is late by more than {@link #FRAME_LATE_THRESHOLD_US} and the frame
|
||||||
|
* is not marked as the last one.
|
||||||
|
* <li>Signals to never {@linkplain
|
||||||
|
* VideoFrameReleaseControl.FrameTimingEvaluator#shouldIgnoreFrame(long, long, long,
|
||||||
|
* boolean, boolean) ignore} a frame.
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private static final class CompositionFrameTimingEvaluator
|
||||||
|
implements VideoFrameReleaseControl.FrameTimingEvaluator {
|
||||||
|
|
||||||
|
/** The time threshold, in microseconds, after which a frame is considered late. */
|
||||||
|
private static final long FRAME_LATE_THRESHOLD_US = -30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum elapsed time threshold, in microseconds, since last releasing a frame after which
|
||||||
|
* a frame can be force released.
|
||||||
|
*/
|
||||||
|
private static final long FRAME_RELEASE_THRESHOLD_US = 100_000;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldForceReleaseFrame(long earlyUs, long elapsedSinceLastReleaseUs) {
|
||||||
|
return earlyUs < FRAME_LATE_THRESHOLD_US
|
||||||
|
&& elapsedSinceLastReleaseUs > FRAME_RELEASE_THRESHOLD_US;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldDropFrame(long earlyUs, long elapsedRealtimeUs, boolean isLastFrame) {
|
||||||
|
return earlyUs < FRAME_LATE_THRESHOLD_US && !isLastFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldIgnoreFrame(
|
||||||
|
long earlyUs,
|
||||||
|
long positionUs,
|
||||||
|
long elapsedRealtimeUs,
|
||||||
|
boolean isLastFrame,
|
||||||
|
boolean treatDroppedBuffersAsSkipped) {
|
||||||
|
// TODO: b/293873191 - Handle very late buffers and drop to key frame.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PlayerListener implements Player.Listener {
|
||||||
|
private final int playerIndex;
|
||||||
|
|
||||||
|
public PlayerListener(int playerIndex) {
|
||||||
|
this.playerIndex = playerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEvents(Player player, Events events) {
|
||||||
|
if (events.containsAny(SUPPORTED_LISTENER_EVENTS)) {
|
||||||
|
invalidateState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
|
||||||
|
playWhenReadyChangeReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerError(PlaybackException error) {
|
||||||
|
maybeUpdatePlaybackError("error from player " + playerIndex, error, error.errorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link DefaultTrackSelector} extension to de-select generated audio when the audio from the
|
||||||
|
* media is playable.
|
||||||
|
*/
|
||||||
|
private static final class CompositionTrackSelector extends DefaultTrackSelector {
|
||||||
|
|
||||||
|
private static final String SILENCE_AUDIO_TRACK_GROUP_ID = "1:";
|
||||||
|
|
||||||
|
public CompositionTrackSelector(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
protected Pair<ExoTrackSelection.Definition, Integer> selectAudioTrack(
|
||||||
|
MappedTrackInfo mappedTrackInfo,
|
||||||
|
@RendererCapabilities.Capabilities int[][][] rendererFormatSupports,
|
||||||
|
@RendererCapabilities.AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports,
|
||||||
|
Parameters params)
|
||||||
|
throws ExoPlaybackException {
|
||||||
|
int audioRenderIndex = C.INDEX_UNSET;
|
||||||
|
for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
|
||||||
|
if (mappedTrackInfo.getRendererType(i) == C.TRACK_TYPE_AUDIO) {
|
||||||
|
audioRenderIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkState(audioRenderIndex != C.INDEX_UNSET);
|
||||||
|
|
||||||
|
TrackGroupArray audioTrackGroups = mappedTrackInfo.getTrackGroups(audioRenderIndex);
|
||||||
|
// If there's only one audio TrackGroup, it'll be silence, there's no need to override track
|
||||||
|
// selection.
|
||||||
|
if (audioTrackGroups.length > 1) {
|
||||||
|
boolean mediaAudioIsPlayable = false;
|
||||||
|
int silenceAudioTrackGroupIndex = C.INDEX_UNSET;
|
||||||
|
for (int i = 0; i < audioTrackGroups.length; i++) {
|
||||||
|
if (audioTrackGroups.get(i).id.startsWith(SILENCE_AUDIO_TRACK_GROUP_ID)) {
|
||||||
|
silenceAudioTrackGroupIndex = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// For non-silence tracks
|
||||||
|
for (int j = 0; j < audioTrackGroups.get(i).length; j++) {
|
||||||
|
mediaAudioIsPlayable |=
|
||||||
|
RendererCapabilities.getFormatSupport(
|
||||||
|
rendererFormatSupports[audioRenderIndex][i][j])
|
||||||
|
== C.FORMAT_HANDLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkState(silenceAudioTrackGroupIndex != C.INDEX_UNSET);
|
||||||
|
|
||||||
|
if (mediaAudioIsPlayable) {
|
||||||
|
// Disable silence if the media's audio track is playable.
|
||||||
|
int silenceAudioTrackIndex = audioTrackGroups.length - 1;
|
||||||
|
rendererFormatSupports[audioRenderIndex][silenceAudioTrackIndex][0] =
|
||||||
|
RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.selectAudioTrack(
|
||||||
|
mappedTrackInfo, rendererFormatSupports, rendererMixedMimeTypeAdaptationSupports, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.common.util.Assertions.checkState;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.Message;
|
||||||
|
import android.view.Surface;
|
||||||
|
import androidx.media3.common.PlaybackException;
|
||||||
|
import androidx.media3.common.util.Clock;
|
||||||
|
import androidx.media3.common.util.ConditionVariable;
|
||||||
|
import androidx.media3.common.util.HandlerWrapper;
|
||||||
|
import androidx.media3.common.util.Log;
|
||||||
|
import androidx.media3.common.util.Size;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.exoplayer.video.CompositingVideoSinkProvider;
|
||||||
|
|
||||||
|
/** Provides access to the composition preview audio and video components on the playback thread. */
|
||||||
|
/* package */ final class CompositionPlayerInternal implements Handler.Callback {
|
||||||
|
|
||||||
|
/** A listener for events. */
|
||||||
|
public interface Listener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an error occurs
|
||||||
|
*
|
||||||
|
* @param message The error message.
|
||||||
|
* @param cause The error cause.
|
||||||
|
* @param errorCode The error code.
|
||||||
|
*/
|
||||||
|
void onError(String message, Exception cause, @PlaybackException.ErrorCode int errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TAG = "CompPlayerInternal";
|
||||||
|
private static final int MSG_SET_OUTPUT_SURFACE_INFO = 0;
|
||||||
|
private static final int MSG_CLEAR_OUTPUT_SURFACE = 1;
|
||||||
|
private static final int MSG_START_SEEK = 2;
|
||||||
|
private static final int MSG_END_SEEK = 3;
|
||||||
|
private static final int MSG_RELEASE = 4;
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
private final HandlerWrapper handler;
|
||||||
|
|
||||||
|
/** Must be accessed on the playback thread only. */
|
||||||
|
private final PreviewAudioPipeline previewAudioPipeline;
|
||||||
|
|
||||||
|
/** Must be accessed on the playback thread only. */
|
||||||
|
private final CompositingVideoSinkProvider compositingVideoSinkProvider;
|
||||||
|
|
||||||
|
private final Listener listener;
|
||||||
|
private final HandlerWrapper listenerHandler;
|
||||||
|
|
||||||
|
private boolean released;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a instance.
|
||||||
|
*
|
||||||
|
* @param playbackLooper The playback thread {@link Looper}.
|
||||||
|
* @param clock The {@link Clock} used.
|
||||||
|
* @param previewAudioPipeline The {@link PreviewAudioPipeline}.
|
||||||
|
* @param compositingVideoSinkProvider The {@link CompositingVideoSinkProvider}.
|
||||||
|
* @param listener A {@link Listener} to send callbacks back to the player.
|
||||||
|
* @param listenerHandler A {@link HandlerWrapper} to dispatch {@link Listener} callbacks.
|
||||||
|
*/
|
||||||
|
public CompositionPlayerInternal(
|
||||||
|
Looper playbackLooper,
|
||||||
|
Clock clock,
|
||||||
|
PreviewAudioPipeline previewAudioPipeline,
|
||||||
|
CompositingVideoSinkProvider compositingVideoSinkProvider,
|
||||||
|
Listener listener,
|
||||||
|
HandlerWrapper listenerHandler) {
|
||||||
|
this.clock = clock;
|
||||||
|
this.handler = clock.createHandler(playbackLooper, /* callback= */ this);
|
||||||
|
this.previewAudioPipeline = previewAudioPipeline;
|
||||||
|
this.compositingVideoSinkProvider = compositingVideoSinkProvider;
|
||||||
|
this.listener = listener;
|
||||||
|
this.listenerHandler = listenerHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public methods
|
||||||
|
|
||||||
|
/** Sets the output surface information on the video pipeline. */
|
||||||
|
public void setOutputSurfaceInfo(Surface surface, Size size) {
|
||||||
|
handler
|
||||||
|
.obtainMessage(MSG_SET_OUTPUT_SURFACE_INFO, new OutputSurfaceInfo(surface, size))
|
||||||
|
.sendToTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the output surface from the video pipeline. */
|
||||||
|
public void clearOutputSurface() {
|
||||||
|
handler.obtainMessage(MSG_CLEAR_OUTPUT_SURFACE).sendToTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startSeek(long positionMs) {
|
||||||
|
handler.obtainMessage(MSG_START_SEEK, positionMs).sendToTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void endSeek() {
|
||||||
|
handler.obtainMessage(MSG_END_SEEK).sendToTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases internal components on the playback thread and blocks the current thread until the
|
||||||
|
* components are released.
|
||||||
|
*/
|
||||||
|
public void release() {
|
||||||
|
checkState(!released);
|
||||||
|
// Set released to true now to silence any pending listener callback.
|
||||||
|
released = true;
|
||||||
|
ConditionVariable conditionVariable = new ConditionVariable();
|
||||||
|
handler.obtainMessage(MSG_RELEASE, conditionVariable).sendToTarget();
|
||||||
|
clock.onThreadBlocked();
|
||||||
|
try {
|
||||||
|
conditionVariable.block();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler.Callback methods
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean handleMessage(Message message) {
|
||||||
|
try {
|
||||||
|
switch (message.what) {
|
||||||
|
case MSG_SET_OUTPUT_SURFACE_INFO:
|
||||||
|
setOutputSurfaceInfoOnInternalThread(
|
||||||
|
/* outputSurfaceInfo= */ (OutputSurfaceInfo) message.obj);
|
||||||
|
break;
|
||||||
|
case MSG_CLEAR_OUTPUT_SURFACE:
|
||||||
|
clearOutputSurfaceInternal();
|
||||||
|
break;
|
||||||
|
case MSG_START_SEEK:
|
||||||
|
// Video seeking is currently handled by the video renderers, specifically in
|
||||||
|
// onPositionReset.
|
||||||
|
previewAudioPipeline.startSeek(/* positionUs= */ Util.msToUs((long) message.obj));
|
||||||
|
break;
|
||||||
|
case MSG_END_SEEK:
|
||||||
|
previewAudioPipeline.endSeek();
|
||||||
|
break;
|
||||||
|
case MSG_RELEASE:
|
||||||
|
releaseInternal(/* conditionVariable= */ (ConditionVariable) message.obj);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
maybeRaiseError(
|
||||||
|
/* message= */ "Unknown message",
|
||||||
|
new IllegalStateException(String.valueOf(message.what)),
|
||||||
|
/* errorCode= */ PlaybackException.ERROR_CODE_UNSPECIFIED);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
maybeRaiseError(
|
||||||
|
/* message= */ "Unknown error",
|
||||||
|
e,
|
||||||
|
/* errorCode= */ PlaybackException.ERROR_CODE_UNSPECIFIED);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal methods
|
||||||
|
|
||||||
|
private void releaseInternal(ConditionVariable conditionVariable) {
|
||||||
|
try {
|
||||||
|
previewAudioPipeline.release();
|
||||||
|
compositingVideoSinkProvider.clearOutputSurfaceInfo();
|
||||||
|
compositingVideoSinkProvider.release();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
Log.e(TAG, "error while releasing the player", e);
|
||||||
|
} finally {
|
||||||
|
conditionVariable.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearOutputSurfaceInternal() {
|
||||||
|
try {
|
||||||
|
compositingVideoSinkProvider.clearOutputSurfaceInfo();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
maybeRaiseError(
|
||||||
|
/* message= */ "error clearing video output",
|
||||||
|
e,
|
||||||
|
/* errorCode= */ PlaybackException.ERROR_CODE_VIDEO_FRAME_PROCESSING_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setOutputSurfaceInfoOnInternalThread(OutputSurfaceInfo outputSurfaceInfo) {
|
||||||
|
try {
|
||||||
|
compositingVideoSinkProvider.setOutputSurfaceInfo(
|
||||||
|
outputSurfaceInfo.surface, outputSurfaceInfo.size);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
maybeRaiseError(
|
||||||
|
/* message= */ "error setting surface view",
|
||||||
|
e,
|
||||||
|
/* errorCode= */ PlaybackException.ERROR_CODE_VIDEO_FRAME_PROCESSING_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void maybeRaiseError(
|
||||||
|
String message, Exception cause, @PlaybackException.ErrorCode int errorCode) {
|
||||||
|
try {
|
||||||
|
listenerHandler.post(
|
||||||
|
() -> {
|
||||||
|
// This code runs on the application thread, hence access to the `release` field does
|
||||||
|
// not need to be synchronized.
|
||||||
|
if (!released) {
|
||||||
|
listener.onError(message, cause, errorCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
Log.e(TAG, "error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class OutputSurfaceInfo {
|
||||||
|
public final Surface surface;
|
||||||
|
public final Size size;
|
||||||
|
|
||||||
|
public OutputSurfaceInfo(Surface surface, Size size) {
|
||||||
|
this.surface = surface;
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.common.util.Util.sampleCountToDurationUs;
|
||||||
|
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.audio.AudioProcessor;
|
||||||
|
import androidx.media3.common.audio.AudioProcessor.AudioFormat;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes input from {@link AudioGraphInputAudioSink} instances, plumbing the data through an
|
||||||
|
* {@link AudioGraph} and writing the output to the provided {@link AudioSink}.
|
||||||
|
*
|
||||||
|
* <p>Multiple streams of {@linkplain #createInput() input} are not currently supported.
|
||||||
|
*/
|
||||||
|
/* package */ final class PreviewAudioPipeline {
|
||||||
|
private final AudioSink finalAudioSink;
|
||||||
|
private final AudioGraph audioGraph;
|
||||||
|
|
||||||
|
private int audioGraphInputsCreated;
|
||||||
|
private int inputAudioSinksCreated;
|
||||||
|
private int inputAudioSinksPlaying;
|
||||||
|
private AudioFormat outputAudioFormat;
|
||||||
|
private long outputFramesWritten;
|
||||||
|
private long seekPositionUs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance.
|
||||||
|
*
|
||||||
|
* @param mixerFactory The {@linkplain AudioMixer.Factory factory} used to {@linkplain
|
||||||
|
* AudioMixer.Factory#create() create} the underlying {@link AudioMixer}.
|
||||||
|
* @param effects The composition-level audio effects that are applied after mixing.
|
||||||
|
* @param finalAudioSink The {@linkplain AudioSink sink} for processed output audio.
|
||||||
|
*/
|
||||||
|
public PreviewAudioPipeline(
|
||||||
|
AudioMixer.Factory mixerFactory,
|
||||||
|
ImmutableList<AudioProcessor> effects,
|
||||||
|
AudioSink finalAudioSink) {
|
||||||
|
audioGraph = new AudioGraph(mixerFactory, effects);
|
||||||
|
this.finalAudioSink = finalAudioSink;
|
||||||
|
|
||||||
|
outputAudioFormat = AudioFormat.NOT_SET;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases any underlying resources. */
|
||||||
|
public void release() {
|
||||||
|
audioGraph.reset();
|
||||||
|
finalAudioSink.reset();
|
||||||
|
finalAudioSink.release();
|
||||||
|
audioGraphInputsCreated = 0;
|
||||||
|
inputAudioSinksCreated = 0;
|
||||||
|
inputAudioSinksPlaying = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an {@link AudioSink} for a single sequence of non-overlapping raw PCM audio. */
|
||||||
|
public AudioGraphInputAudioSink createInput() {
|
||||||
|
return new AudioGraphInputAudioSink(new SinkController());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes data through the underlying components.
|
||||||
|
*
|
||||||
|
* @return Whether more data can be processed by immediately calling this method again.
|
||||||
|
*/
|
||||||
|
public boolean processData()
|
||||||
|
throws ExportException,
|
||||||
|
AudioSink.WriteException,
|
||||||
|
AudioSink.InitializationException,
|
||||||
|
AudioSink.ConfigurationException {
|
||||||
|
// Do not process any data until the input audio sinks have created audio graph inputs.
|
||||||
|
if (inputAudioSinksCreated == 0 || inputAudioSinksCreated != audioGraphInputsCreated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Objects.equals(outputAudioFormat, AudioFormat.NOT_SET)) {
|
||||||
|
AudioFormat audioGraphAudioFormat = audioGraph.getOutputAudioFormat();
|
||||||
|
if (Objects.equals(audioGraphAudioFormat, AudioFormat.NOT_SET)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalAudioSink.configure(
|
||||||
|
Util.getPcmFormat(audioGraphAudioFormat),
|
||||||
|
/* specifiedBufferSize= */ 0,
|
||||||
|
/* outputChannels= */ null);
|
||||||
|
outputAudioFormat = audioGraphAudioFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioGraph.isEnded()) {
|
||||||
|
if (finalAudioSink.isEnded()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finalAudioSink.playToEndOfStream();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer audioBuffer = audioGraph.getOutput();
|
||||||
|
if (!audioBuffer.hasRemaining()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bytesToWrite = audioBuffer.remaining();
|
||||||
|
boolean bufferHandled =
|
||||||
|
finalAudioSink.handleBuffer(
|
||||||
|
audioBuffer, getBufferPresentationTimeUs(), /* encodedAccessUnitCount= */ 1);
|
||||||
|
outputFramesWritten +=
|
||||||
|
(bytesToWrite - audioBuffer.remaining()) / outputAudioFormat.bytesPerFrame;
|
||||||
|
return bufferHandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getBufferPresentationTimeUs() {
|
||||||
|
return seekPositionUs
|
||||||
|
+ sampleCountToDurationUs(outputFramesWritten, outputAudioFormat.sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the steps that need to be executed for a seek before seeking the upstream players.
|
||||||
|
*
|
||||||
|
* @param positionUs The seek position, in microseconds.
|
||||||
|
*/
|
||||||
|
public void startSeek(long positionUs) {
|
||||||
|
finalAudioSink.pause();
|
||||||
|
audioGraph.blockInput();
|
||||||
|
audioGraph.setPendingStartTimeUs(positionUs);
|
||||||
|
audioGraph.flush();
|
||||||
|
finalAudioSink.flush();
|
||||||
|
outputFramesWritten = 0;
|
||||||
|
seekPositionUs = positionUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles the steps that need to be executed for a seek after seeking the upstream players. */
|
||||||
|
public void endSeek() {
|
||||||
|
audioGraph.unblockInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class SinkController implements AudioGraphInputAudioSink.Controller {
|
||||||
|
private boolean playing;
|
||||||
|
|
||||||
|
public SinkController() {
|
||||||
|
inputAudioSinksCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AudioGraphInput getAudioGraphInput(EditedMediaItem editedMediaItem, Format format)
|
||||||
|
throws ExportException {
|
||||||
|
AudioGraphInput audioGraphInput = audioGraph.registerInput(editedMediaItem, format);
|
||||||
|
audioGraphInputsCreated++;
|
||||||
|
return audioGraphInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getCurrentPositionUs() {
|
||||||
|
return finalAudioSink.getCurrentPositionUs(/* sourceEnded= */ false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnded() {
|
||||||
|
return finalAudioSink.isEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlay() {
|
||||||
|
if (playing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playing = true;
|
||||||
|
|
||||||
|
inputAudioSinksPlaying++;
|
||||||
|
if (inputAudioSinksCreated == inputAudioSinksPlaying) {
|
||||||
|
finalAudioSink.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
if (!playing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playing = false;
|
||||||
|
|
||||||
|
if (inputAudioSinksCreated == inputAudioSinksPlaying) {
|
||||||
|
finalAudioSink.pause();
|
||||||
|
}
|
||||||
|
inputAudioSinksPlaying--;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReset() {
|
||||||
|
onPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,433 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.common.util.Assertions.checkState;
|
||||||
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
|
import static androidx.media3.exoplayer.DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS;
|
||||||
|
import static androidx.media3.exoplayer.DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.os.Handler;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.ColorInfo;
|
||||||
|
import androidx.media3.common.Effect;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
import androidx.media3.common.PlaybackException;
|
||||||
|
import androidx.media3.common.util.ConstantRateTimestampIterator;
|
||||||
|
import androidx.media3.exoplayer.ExoPlaybackException;
|
||||||
|
import androidx.media3.exoplayer.Renderer;
|
||||||
|
import androidx.media3.exoplayer.RenderersFactory;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer;
|
||||||
|
import androidx.media3.exoplayer.image.ImageDecoder;
|
||||||
|
import androidx.media3.exoplayer.image.ImageOutput;
|
||||||
|
import androidx.media3.exoplayer.image.ImageRenderer;
|
||||||
|
import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter;
|
||||||
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
|
||||||
|
import androidx.media3.exoplayer.metadata.MetadataOutput;
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource;
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput;
|
||||||
|
import androidx.media3.exoplayer.video.CompositingVideoSinkProvider;
|
||||||
|
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer;
|
||||||
|
import androidx.media3.exoplayer.video.VideoFrameReleaseControl;
|
||||||
|
import androidx.media3.exoplayer.video.VideoRendererEventListener;
|
||||||
|
import androidx.media3.exoplayer.video.VideoSink;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
|
||||||
|
/** Wraps {@link EditedMediaItemSequence} specific rendering logic and state. */
|
||||||
|
/* package */ final class SequencePlayerRenderersWrapper implements RenderersFactory {
|
||||||
|
|
||||||
|
private static final int DEFAULT_FRAME_RATE = 30;
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final EditedMediaItemSequence sequence;
|
||||||
|
private final PreviewAudioPipeline previewAudioPipeline;
|
||||||
|
@Nullable private final CompositingVideoSinkProvider compositingVideoSinkProvider;
|
||||||
|
@Nullable private final ImageDecoder.Factory imageDecoderFactory;
|
||||||
|
|
||||||
|
/** Creates a renderers wrapper for a player that will play video, image and audio. */
|
||||||
|
public static SequencePlayerRenderersWrapper create(
|
||||||
|
Context context,
|
||||||
|
EditedMediaItemSequence sequence,
|
||||||
|
PreviewAudioPipeline previewAudioPipeline,
|
||||||
|
CompositingVideoSinkProvider compositingVideoSinkProvider,
|
||||||
|
ImageDecoder.Factory imageDecoderFactory) {
|
||||||
|
return new SequencePlayerRenderersWrapper(
|
||||||
|
context, sequence, previewAudioPipeline, compositingVideoSinkProvider, imageDecoderFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a renderers wrapper that for a player that will only play audio. */
|
||||||
|
public static SequencePlayerRenderersWrapper createForAudio(
|
||||||
|
Context context,
|
||||||
|
EditedMediaItemSequence sequence,
|
||||||
|
PreviewAudioPipeline previewAudioPipeline) {
|
||||||
|
return new SequencePlayerRenderersWrapper(
|
||||||
|
context,
|
||||||
|
sequence,
|
||||||
|
previewAudioPipeline,
|
||||||
|
/* compositingVideoSinkProvider= */ null,
|
||||||
|
/* imageDecoderFactory= */ null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SequencePlayerRenderersWrapper(
|
||||||
|
Context context,
|
||||||
|
EditedMediaItemSequence sequence,
|
||||||
|
PreviewAudioPipeline previewAudioPipeline,
|
||||||
|
@Nullable CompositingVideoSinkProvider compositingVideoSinkProvider,
|
||||||
|
@Nullable ImageDecoder.Factory imageDecoderFactory) {
|
||||||
|
this.context = context;
|
||||||
|
this.sequence = sequence;
|
||||||
|
this.previewAudioPipeline = previewAudioPipeline;
|
||||||
|
this.compositingVideoSinkProvider = compositingVideoSinkProvider;
|
||||||
|
this.imageDecoderFactory = imageDecoderFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Renderer[] createRenderers(
|
||||||
|
Handler eventHandler,
|
||||||
|
VideoRendererEventListener videoRendererEventListener,
|
||||||
|
AudioRendererEventListener audioRendererEventListener,
|
||||||
|
TextOutput textRendererOutput,
|
||||||
|
MetadataOutput metadataRendererOutput) {
|
||||||
|
List<Renderer> renderers = new ArrayList<>();
|
||||||
|
renderers.add(
|
||||||
|
new SequenceAudioRenderer(
|
||||||
|
context,
|
||||||
|
/* sequencePlayerRenderersWrapper= */ this,
|
||||||
|
eventHandler,
|
||||||
|
audioRendererEventListener,
|
||||||
|
previewAudioPipeline.createInput()));
|
||||||
|
|
||||||
|
if (compositingVideoSinkProvider != null) {
|
||||||
|
renderers.add(
|
||||||
|
new SequenceVideoRenderer(
|
||||||
|
checkStateNotNull(context),
|
||||||
|
eventHandler,
|
||||||
|
videoRendererEventListener,
|
||||||
|
/* sequencePlayerRenderersWrapper= */ this));
|
||||||
|
renderers.add(new SequenceImageRenderer(/* sequencePlayerRenderersWrapper= */ this));
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderers.toArray(new Renderer[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SequenceAudioRenderer extends MediaCodecAudioRenderer {
|
||||||
|
private final SequencePlayerRenderersWrapper sequencePlayerRenderersWrapper;
|
||||||
|
private final AudioGraphInputAudioSink audioSink;
|
||||||
|
|
||||||
|
@Nullable private EditedMediaItem pendingEditedMediaItem;
|
||||||
|
private long pendingOffsetToCompositionTimeUs;
|
||||||
|
|
||||||
|
// TODO - b/320007703: Revisit the abstractions needed here (editedMediaItemProvider and
|
||||||
|
// Supplier<EditedMediaItem>) once we finish all the wiring to support multiple sequences.
|
||||||
|
public SequenceAudioRenderer(
|
||||||
|
Context context,
|
||||||
|
SequencePlayerRenderersWrapper sequencePlayerRenderersWrapper,
|
||||||
|
@Nullable Handler eventHandler,
|
||||||
|
@Nullable AudioRendererEventListener eventListener,
|
||||||
|
AudioGraphInputAudioSink audioSink) {
|
||||||
|
super(context, MediaCodecSelector.DEFAULT, eventHandler, eventListener, audioSink);
|
||||||
|
this.sequencePlayerRenderersWrapper = sequencePlayerRenderersWrapper;
|
||||||
|
this.audioSink = audioSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaCodecAudioRenderer methods
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
|
||||||
|
super.render(positionUs, elapsedRealtimeUs);
|
||||||
|
try {
|
||||||
|
while (sequencePlayerRenderersWrapper.previewAudioPipeline.processData()) {}
|
||||||
|
} catch (ExportException
|
||||||
|
| AudioSink.WriteException
|
||||||
|
| AudioSink.InitializationException
|
||||||
|
| AudioSink.ConfigurationException e) {
|
||||||
|
throw createRendererException(
|
||||||
|
e, /* format= */ null, ExoPlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStreamChanged(
|
||||||
|
Format[] formats,
|
||||||
|
long startPositionUs,
|
||||||
|
long offsetUs,
|
||||||
|
MediaSource.MediaPeriodId mediaPeriodId)
|
||||||
|
throws ExoPlaybackException {
|
||||||
|
checkState(getTimeline().getWindowCount() == 1);
|
||||||
|
int mediaItemIndex = getTimeline().getIndexOfPeriod(mediaPeriodId.periodUid);
|
||||||
|
// We must first update the pending media item state before calling super.onStreamChanged()
|
||||||
|
// because the super method will call onProcessedStreamChange()
|
||||||
|
pendingEditedMediaItem =
|
||||||
|
sequencePlayerRenderersWrapper.sequence.editedMediaItems.get(mediaItemIndex);
|
||||||
|
// Reverse engineer how timestamps and offsets are computed with a ConcatenatingMediaSource2
|
||||||
|
// to compute an offset converting buffer timestamps to composition timestamps.
|
||||||
|
// startPositionUs is not used because it is equal to offsetUs + clipping start time + seek
|
||||||
|
// position when seeking from any MediaItem in the playlist to the first MediaItem.
|
||||||
|
// TODO(b/331547894): remove this reverse-engineered logic by moving away from using a
|
||||||
|
// ConcatenatingMediaSource2.
|
||||||
|
// The offset to convert the sample timestamps to composition time is negative because we need
|
||||||
|
// to remove the large offset added by ExoPlayer to make sure the decoder doesn't received any
|
||||||
|
// negative timestamps. We also need to remove the clipping start position.
|
||||||
|
pendingOffsetToCompositionTimeUs = -offsetUs;
|
||||||
|
if (mediaItemIndex == 0) {
|
||||||
|
pendingOffsetToCompositionTimeUs -=
|
||||||
|
pendingEditedMediaItem.mediaItem.clippingConfiguration.startPositionUs;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < mediaItemIndex; i++) {
|
||||||
|
pendingOffsetToCompositionTimeUs +=
|
||||||
|
sequencePlayerRenderersWrapper
|
||||||
|
.sequence
|
||||||
|
.editedMediaItems
|
||||||
|
.get(i)
|
||||||
|
.getPresentationDurationUs();
|
||||||
|
}
|
||||||
|
super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onProcessedStreamChange() {
|
||||||
|
super.onProcessedStreamChange();
|
||||||
|
onMediaItemChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException {
|
||||||
|
super.onPositionReset(positionUs, joining);
|
||||||
|
onMediaItemChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other methods
|
||||||
|
|
||||||
|
private void onMediaItemChanged() {
|
||||||
|
EditedMediaItem currentEditedMediaItem = checkStateNotNull(pendingEditedMediaItem);
|
||||||
|
// Use reference equality intentionally.
|
||||||
|
boolean isLastInSequence =
|
||||||
|
currentEditedMediaItem
|
||||||
|
== Iterables.getLast(sequencePlayerRenderersWrapper.sequence.editedMediaItems);
|
||||||
|
audioSink.onMediaItemChanged(
|
||||||
|
currentEditedMediaItem, pendingOffsetToCompositionTimeUs, isLastInSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SequenceVideoRenderer extends MediaCodecVideoRenderer {
|
||||||
|
private final SequencePlayerRenderersWrapper sequencePlayerRenderersWrapper;
|
||||||
|
private final VideoSink videoSink;
|
||||||
|
@Nullable private ImmutableList<Effect> pendingEffect;
|
||||||
|
|
||||||
|
public SequenceVideoRenderer(
|
||||||
|
Context context,
|
||||||
|
Handler eventHandler,
|
||||||
|
VideoRendererEventListener videoRendererEventListener,
|
||||||
|
SequencePlayerRenderersWrapper sequencePlayerRenderersWrapper) {
|
||||||
|
super(
|
||||||
|
context,
|
||||||
|
MediaCodecAdapter.Factory.getDefault(context),
|
||||||
|
MediaCodecSelector.DEFAULT,
|
||||||
|
DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS,
|
||||||
|
/* enableDecoderFallback= */ false,
|
||||||
|
eventHandler,
|
||||||
|
videoRendererEventListener,
|
||||||
|
MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY,
|
||||||
|
/* assumedMinimumCodecOperatingRate= */ DEFAULT_FRAME_RATE,
|
||||||
|
checkStateNotNull(sequencePlayerRenderersWrapper.compositingVideoSinkProvider));
|
||||||
|
this.sequencePlayerRenderersWrapper = sequencePlayerRenderersWrapper;
|
||||||
|
videoSink =
|
||||||
|
checkStateNotNull(sequencePlayerRenderersWrapper.compositingVideoSinkProvider).getSink();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStreamChanged(
|
||||||
|
Format[] formats,
|
||||||
|
long startPositionUs,
|
||||||
|
long offsetUs,
|
||||||
|
MediaSource.MediaPeriodId mediaPeriodId)
|
||||||
|
throws ExoPlaybackException {
|
||||||
|
checkState(getTimeline().getWindowCount() == 1);
|
||||||
|
super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId);
|
||||||
|
pendingEffect =
|
||||||
|
sequencePlayerRenderersWrapper.sequence.editedMediaItems.get(
|
||||||
|
getTimeline().getIndexOfPeriod(mediaPeriodId.periodUid))
|
||||||
|
.effects
|
||||||
|
.videoEffects;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onReadyToRegisterVideoSinkInputStream() {
|
||||||
|
@Nullable ImmutableList<Effect> pendingEffect = this.pendingEffect;
|
||||||
|
if (pendingEffect != null) {
|
||||||
|
videoSink.setPendingVideoEffects(pendingEffect);
|
||||||
|
this.pendingEffect = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SequenceImageRenderer extends ImageRenderer {
|
||||||
|
private final SequencePlayerRenderersWrapper sequencePlayerRenderersWrapper;
|
||||||
|
private final CompositingVideoSinkProvider compositingVideoSinkProvider;
|
||||||
|
private final VideoSink videoSink;
|
||||||
|
private final VideoFrameReleaseControl videoFrameReleaseControl;
|
||||||
|
|
||||||
|
private ImmutableList<Effect> videoEffects;
|
||||||
|
private @MonotonicNonNull ConstantRateTimestampIterator timestampIterator;
|
||||||
|
private boolean inputStreamPendingRegistration;
|
||||||
|
@Nullable private ExoPlaybackException pendingExoPlaybackException;
|
||||||
|
private long streamOffsetUs;
|
||||||
|
private boolean mayRenderStartOfStream;
|
||||||
|
|
||||||
|
public SequenceImageRenderer(SequencePlayerRenderersWrapper sequencePlayerRenderersWrapper) {
|
||||||
|
super(
|
||||||
|
checkStateNotNull(sequencePlayerRenderersWrapper.imageDecoderFactory), ImageOutput.NO_OP);
|
||||||
|
this.sequencePlayerRenderersWrapper = sequencePlayerRenderersWrapper;
|
||||||
|
compositingVideoSinkProvider =
|
||||||
|
checkStateNotNull(sequencePlayerRenderersWrapper.compositingVideoSinkProvider);
|
||||||
|
videoSink = compositingVideoSinkProvider.getSink();
|
||||||
|
videoFrameReleaseControl =
|
||||||
|
checkStateNotNull(compositingVideoSinkProvider.getVideoFrameReleaseControl());
|
||||||
|
videoEffects = ImmutableList.of();
|
||||||
|
streamOffsetUs = C.TIME_UNSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageRenderer methods
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onEnabled(boolean joining, boolean mayRenderStartOfStream)
|
||||||
|
throws ExoPlaybackException {
|
||||||
|
super.onEnabled(joining, mayRenderStartOfStream);
|
||||||
|
this.mayRenderStartOfStream = mayRenderStartOfStream;
|
||||||
|
videoFrameReleaseControl.onEnabled(mayRenderStartOfStream);
|
||||||
|
if (joining) {
|
||||||
|
videoFrameReleaseControl.join(/* renderNextFrameImmediately= */ false);
|
||||||
|
}
|
||||||
|
if (!videoSink.isInitialized()) {
|
||||||
|
Format format = new Format.Builder().build();
|
||||||
|
try {
|
||||||
|
videoSink.initialize(format, getClock());
|
||||||
|
} catch (VideoSink.VideoSinkException e) {
|
||||||
|
throw createRendererException(
|
||||||
|
e, format, PlaybackException.ERROR_CODE_VIDEO_FRAME_PROCESSOR_INIT_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO - b/328444280: Do not set a listener on VideoSink, but MediaCodecVideoRenderer must
|
||||||
|
// unregister itself as a listener too.
|
||||||
|
videoSink.setListener(VideoSink.Listener.NO_OP, /* executor= */ (runnable) -> {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDisabled() {
|
||||||
|
super.onDisabled();
|
||||||
|
videoFrameReleaseControl.onDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
// If the renderer was enabled with mayRenderStartOfStream set to false, meaning the image
|
||||||
|
// renderer is playing after a video, we don't need to wait until the first frame is rendered.
|
||||||
|
// If the renderer was enabled with mayRenderStartOfStream, we must wait until the first frame
|
||||||
|
// is rendered, which is checked by VideoSink.isReady().
|
||||||
|
return super.isReady() && (!mayRenderStartOfStream || videoSink.isReady());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onReset() {
|
||||||
|
super.onReset();
|
||||||
|
pendingExoPlaybackException = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException {
|
||||||
|
videoSink.flush();
|
||||||
|
super.onPositionReset(positionUs, joining);
|
||||||
|
videoFrameReleaseControl.reset();
|
||||||
|
if (joining) {
|
||||||
|
videoFrameReleaseControl.join(/* renderNextFrameImmediately= */ false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStarted() throws ExoPlaybackException {
|
||||||
|
super.onStarted();
|
||||||
|
videoFrameReleaseControl.onStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStopped() {
|
||||||
|
super.onStopped();
|
||||||
|
videoFrameReleaseControl.onStopped();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStreamChanged(
|
||||||
|
Format[] formats,
|
||||||
|
long startPositionUs,
|
||||||
|
long offsetUs,
|
||||||
|
MediaSource.MediaPeriodId mediaPeriodId)
|
||||||
|
throws ExoPlaybackException {
|
||||||
|
checkState(getTimeline().getWindowCount() == 1);
|
||||||
|
super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId);
|
||||||
|
streamOffsetUs = offsetUs;
|
||||||
|
EditedMediaItem editedMediaItem =
|
||||||
|
sequencePlayerRenderersWrapper.sequence.editedMediaItems.get(
|
||||||
|
getTimeline().getIndexOfPeriod(mediaPeriodId.periodUid));
|
||||||
|
videoEffects = editedMediaItem.effects.videoEffects;
|
||||||
|
timestampIterator =
|
||||||
|
new ConstantRateTimestampIterator(
|
||||||
|
editedMediaItem.getPresentationDurationUs(), /* frameRate= */ DEFAULT_FRAME_RATE);
|
||||||
|
inputStreamPendingRegistration = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
|
||||||
|
if (pendingExoPlaybackException != null) {
|
||||||
|
ExoPlaybackException exoPlaybackException = pendingExoPlaybackException;
|
||||||
|
pendingExoPlaybackException = null;
|
||||||
|
throw exoPlaybackException;
|
||||||
|
}
|
||||||
|
super.render(positionUs, elapsedRealtimeUs);
|
||||||
|
compositingVideoSinkProvider.render(positionUs, elapsedRealtimeUs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean processOutputBuffer(
|
||||||
|
long positionUs, long elapsedRealtimeUs, Bitmap outputImage, long timeUs) {
|
||||||
|
if (inputStreamPendingRegistration) {
|
||||||
|
checkState(streamOffsetUs != C.TIME_UNSET);
|
||||||
|
videoSink.setPendingVideoEffects(videoEffects);
|
||||||
|
videoSink.setStreamOffsetUs(streamOffsetUs);
|
||||||
|
videoSink.registerInputStream(
|
||||||
|
VideoSink.INPUT_TYPE_BITMAP,
|
||||||
|
new Format.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.IMAGE_RAW)
|
||||||
|
.setWidth(outputImage.getWidth())
|
||||||
|
.setHeight(outputImage.getHeight())
|
||||||
|
.setColorInfo(ColorInfo.SRGB_BT709_FULL)
|
||||||
|
.build());
|
||||||
|
videoFrameReleaseControl.setFrameRate(/* frameRate= */ DEFAULT_FRAME_RATE);
|
||||||
|
inputStreamPendingRegistration = false;
|
||||||
|
}
|
||||||
|
return videoSink.queueBitmap(outputImage, checkStateNotNull(timestampIterator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,326 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.transformer.TestUtil.ASSET_URI_PREFIX;
|
||||||
|
import static androidx.media3.transformer.TestUtil.FILE_AUDIO_RAW;
|
||||||
|
import static androidx.media3.transformer.TestUtil.FILE_AUDIO_RAW_STEREO_48000KHZ;
|
||||||
|
import static androidx.media3.transformer.TestUtil.createAudioEffects;
|
||||||
|
import static androidx.media3.transformer.TestUtil.createVolumeScalingAudioProcessor;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.Player;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink;
|
||||||
|
import androidx.media3.test.utils.CapturingAudioSink;
|
||||||
|
import androidx.media3.test.utils.DumpFileAsserts;
|
||||||
|
import androidx.media3.test.utils.FakeClock;
|
||||||
|
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper;
|
||||||
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio playback unit tests for {@link CompositionPlayer}.
|
||||||
|
*
|
||||||
|
* <p>These tests focus on audio because the video pipeline doesn't work in Robolectric.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public final class CompositionPlayerAudioPlaybackTest {
|
||||||
|
|
||||||
|
private final Context context = ApplicationProvider.getApplicationContext();
|
||||||
|
private CapturingAudioSink capturingAudioSink;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() throws Exception {
|
||||||
|
capturingAudioSink = new CapturingAudioSink(new DefaultAudioSink.Builder(context).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playback_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ))
|
||||||
|
.setDurationUs(348_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context, capturingAudioSink, "audiosinkdumps/wav/sample.wav_then_sample_rf64.wav.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playback_compositionWithEffects_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.setEffects(createAudioEffects(createVolumeScalingAudioProcessor(0.5f)))
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ))
|
||||||
|
.setDurationUs(348_000L)
|
||||||
|
.setEffects(createAudioEffects(createVolumeScalingAudioProcessor(2f)))
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context,
|
||||||
|
capturingAudioSink,
|
||||||
|
"audiosinkdumps/wav/sample.wav-lowVolume_then_sample_rf64.wav-highVolume.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playback_singleAudioItemWithEffects_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem audioEditedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setRemoveVideo(true)
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.setEffects(createAudioEffects(createVolumeScalingAudioProcessor(2f)))
|
||||||
|
.build();
|
||||||
|
Composition composition =
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(audioEditedMediaItem)).build();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context, capturingAudioSink, "audiosinkdumps/" + FILE_AUDIO_RAW + "/highVolume.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playback_singleAudioItemWithCompositionLevelEffects_outputsCorrectSamples()
|
||||||
|
throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem audioEditedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setRemoveVideo(true)
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
Composition composition =
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(audioEditedMediaItem))
|
||||||
|
.setEffects(createAudioEffects(createVolumeScalingAudioProcessor(2f)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context, capturingAudioSink, "audiosinkdumps/" + FILE_AUDIO_RAW + "/highVolume.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playback_compositionWithClipping_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
MediaItem mediaItem1 =
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW)
|
||||||
|
.setClippingConfiguration(
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setStartPositionMs(300)
|
||||||
|
.setEndPositionMs(800)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(mediaItem1).setDurationUs(1_000_000L).build();
|
||||||
|
MediaItem mediaItem2 =
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)
|
||||||
|
.setClippingConfiguration(
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setStartPositionMs(100)
|
||||||
|
.setEndPositionMs(300)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(mediaItem2).setDurationUs(348_000L).build();
|
||||||
|
Composition composition =
|
||||||
|
new Composition.Builder(new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context,
|
||||||
|
capturingAudioSink,
|
||||||
|
"audiosinkdumps/wav/sample.wav_clipped_then_sample_rf64_clipped.wav.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void seekTo_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem editedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence = new EditedMediaItemSequence(editedMediaItem);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
player.setComposition(composition);
|
||||||
|
|
||||||
|
player.seekTo(/* positionMs= */ 500);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context, capturingAudioSink, "audiosinkdumps/" + FILE_AUDIO_RAW + "/seek_to_500_ms.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void seekToNextMediaItem_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ))
|
||||||
|
.setDurationUs(348_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
player.setComposition(composition);
|
||||||
|
|
||||||
|
player.seekTo(/* positionMs= */ 1200);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context,
|
||||||
|
capturingAudioSink,
|
||||||
|
"audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_seek_to_1200_ms.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void seekToPreviousMediaItem_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ))
|
||||||
|
.setDurationUs(348_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
player.setComposition(composition);
|
||||||
|
|
||||||
|
player.seekTo(/* positionMs= */ 1200);
|
||||||
|
player.seekTo(/* positionMs= */ 500);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context,
|
||||||
|
capturingAudioSink,
|
||||||
|
"audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_seek_to_500_ms.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void seekTo_withClipping_outputsCorrectSamples() throws Exception {
|
||||||
|
CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink);
|
||||||
|
MediaItem mediaItem1 =
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW)
|
||||||
|
.setClippingConfiguration(
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setStartPositionMs(200)
|
||||||
|
.setEndPositionMs(900)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(mediaItem1).setDurationUs(1_000_000L).build();
|
||||||
|
MediaItem mediaItem2 =
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)
|
||||||
|
.setClippingConfiguration(
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setStartPositionMs(100)
|
||||||
|
.setEndPositionMs(300)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(mediaItem2).setDurationUs(348_000L).build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
player.setComposition(composition);
|
||||||
|
|
||||||
|
player.seekTo(/* positionMs= */ 800);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
DumpFileAsserts.assertOutput(
|
||||||
|
context,
|
||||||
|
capturingAudioSink,
|
||||||
|
"audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_clipped_seek_to_800_ms.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompositionPlayer createCompositionPlayer(Context context, AudioSink audioSink) {
|
||||||
|
return new CompositionPlayer.Builder(context)
|
||||||
|
.setClock(new FakeClock(/* isAutoAdvancing= */ true))
|
||||||
|
.setAudioSink(audioSink)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,592 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static androidx.media3.transformer.TestUtil.ASSET_URI_PREFIX;
|
||||||
|
import static androidx.media3.transformer.TestUtil.FILE_AUDIO_RAW;
|
||||||
|
import static androidx.media3.transformer.TestUtil.FILE_AUDIO_RAW_STEREO_48000KHZ;
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.SurfaceTexture;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.Surface;
|
||||||
|
import android.view.TextureView;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.Player;
|
||||||
|
import androidx.media3.common.Timeline;
|
||||||
|
import androidx.media3.common.util.ConditionVariable;
|
||||||
|
import androidx.media3.common.util.Log;
|
||||||
|
import androidx.media3.common.util.NullableType;
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink;
|
||||||
|
import androidx.media3.exoplayer.audio.ForwardingAudioSink;
|
||||||
|
import androidx.media3.test.utils.FakeClock;
|
||||||
|
import androidx.media3.test.utils.robolectric.RobolectricUtil;
|
||||||
|
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper;
|
||||||
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
/** Unit tests for {@link CompositionPlayer}. */
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class CompositionPlayerTest {
|
||||||
|
private static final long TEST_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void builder_buildCalledTwice_throws() {
|
||||||
|
CompositionPlayer.Builder builder =
|
||||||
|
new CompositionPlayer.Builder(ApplicationProvider.getApplicationContext());
|
||||||
|
|
||||||
|
CompositionPlayer player = builder.build();
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, builder::build);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void builder_buildCalledOnNonHandlerThread_throws() throws InterruptedException {
|
||||||
|
AtomicReference<@NullableType Exception> exception = new AtomicReference<>();
|
||||||
|
ConditionVariable conditionVariable = new ConditionVariable();
|
||||||
|
|
||||||
|
Thread thread =
|
||||||
|
new Thread(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
new Composition.Builder(ApplicationProvider.getApplicationContext()).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
exception.set(e);
|
||||||
|
} finally {
|
||||||
|
conditionVariable.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thread.start();
|
||||||
|
|
||||||
|
conditionVariable.block();
|
||||||
|
thread.join();
|
||||||
|
|
||||||
|
assertThat(exception.get()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void instance_accessedByWrongThread_throws() throws InterruptedException {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
AtomicReference<@NullableType RuntimeException> exception = new AtomicReference<>();
|
||||||
|
ConditionVariable conditionVariable = new ConditionVariable();
|
||||||
|
HandlerThread handlerThread = new HandlerThread("test");
|
||||||
|
handlerThread.start();
|
||||||
|
|
||||||
|
new Handler(handlerThread.getLooper())
|
||||||
|
.post(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
player.setComposition(buildComposition());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
exception.set(e);
|
||||||
|
} finally {
|
||||||
|
conditionVariable.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
conditionVariable.block();
|
||||||
|
player.release();
|
||||||
|
handlerThread.quit();
|
||||||
|
handlerThread.join();
|
||||||
|
|
||||||
|
assertThat(exception.get()).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(exception.get()).hasMessageThat().contains("Player is accessed on the wrong thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void instance_withSpecifiedApplicationLooper_callbacksDispatchedOnSpecifiedThread()
|
||||||
|
throws Exception {
|
||||||
|
HandlerThread applicationHandlerThread = new HandlerThread("app-thread");
|
||||||
|
applicationHandlerThread.start();
|
||||||
|
Looper applicationLooper = applicationHandlerThread.getLooper();
|
||||||
|
Handler applicationThreadHandler = new Handler(applicationLooper);
|
||||||
|
AtomicReference<Thread> callbackThread = new AtomicReference<>();
|
||||||
|
ConditionVariable eventsArrived = new ConditionVariable();
|
||||||
|
CompositionPlayer player =
|
||||||
|
createCompositionPlayerBuilder().setLooper(applicationLooper).build();
|
||||||
|
// Listeners can be added by any thread.
|
||||||
|
player.addListener(
|
||||||
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onEvents(Player player, Player.Events events) {
|
||||||
|
callbackThread.set(Thread.currentThread());
|
||||||
|
eventsArrived.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
applicationThreadHandler.post(
|
||||||
|
() -> {
|
||||||
|
player.setComposition(buildComposition());
|
||||||
|
player.prepare();
|
||||||
|
});
|
||||||
|
if (!eventsArrived.block(TEST_TIMEOUT_MS)) {
|
||||||
|
throw new TimeoutException();
|
||||||
|
}
|
||||||
|
// Use a separate condition variable to releasing the player to avoid race conditions
|
||||||
|
// with the condition variable used for the callback.
|
||||||
|
ConditionVariable released = new ConditionVariable();
|
||||||
|
applicationThreadHandler.post(
|
||||||
|
() -> {
|
||||||
|
player.release();
|
||||||
|
released.open();
|
||||||
|
});
|
||||||
|
if (!released.block(TEST_TIMEOUT_MS)) {
|
||||||
|
throw new TimeoutException();
|
||||||
|
}
|
||||||
|
applicationHandlerThread.quit();
|
||||||
|
applicationHandlerThread.join();
|
||||||
|
|
||||||
|
assertThat(eventsArrived.isOpen()).isTrue();
|
||||||
|
assertThat(callbackThread.get()).isEqualTo(applicationLooper.getThread());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void release_onNewlyCreateInstance() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void release_audioFailsDuringRelease_onlyLogsError() throws Exception {
|
||||||
|
Log.Logger logger = mock(Log.Logger.class);
|
||||||
|
Log.setLogger(logger);
|
||||||
|
AudioSink audioSink =
|
||||||
|
new ForwardingAudioSink(
|
||||||
|
new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build()) {
|
||||||
|
@Override
|
||||||
|
public void release() {
|
||||||
|
throw new RuntimeException("AudioSink release error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CompositionPlayer player = createCompositionPlayerBuilder().setAudioSink(audioSink).build();
|
||||||
|
Player.Listener listener = mock(Player.Listener.class);
|
||||||
|
player.addListener(listener);
|
||||||
|
|
||||||
|
player.setComposition(buildComposition());
|
||||||
|
player.prepare();
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_READY);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
verify(listener, never()).onPlayerError(any());
|
||||||
|
verify(logger)
|
||||||
|
.e(
|
||||||
|
eq("CompPlayerInternal"),
|
||||||
|
eq("error while releasing the player"),
|
||||||
|
argThat(
|
||||||
|
throwable ->
|
||||||
|
throwable instanceof RuntimeException
|
||||||
|
&& throwable.getMessage().contains("AudioSink release error")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getAvailableCommands_returnsSpecificCommands() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
assertThat(getList(player.getAvailableCommands()))
|
||||||
|
.containsExactly(
|
||||||
|
Player.COMMAND_PLAY_PAUSE,
|
||||||
|
Player.COMMAND_PREPARE,
|
||||||
|
Player.COMMAND_STOP,
|
||||||
|
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
||||||
|
Player.COMMAND_SEEK_BACK,
|
||||||
|
Player.COMMAND_SEEK_FORWARD,
|
||||||
|
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||||
|
Player.COMMAND_GET_TIMELINE,
|
||||||
|
Player.COMMAND_SET_VIDEO_SURFACE,
|
||||||
|
Player.COMMAND_RELEASE);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setComposition_calledTwice_throws() {
|
||||||
|
Composition composition = buildComposition();
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> player.setComposition(composition));
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setComposition_threeSequences_throws() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
EditedMediaItem editedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.EMPTY).setDurationUs(1_000).build();
|
||||||
|
Composition composition =
|
||||||
|
new Composition.Builder(
|
||||||
|
ImmutableList.of(
|
||||||
|
new EditedMediaItemSequence(editedMediaItem),
|
||||||
|
new EditedMediaItemSequence(editedMediaItem),
|
||||||
|
new EditedMediaItemSequence(editedMediaItem)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> player.setComposition(composition));
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setComposition_unmatchingDurations_throws() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
Composition composition =
|
||||||
|
new Composition.Builder(
|
||||||
|
ImmutableList.of(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.EMPTY).setDurationUs(1).build()),
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
new EditedMediaItem.Builder(MediaItem.EMPTY).setDurationUs(2).build())))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> player.setComposition(composition));
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void prepare_withoutCompositionSet_throws() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, player::prepare);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playWhenReady_calledBeforePrepare_startsPlayingAfterPrepareCalled() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
player.setPlayWhenReady(true);
|
||||||
|
player.setComposition(buildComposition());
|
||||||
|
player.prepare();
|
||||||
|
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void playWhenReady_triggersPlayWhenReadyCallbackWithReason() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
AtomicInteger playWhenReadyReason = new AtomicInteger(-1);
|
||||||
|
player.addListener(
|
||||||
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onPlayWhenReadyChanged(
|
||||||
|
boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
|
||||||
|
playWhenReadyReason.set(reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
player.setPlayWhenReady(true);
|
||||||
|
RobolectricUtil.runMainLooperUntil(() -> playWhenReadyReason.get() != -1);
|
||||||
|
|
||||||
|
assertThat(playWhenReadyReason.get())
|
||||||
|
.isEqualTo(Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoTextureView_throws() {
|
||||||
|
Context context = ApplicationProvider.getApplicationContext();
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
UnsupportedOperationException.class,
|
||||||
|
() -> player.setVideoTextureView(new TextureView(context)));
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setVideoSurface_withNonNullSurface_throws() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
Surface surface = new Surface(new SurfaceTexture(/* texName= */ 0));
|
||||||
|
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> player.setVideoSurface(surface));
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
surface.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void clearVideoSurface_specifiedSurfaceNotPreviouslySet_throws() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> player.clearVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 0))));
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getTotalBufferedDuration_playerStillIdle_returnsZero() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
assertThat(player.getTotalBufferedDuration()).isEqualTo(0);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getTotalBufferedDuration_setCompositionButNotPrepare_returnsZero() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
player.setComposition(buildComposition());
|
||||||
|
|
||||||
|
assertThat(player.getTotalBufferedDuration()).isEqualTo(0);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getTotalBufferedDuration_playerReady_returnsNonZero() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
player.setComposition(buildComposition());
|
||||||
|
player.prepare();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY);
|
||||||
|
|
||||||
|
assertThat(player.getTotalBufferedDuration()).isGreaterThan(0);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getDuration_withoutComposition_returnsTimeUnset() {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
|
||||||
|
assertThat(player.getDuration()).isEqualTo(C.TIME_UNSET);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getDuration_withComposition_returnsDuration() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
Composition composition = buildComposition();
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY);
|
||||||
|
|
||||||
|
// Refer to the durations in buildComposition().
|
||||||
|
assertThat(player.getDuration()).isEqualTo(1_348);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addListener_callsSupportedCallbacks() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
Composition composition = buildComposition();
|
||||||
|
List<Integer> playbackStates = new ArrayList<>();
|
||||||
|
AtomicBoolean playing = new AtomicBoolean();
|
||||||
|
Player.Listener listener =
|
||||||
|
spy(
|
||||||
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onPlaybackStateChanged(int playbackState) {
|
||||||
|
if (playbackStates.isEmpty()
|
||||||
|
|| Iterables.getLast(playbackStates) != playbackState) {
|
||||||
|
playbackStates.add(playbackState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIsPlayingChanged(boolean isPlaying) {
|
||||||
|
playing.set(isPlaying);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
InOrder inOrder = Mockito.inOrder(listener);
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.addListener(listener);
|
||||||
|
player.prepare();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY);
|
||||||
|
|
||||||
|
inOrder
|
||||||
|
.verify(listener)
|
||||||
|
.onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED));
|
||||||
|
inOrder.verify(listener).onPlaybackStateChanged(Player.STATE_BUFFERING);
|
||||||
|
inOrder.verify(listener).onPlaybackStateChanged(Player.STATE_READY);
|
||||||
|
|
||||||
|
player.setPlayWhenReady(true);
|
||||||
|
|
||||||
|
// Ensure that Player.Listener.onIsPlayingChanged(true) is called.
|
||||||
|
RobolectricUtil.runMainLooperUntil(playing::get);
|
||||||
|
inOrder
|
||||||
|
.verify(listener)
|
||||||
|
.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
|
||||||
|
inOrder.verify(listener).onIsPlayingChanged(true);
|
||||||
|
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
|
||||||
|
inOrder.verify(listener).onPlaybackStateChanged(Player.STATE_ENDED);
|
||||||
|
|
||||||
|
player.release();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||||
|
inOrder.verify(listener).onPlaybackStateChanged(Player.STATE_IDLE);
|
||||||
|
|
||||||
|
assertThat(playbackStates)
|
||||||
|
.containsExactly(
|
||||||
|
Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED, Player.STATE_IDLE)
|
||||||
|
.inOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addListener_callsOnEventsWithSupportedEvents() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
Composition composition = buildComposition();
|
||||||
|
Player.Listener mockListener = mock(Player.Listener.class);
|
||||||
|
ArgumentCaptor<Player.Events> eventsCaptor = ArgumentCaptor.forClass(Player.Events.class);
|
||||||
|
ImmutableSet<Integer> supportedEvents =
|
||||||
|
ImmutableSet.of(
|
||||||
|
Player.EVENT_TIMELINE_CHANGED,
|
||||||
|
Player.EVENT_MEDIA_ITEM_TRANSITION,
|
||||||
|
Player.EVENT_PLAYBACK_STATE_CHANGED,
|
||||||
|
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||||
|
Player.EVENT_IS_PLAYING_CHANGED);
|
||||||
|
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.addListener(mockListener);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
verify(mockListener, atLeastOnce()).onEvents(any(), eventsCaptor.capture());
|
||||||
|
List<Player.Events> eventsList = eventsCaptor.getAllValues();
|
||||||
|
for (Player.Events events : eventsList) {
|
||||||
|
assertThat(events.size()).isNotEqualTo(0);
|
||||||
|
for (int j = 0; j < events.size(); j++) {
|
||||||
|
assertThat(supportedEvents).contains(events.get(j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void play_withCorrectTimelineUpdated() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
Composition composition = buildComposition();
|
||||||
|
Player.Listener mockListener = mock(Player.Listener.class);
|
||||||
|
ArgumentCaptor<Timeline> timelineCaptor = ArgumentCaptor.forClass(Timeline.class);
|
||||||
|
ArgumentCaptor<Integer> timelineChangeReasonCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.addListener(mockListener);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
verify(mockListener)
|
||||||
|
.onTimelineChanged(timelineCaptor.capture(), timelineChangeReasonCaptor.capture());
|
||||||
|
assertThat(timelineCaptor.getAllValues()).hasSize(1);
|
||||||
|
assertThat(timelineChangeReasonCaptor.getAllValues()).hasSize(1);
|
||||||
|
Timeline timeline = timelineCaptor.getValue();
|
||||||
|
assertThat(timeline.getWindowCount()).isEqualTo(1);
|
||||||
|
assertThat(timeline.getPeriodCount()).isEqualTo(1);
|
||||||
|
// Refer to the durations in buildComposition().
|
||||||
|
assertThat(timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).durationUs)
|
||||||
|
.isEqualTo(1_348_000L);
|
||||||
|
assertThat(timelineChangeReasonCaptor.getValue())
|
||||||
|
.isEqualTo(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void seekPastDuration_ends() throws Exception {
|
||||||
|
CompositionPlayer player = buildCompositionPlayer();
|
||||||
|
EditedMediaItem editedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence = new EditedMediaItemSequence(editedMediaItem);
|
||||||
|
Composition composition = new Composition.Builder(sequence).build();
|
||||||
|
player.setComposition(composition);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
|
||||||
|
player.seekTo(/* positionMs= */ 1100);
|
||||||
|
TestPlayerRunHelper.run(player).untilState(Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompositionPlayer buildCompositionPlayer() {
|
||||||
|
return createCompositionPlayerBuilder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompositionPlayer.Builder createCompositionPlayerBuilder() {
|
||||||
|
return new CompositionPlayer.Builder(ApplicationProvider.getApplicationContext())
|
||||||
|
.setClock(new FakeClock(/* isAutoAdvancing= */ true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Composition buildComposition() {
|
||||||
|
// Use raw audio-only assets which can be played in robolectric tests.
|
||||||
|
EditedMediaItem editedMediaItem1 =
|
||||||
|
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW))
|
||||||
|
.setDurationUs(1_000_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem2 =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ))
|
||||||
|
.setDurationUs(348_000L)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2);
|
||||||
|
return new Composition.Builder(sequence).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> getList(Player.Commands commands) {
|
||||||
|
List<Integer> commandList = new ArrayList<>();
|
||||||
|
for (int i = 0; i < commands.size(); i++) {
|
||||||
|
commandList.add(commands.get(i));
|
||||||
|
}
|
||||||
|
return commandList;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.media3.transformer;
|
||||||
|
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static org.mockito.Mockito.atMostOnce;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.mockito.junit.MockitoJUnit;
|
||||||
|
import org.mockito.junit.MockitoRule;
|
||||||
|
|
||||||
|
/** Unit tests for {@link PreviewAudioPipeline}. */
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class PreviewAudioPipelineTest {
|
||||||
|
@Rule public final MockitoRule mockito = MockitoJUnit.rule();
|
||||||
|
|
||||||
|
private PreviewAudioPipeline previewAudioPipeline;
|
||||||
|
@Mock AudioSink outputAudioSink;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() {
|
||||||
|
previewAudioPipeline =
|
||||||
|
new PreviewAudioPipeline(
|
||||||
|
new DefaultAudioMixer.Factory(), /* effects= */ ImmutableList.of(), outputAudioSink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void tearDown() {
|
||||||
|
previewAudioPipeline.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void processData_noAudioSinksCreated_returnsFalse() throws Exception {
|
||||||
|
assertThat(previewAudioPipeline.processData()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void processData_audioSinkHasNotConfiguredYet_returnsFalse() throws Exception {
|
||||||
|
AudioGraphInputAudioSink unused = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
assertThat(previewAudioPipeline.processData()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inputPlay_withOneInput_playsOutputSink() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink.play();
|
||||||
|
|
||||||
|
verify(outputAudioSink).play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inputPause_withOneInput_pausesOutputSink() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink.play();
|
||||||
|
inputAudioSink.pause();
|
||||||
|
|
||||||
|
verify(outputAudioSink).pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inputReset_withOneInput_pausesOutputSink() {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink.play();
|
||||||
|
inputAudioSink.reset();
|
||||||
|
|
||||||
|
verify(outputAudioSink).pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inputPlay_whenPlaying_doesNotPlayOutputSink() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink = previewAudioPipeline.createInput();
|
||||||
|
inputAudioSink.play();
|
||||||
|
inputAudioSink.play();
|
||||||
|
|
||||||
|
verify(outputAudioSink, atMostOnce()).play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inputPause_whenNotPlaying_doesNotPauseOutputSink() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink.pause();
|
||||||
|
|
||||||
|
verify(outputAudioSink, never()).pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void someInputPlay_withMultipleInputs_doesNotPlayOutputSink() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink1 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink2 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink unused = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink1.play();
|
||||||
|
inputAudioSink2.play();
|
||||||
|
verify(outputAudioSink, never()).play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void allInputPlay_withMultipleInputs_playsOutputSinkOnce() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink1 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink2 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink3 = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink1.play();
|
||||||
|
inputAudioSink2.play();
|
||||||
|
inputAudioSink3.play();
|
||||||
|
|
||||||
|
verify(outputAudioSink, atMostOnce()).play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void firstInputPause_withMultipleInputs_pausesOutputSink() throws Exception {
|
||||||
|
InOrder inOrder = inOrder(outputAudioSink);
|
||||||
|
AudioGraphInputAudioSink inputAudioSink1 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink2 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink3 = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink1.play();
|
||||||
|
inputAudioSink2.play();
|
||||||
|
inputAudioSink3.play();
|
||||||
|
inputAudioSink2.pause();
|
||||||
|
|
||||||
|
inOrder.verify(outputAudioSink).pause();
|
||||||
|
inOrder.verifyNoMoreInteractions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void allInputPause_withMultipleInputs_pausesOutputSinkOnce() throws Exception {
|
||||||
|
AudioGraphInputAudioSink inputAudioSink1 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink2 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink3 = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink1.play();
|
||||||
|
inputAudioSink2.play();
|
||||||
|
inputAudioSink3.play();
|
||||||
|
inputAudioSink2.pause();
|
||||||
|
inputAudioSink1.pause();
|
||||||
|
inputAudioSink3.pause();
|
||||||
|
|
||||||
|
verify(outputAudioSink, atMostOnce()).pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void inputPlayAfterPause_withMultipleInputs_playsOutputSink() throws Exception {
|
||||||
|
InOrder inOrder = inOrder(outputAudioSink);
|
||||||
|
AudioGraphInputAudioSink inputAudioSink1 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink2 = previewAudioPipeline.createInput();
|
||||||
|
AudioGraphInputAudioSink inputAudioSink3 = previewAudioPipeline.createInput();
|
||||||
|
|
||||||
|
inputAudioSink1.play();
|
||||||
|
inputAudioSink2.play();
|
||||||
|
inputAudioSink3.play();
|
||||||
|
inputAudioSink2.pause();
|
||||||
|
inputAudioSink1.pause();
|
||||||
|
inputAudioSink2.play();
|
||||||
|
inputAudioSink1.play();
|
||||||
|
|
||||||
|
inOrder.verify(outputAudioSink).play();
|
||||||
|
inOrder.verify(outputAudioSink).pause();
|
||||||
|
inOrder.verify(outputAudioSink).play();
|
||||||
|
Mockito.verifyNoMoreInteractions(outputAudioSink);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user