Implement resume API in Transformer
Changes includes: 1. Add resume flow. 2. Change demo app code to resume export. 3. Changes in test infra to trigger resume. 4. E2E Test cases PiperOrigin-RevId: 579895744
This commit is contained in:
parent
414b72619b
commit
5db9a66b3b
@ -133,7 +133,8 @@ public final class TransformerActivity extends AppCompatActivity {
|
|||||||
@Nullable private ExoPlayer inputPlayer;
|
@Nullable private ExoPlayer inputPlayer;
|
||||||
@Nullable private ExoPlayer outputPlayer;
|
@Nullable private ExoPlayer outputPlayer;
|
||||||
@Nullable private Transformer transformer;
|
@Nullable private Transformer transformer;
|
||||||
@Nullable private File externalCacheFile;
|
@Nullable private File outputFile;
|
||||||
|
@Nullable private File oldOutputFile;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
@ -153,7 +154,7 @@ public final class TransformerActivity extends AppCompatActivity {
|
|||||||
cancelButton = findViewById(R.id.cancel_button);
|
cancelButton = findViewById(R.id.cancel_button);
|
||||||
cancelButton.setOnClickListener(this::cancelExport);
|
cancelButton.setOnClickListener(this::cancelExport);
|
||||||
resumeButton = findViewById(R.id.resume_button);
|
resumeButton = findViewById(R.id.resume_button);
|
||||||
resumeButton.setOnClickListener(this::resumeExport);
|
resumeButton.setOnClickListener(view -> startExport());
|
||||||
debugFrame = findViewById(R.id.debug_aspect_ratio_frame_layout);
|
debugFrame = findViewById(R.id.debug_aspect_ratio_frame_layout);
|
||||||
displayInputButton = findViewById(R.id.display_input_button);
|
displayInputButton = findViewById(R.id.display_input_button);
|
||||||
displayInputButton.setOnClickListener(this::toggleInputVideoDisplay);
|
displayInputButton.setOnClickListener(this::toggleInputVideoDisplay);
|
||||||
@ -195,8 +196,12 @@ public final class TransformerActivity extends AppCompatActivity {
|
|||||||
checkNotNull(outputPlayerView).onPause();
|
checkNotNull(outputPlayerView).onPause();
|
||||||
releasePlayer();
|
releasePlayer();
|
||||||
|
|
||||||
checkNotNull(externalCacheFile).delete();
|
checkNotNull(outputFile).delete();
|
||||||
externalCacheFile = null;
|
outputFile = null;
|
||||||
|
if (oldOutputFile != null) {
|
||||||
|
oldOutputFile.delete();
|
||||||
|
oldOutputFile = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startExport() {
|
private void startExport() {
|
||||||
@ -221,18 +226,23 @@ public final class TransformerActivity extends AppCompatActivity {
|
|||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
Uri inputUri = checkNotNull(intent.getData());
|
Uri inputUri = checkNotNull(intent.getData());
|
||||||
try {
|
try {
|
||||||
externalCacheFile =
|
outputFile =
|
||||||
createExternalCacheFile("transformer-output-" + Clock.DEFAULT.elapsedRealtime() + ".mp4");
|
createExternalCacheFile("transformer-output-" + Clock.DEFAULT.elapsedRealtime() + ".mp4");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IllegalStateException(e);
|
throw new IllegalStateException(e);
|
||||||
}
|
}
|
||||||
String filePath = externalCacheFile.getAbsolutePath();
|
String outputFilePath = outputFile.getAbsolutePath();
|
||||||
@Nullable Bundle bundle = intent.getExtras();
|
@Nullable Bundle bundle = intent.getExtras();
|
||||||
MediaItem mediaItem = createMediaItem(bundle, inputUri);
|
MediaItem mediaItem = createMediaItem(bundle, inputUri);
|
||||||
Transformer transformer = createTransformer(bundle, inputUri, filePath);
|
Transformer transformer = createTransformer(bundle, inputUri, outputFilePath);
|
||||||
Composition composition = createComposition(mediaItem, bundle);
|
Composition composition = createComposition(mediaItem, bundle);
|
||||||
|
exportStopwatch.reset();
|
||||||
exportStopwatch.start();
|
exportStopwatch.start();
|
||||||
transformer.start(composition, filePath);
|
if (oldOutputFile == null) {
|
||||||
|
transformer.start(composition, outputFilePath);
|
||||||
|
} else {
|
||||||
|
transformer.resume(composition, outputFilePath, oldOutputFile.getAbsolutePath());
|
||||||
|
}
|
||||||
this.transformer = transformer;
|
this.transformer = transformer;
|
||||||
displayInputButton.setVisibility(View.GONE);
|
displayInputButton.setVisibility(View.GONE);
|
||||||
inputCardView.setVisibility(View.GONE);
|
inputCardView.setVisibility(View.GONE);
|
||||||
@ -243,6 +253,7 @@ public final class TransformerActivity extends AppCompatActivity {
|
|||||||
progressViewGroup.setVisibility(View.VISIBLE);
|
progressViewGroup.setVisibility(View.VISIBLE);
|
||||||
cancelButton.setVisibility(View.VISIBLE);
|
cancelButton.setVisibility(View.VISIBLE);
|
||||||
resumeButton.setVisibility(View.GONE);
|
resumeButton.setVisibility(View.GONE);
|
||||||
|
progressIndicator.setProgress(0);
|
||||||
Handler mainHandler = new Handler(getMainLooper());
|
Handler mainHandler = new Handler(getMainLooper());
|
||||||
ProgressHolder progressHolder = new ProgressHolder();
|
ProgressHolder progressHolder = new ProgressHolder();
|
||||||
mainHandler.post(
|
mainHandler.post(
|
||||||
@ -834,12 +845,10 @@ public final class TransformerActivity extends AppCompatActivity {
|
|||||||
exportStopwatch.stop();
|
exportStopwatch.stop();
|
||||||
cancelButton.setVisibility(View.GONE);
|
cancelButton.setVisibility(View.GONE);
|
||||||
resumeButton.setVisibility(View.VISIBLE);
|
resumeButton.setVisibility(View.VISIBLE);
|
||||||
|
if (oldOutputFile != null) {
|
||||||
|
oldOutputFile.delete();
|
||||||
}
|
}
|
||||||
|
oldOutputFile = outputFile;
|
||||||
@RequiresNonNull({"exportStopwatch"})
|
|
||||||
private void resumeExport(View view) {
|
|
||||||
exportStopwatch.reset();
|
|
||||||
startExport();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class DemoDebugViewProvider implements DebugViewProvider {
|
private final class DemoDebugViewProvider implements DebugViewProvider {
|
||||||
|
@ -188,12 +188,28 @@ public class TransformerAndroidTestRunner {
|
|||||||
* @throws Exception The cause of the export not completing.
|
* @throws Exception The cause of the export not completing.
|
||||||
*/
|
*/
|
||||||
public ExportTestResult run(String testId, Composition composition) throws Exception {
|
public ExportTestResult run(String testId, Composition composition) throws Exception {
|
||||||
|
return run(testId, composition, /* oldFilePath= */ null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the {@link Composition}, saving a summary of the export to the application cache.
|
||||||
|
* Resumes exporting if the {@code oldFilePath} is specified.
|
||||||
|
*
|
||||||
|
* @param testId A unique identifier for the transformer test run.
|
||||||
|
* @param composition The {@link Composition} to export.
|
||||||
|
* @param oldFilePath The old output file path to resume the export from. Passing {@code null}
|
||||||
|
* will restart the export from the beginning.
|
||||||
|
* @return The {@link ExportTestResult}.
|
||||||
|
* @throws Exception The cause of the export not completing.
|
||||||
|
*/
|
||||||
|
public ExportTestResult run(String testId, Composition composition, @Nullable String oldFilePath)
|
||||||
|
throws Exception {
|
||||||
JSONObject resultJson = new JSONObject();
|
JSONObject resultJson = new JSONObject();
|
||||||
if (inputValues != null) {
|
if (inputValues != null) {
|
||||||
resultJson.put("inputValues", JSONObject.wrap(inputValues));
|
resultJson.put("inputValues", JSONObject.wrap(inputValues));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ExportTestResult exportTestResult = runInternal(testId, composition);
|
ExportTestResult exportTestResult = runInternal(testId, composition, oldFilePath);
|
||||||
resultJson.put("exportResult", exportTestResult.asJsonObject());
|
resultJson.put("exportResult", exportTestResult.asJsonObject());
|
||||||
if (exportTestResult.exportResult.exportException != null) {
|
if (exportTestResult.exportResult.exportException != null) {
|
||||||
throw exportTestResult.exportResult.exportException;
|
throw exportTestResult.exportResult.exportException;
|
||||||
@ -250,6 +266,8 @@ public class TransformerAndroidTestRunner {
|
|||||||
*
|
*
|
||||||
* @param testId An identifier for the test.
|
* @param testId An identifier for the test.
|
||||||
* @param composition The {@link Composition} to export.
|
* @param composition The {@link Composition} to export.
|
||||||
|
* @param oldFilePath The old output file path to resume the export from. Passing {@code null}
|
||||||
|
* will restart the export from the beginning.
|
||||||
* @return The {@link ExportTestResult}.
|
* @return The {@link ExportTestResult}.
|
||||||
* @throws IllegalStateException See {@link Transformer#start(Composition, String)}.
|
* @throws IllegalStateException See {@link Transformer#start(Composition, String)}.
|
||||||
* @throws InterruptedException If the thread is interrupted whilst waiting for transformer to
|
* @throws InterruptedException If the thread is interrupted whilst waiting for transformer to
|
||||||
@ -258,7 +276,8 @@ public class TransformerAndroidTestRunner {
|
|||||||
* @throws TimeoutException If the export has not completed after {@linkplain
|
* @throws TimeoutException If the export has not completed after {@linkplain
|
||||||
* Builder#setTimeoutSeconds(int) the given timeout}.
|
* Builder#setTimeoutSeconds(int) the given timeout}.
|
||||||
*/
|
*/
|
||||||
private ExportTestResult runInternal(String testId, Composition composition)
|
private ExportTestResult runInternal(
|
||||||
|
String testId, Composition composition, @Nullable String oldFilePath)
|
||||||
throws InterruptedException, IOException, TimeoutException {
|
throws InterruptedException, IOException, TimeoutException {
|
||||||
if (requestCalculateSsim) {
|
if (requestCalculateSsim) {
|
||||||
checkArgument(
|
checkArgument(
|
||||||
@ -347,7 +366,12 @@ public class TransformerAndroidTestRunner {
|
|||||||
.runOnMainSync(
|
.runOnMainSync(
|
||||||
() -> {
|
() -> {
|
||||||
try {
|
try {
|
||||||
|
if (oldFilePath == null) {
|
||||||
testTransformer.start(composition, outputVideoFile.getAbsolutePath());
|
testTransformer.start(composition, outputVideoFile.getAbsolutePath());
|
||||||
|
} else {
|
||||||
|
testTransformer.resume(
|
||||||
|
composition, outputVideoFile.getAbsolutePath(), oldFilePath);
|
||||||
|
}
|
||||||
// Catch all exceptions to report. Exceptions thrown here and not caught will NOT
|
// Catch all exceptions to report. Exceptions thrown here and not caught will NOT
|
||||||
// propagate.
|
// propagate.
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
@ -0,0 +1,477 @@
|
|||||||
|
/*
|
||||||
|
* 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.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_FORMAT;
|
||||||
|
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING;
|
||||||
|
import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.Effect;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.Metadata;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
import androidx.media3.common.audio.AudioProcessor;
|
||||||
|
import androidx.media3.common.audio.SonicAudioProcessor;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import androidx.media3.effect.RgbFilter;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import com.google.common.base.Ascii;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/** End-to-end instrumentation tests for {@link Transformer} pause and resume scenarios. */
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class TransformerPauseResumeTest {
|
||||||
|
@Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
private static final long DEFAULT_PRESENTATION_TIME_US_TO_BLOCK_FRAME = 5_000_000L;
|
||||||
|
private static final int DEFAULT_TIMEOUT_SECONDS = 120;
|
||||||
|
private static final int MP4_ASSET_FRAME_COUNT = 932;
|
||||||
|
|
||||||
|
private final Context context = getApplicationContext();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resume_withSingleMediaItem_outputMatchesExpected() throws Exception {
|
||||||
|
String testId = "resume_withSingleMediaItem_outputMatchesExpected";
|
||||||
|
if (shouldSkipDevice(testId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composition composition =
|
||||||
|
buildSingleSequenceComposition(
|
||||||
|
/* clippingStartPositionMs= */ 0,
|
||||||
|
/* clippingEndPositionMs= */ C.TIME_END_OF_SOURCE,
|
||||||
|
/* mediaItemsInSequence= */ 1);
|
||||||
|
CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||||
|
Transformer blockingTransformer = buildBlockingTransformer(countDownLatch::countDown);
|
||||||
|
String firstOutputPath = temporaryFolder.newFile("FirstOutput.mp4").getAbsolutePath();
|
||||||
|
InstrumentationRegistry.getInstrumentation()
|
||||||
|
.runOnMainSync(() -> blockingTransformer.start(composition, firstOutputPath));
|
||||||
|
// Block here until timeout reached or latch is counted down.
|
||||||
|
if (!countDownLatch.await(DEFAULT_TIMEOUT_SECONDS, SECONDS)) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"Transformer timed out after " + DEFAULT_TIMEOUT_SECONDS + " seconds.");
|
||||||
|
}
|
||||||
|
InstrumentationRegistry.getInstrumentation().runOnMainSync(blockingTransformer::cancel);
|
||||||
|
TransformerAndroidTestRunner testRunner =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Resume the export.
|
||||||
|
ExportResult exportResult = testRunner.run(testId, composition, firstOutputPath).exportResult;
|
||||||
|
|
||||||
|
assertThat(exportResult.processedInputs).hasSize(4);
|
||||||
|
assertThat(exportResult.videoFrameCount)
|
||||||
|
.isEqualTo(MP4_ASSET_FRAME_COUNT - getDeviceSpecificMissingFrameCount());
|
||||||
|
// The first processed media item corresponds to remuxing previous output video.
|
||||||
|
assertThat(exportResult.processedInputs.get(0).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(0).videoDecoderName).isNull();
|
||||||
|
// The second processed media item corresponds to processing remaining video.
|
||||||
|
assertThat(exportResult.processedInputs.get(1).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(1).videoDecoderName).isNotEmpty();
|
||||||
|
assertThat(exportResult.processedInputs.get(1).mediaItem.clippingConfiguration.startPositionMs)
|
||||||
|
.isGreaterThan(0);
|
||||||
|
// The third processed media item corresponds to processing audio.
|
||||||
|
assertThat(exportResult.processedInputs.get(2).audioDecoderName).isNotEmpty();
|
||||||
|
assertThat(exportResult.processedInputs.get(2).videoDecoderName).isNull();
|
||||||
|
// The fourth processed media item corresponds to transmuxing processed video.
|
||||||
|
assertThat(exportResult.processedInputs.get(3).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(3).videoDecoderName).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resume_withSingleMediaItemAfterImmediateCancellation_restartsExport()
|
||||||
|
throws Exception {
|
||||||
|
String testId = "resume_withSingleMediaItemAfterImmediateCancellation_restartsExport";
|
||||||
|
if (shouldSkipDevice(testId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composition composition =
|
||||||
|
buildSingleSequenceComposition(
|
||||||
|
/* clippingStartPositionMs= */ 0,
|
||||||
|
/* clippingEndPositionMs= */ C.TIME_END_OF_SOURCE,
|
||||||
|
/* mediaItemsInSequence= */ 1);
|
||||||
|
Transformer transformer = new Transformer.Builder(context).build();
|
||||||
|
String firstOutputPath = temporaryFolder.newFile("FirstOutput.mp4").getAbsolutePath();
|
||||||
|
InstrumentationRegistry.getInstrumentation()
|
||||||
|
.runOnMainSync(
|
||||||
|
() -> {
|
||||||
|
transformer.start(composition, firstOutputPath);
|
||||||
|
transformer.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume the export.
|
||||||
|
ExportResult exportResult =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, transformer)
|
||||||
|
.build()
|
||||||
|
.run(testId, composition, firstOutputPath)
|
||||||
|
.exportResult;
|
||||||
|
|
||||||
|
// The first export did not progress because of the immediate cancellation hence resuming
|
||||||
|
// actually restarts the export.
|
||||||
|
assertThat(exportResult.processedInputs).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resume_withSingleMediaItem_outputMatchesWithoutResume() throws Exception {
|
||||||
|
String testId = "resume_withSingleMediaItem_outputMatchesWithoutResume";
|
||||||
|
if (shouldSkipDevice(testId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composition composition =
|
||||||
|
buildSingleSequenceComposition(
|
||||||
|
/* clippingStartPositionMs= */ 0,
|
||||||
|
/* clippingEndPositionMs= */ C.TIME_END_OF_SOURCE,
|
||||||
|
/* mediaItemsInSequence= */ 1);
|
||||||
|
// Export without resume.
|
||||||
|
ExportResult exportResultWithoutResume =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build()
|
||||||
|
.run(testId, composition)
|
||||||
|
.exportResult;
|
||||||
|
// Export with resume.
|
||||||
|
CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||||
|
Transformer blockingTransformer = buildBlockingTransformer(countDownLatch::countDown);
|
||||||
|
String firstOutputPath = temporaryFolder.newFile("FirstOutput.mp4").getAbsolutePath();
|
||||||
|
InstrumentationRegistry.getInstrumentation()
|
||||||
|
.runOnMainSync(() -> blockingTransformer.start(composition, firstOutputPath));
|
||||||
|
// Block here until timeout reached or latch is counted down.
|
||||||
|
if (!countDownLatch.await(DEFAULT_TIMEOUT_SECONDS, SECONDS)) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"Transformer timed out after " + DEFAULT_TIMEOUT_SECONDS + " seconds.");
|
||||||
|
}
|
||||||
|
InstrumentationRegistry.getInstrumentation().runOnMainSync(blockingTransformer::cancel);
|
||||||
|
|
||||||
|
// Resume the export.
|
||||||
|
ExportResult exportResultWithResume =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build()
|
||||||
|
.run(testId, composition, firstOutputPath)
|
||||||
|
.exportResult;
|
||||||
|
|
||||||
|
assertThat(exportResultWithResume.processedInputs).hasSize(4);
|
||||||
|
assertThat(exportResultWithResume.audioEncoderName)
|
||||||
|
.isEqualTo(exportResultWithoutResume.audioEncoderName);
|
||||||
|
assertThat(exportResultWithResume.videoEncoderName)
|
||||||
|
.isEqualTo(exportResultWithoutResume.videoEncoderName);
|
||||||
|
assertThat(exportResultWithResume.videoFrameCount)
|
||||||
|
.isEqualTo(
|
||||||
|
exportResultWithoutResume.videoFrameCount - getDeviceSpecificMissingFrameCount());
|
||||||
|
// TODO: b/306595508 - Remove this expected difference once inconsistent behaviour of audio
|
||||||
|
// encoder is fixed.
|
||||||
|
int maxDiffExpectedInDurationMs = 2;
|
||||||
|
assertThat(exportResultWithResume.durationMs - exportResultWithoutResume.durationMs)
|
||||||
|
.isLessThan(maxDiffExpectedInDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resume_withSingleMediaItemHavingClippingConfig_outputMatchesWithoutResume()
|
||||||
|
throws Exception {
|
||||||
|
String testId = "resume_withSingleMediaItemHavingClippingConfig_outputMatchesWithoutResume";
|
||||||
|
if (shouldSkipDevice(testId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composition composition =
|
||||||
|
buildSingleSequenceComposition(
|
||||||
|
/* clippingStartPositionMs= */ 2_000L,
|
||||||
|
/* clippingEndPositionMs= */ 13_000L,
|
||||||
|
/* mediaItemsInSequence= */ 1);
|
||||||
|
// Export without resume.
|
||||||
|
ExportResult exportResultWithoutResume =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build()
|
||||||
|
.run(testId, composition)
|
||||||
|
.exportResult;
|
||||||
|
// Export with resume.
|
||||||
|
CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||||
|
Transformer blockingTransformer = buildBlockingTransformer(countDownLatch::countDown);
|
||||||
|
String firstOutputPath = temporaryFolder.newFile("FirstOutput.mp4").getAbsolutePath();
|
||||||
|
InstrumentationRegistry.getInstrumentation()
|
||||||
|
.runOnMainSync(() -> blockingTransformer.start(composition, firstOutputPath));
|
||||||
|
// Block here until timeout reached or latch is counted down.
|
||||||
|
if (!countDownLatch.await(DEFAULT_TIMEOUT_SECONDS, SECONDS)) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"Transformer timed out after " + DEFAULT_TIMEOUT_SECONDS + " seconds.");
|
||||||
|
}
|
||||||
|
InstrumentationRegistry.getInstrumentation().runOnMainSync(blockingTransformer::cancel);
|
||||||
|
|
||||||
|
// Resume the export.
|
||||||
|
ExportResult exportResultWithResume =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build()
|
||||||
|
.run(testId, composition, firstOutputPath)
|
||||||
|
.exportResult;
|
||||||
|
|
||||||
|
assertThat(exportResultWithResume.processedInputs).hasSize(4);
|
||||||
|
assertThat(exportResultWithResume.audioEncoderName)
|
||||||
|
.isEqualTo(exportResultWithoutResume.audioEncoderName);
|
||||||
|
assertThat(exportResultWithResume.videoEncoderName)
|
||||||
|
.isEqualTo(exportResultWithoutResume.videoEncoderName);
|
||||||
|
assertThat(exportResultWithResume.videoFrameCount)
|
||||||
|
.isEqualTo(
|
||||||
|
exportResultWithoutResume.videoFrameCount - getDeviceSpecificMissingFrameCount());
|
||||||
|
int maxDiffExpectedInDurationMs = 2;
|
||||||
|
assertThat(exportResultWithResume.durationMs - exportResultWithoutResume.durationMs)
|
||||||
|
.isLessThan(maxDiffExpectedInDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resume_withTwoMediaItems_outputMatchesExpected() throws Exception {
|
||||||
|
String testId = "resume_withTwoMediaItems_outputMatchesExpected";
|
||||||
|
if (shouldSkipDevice(testId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composition composition =
|
||||||
|
buildSingleSequenceComposition(
|
||||||
|
/* clippingStartPositionMs= */ 0,
|
||||||
|
/* clippingEndPositionMs= */ C.TIME_END_OF_SOURCE,
|
||||||
|
/* mediaItemsInSequence= */ 2);
|
||||||
|
CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||||
|
Transformer blockingTransformer = buildBlockingTransformer(countDownLatch::countDown);
|
||||||
|
String firstOutputPath = temporaryFolder.newFile("FirstOutput.mp4").getAbsolutePath();
|
||||||
|
InstrumentationRegistry.getInstrumentation()
|
||||||
|
.runOnMainSync(() -> blockingTransformer.start(composition, firstOutputPath));
|
||||||
|
// Block here until timeout reached or latch is counted down.
|
||||||
|
if (!countDownLatch.await(DEFAULT_TIMEOUT_SECONDS, SECONDS)) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"Transformer timed out after " + DEFAULT_TIMEOUT_SECONDS + " seconds.");
|
||||||
|
}
|
||||||
|
InstrumentationRegistry.getInstrumentation().runOnMainSync(blockingTransformer::cancel);
|
||||||
|
TransformerAndroidTestRunner testRunner =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ExportResult exportResult = testRunner.run(testId, composition, firstOutputPath).exportResult;
|
||||||
|
|
||||||
|
assertThat(exportResult.processedInputs).hasSize(6);
|
||||||
|
int expectedVideoFrameCount = MP4_ASSET_FRAME_COUNT * 2 - getDeviceSpecificMissingFrameCount();
|
||||||
|
assertThat(exportResult.videoFrameCount).isEqualTo(expectedVideoFrameCount);
|
||||||
|
// The first processed media item corresponds to remuxing previous output video.
|
||||||
|
assertThat(exportResult.processedInputs.get(0).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(0).videoDecoderName).isNull();
|
||||||
|
// The next two processed media item corresponds to processing remaining video.
|
||||||
|
assertThat(exportResult.processedInputs.get(1).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(1).videoDecoderName).isNotEmpty();
|
||||||
|
assertThat(exportResult.processedInputs.get(1).mediaItem.clippingConfiguration.startPositionMs)
|
||||||
|
.isGreaterThan(0);
|
||||||
|
assertThat(exportResult.processedInputs.get(2).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(2).videoDecoderName).isNotEmpty();
|
||||||
|
// The next two processed media item corresponds to processing audio.
|
||||||
|
assertThat(exportResult.processedInputs.get(3).audioDecoderName).isNotEmpty();
|
||||||
|
assertThat(exportResult.processedInputs.get(3).videoDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(4).audioDecoderName).isNotEmpty();
|
||||||
|
assertThat(exportResult.processedInputs.get(4).videoDecoderName).isNull();
|
||||||
|
// The last processed media item corresponds to transmuxing processed video.
|
||||||
|
assertThat(exportResult.processedInputs.get(5).audioDecoderName).isNull();
|
||||||
|
assertThat(exportResult.processedInputs.get(5).videoDecoderName).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resume_withTwoMediaItems_outputMatchesWithoutResume() throws Exception {
|
||||||
|
String testId = "resume_withTwoMediaItems_outputMatchesWithoutResume";
|
||||||
|
if (shouldSkipDevice(testId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Composition composition =
|
||||||
|
buildSingleSequenceComposition(
|
||||||
|
/* clippingStartPositionMs= */ 0,
|
||||||
|
/* clippingEndPositionMs= */ C.TIME_END_OF_SOURCE,
|
||||||
|
/* mediaItemsInSequence= */ 2);
|
||||||
|
// Export without resume.
|
||||||
|
ExportResult exportResultWithoutResume =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build()
|
||||||
|
.run(testId, composition)
|
||||||
|
.exportResult;
|
||||||
|
// Export with resume.
|
||||||
|
CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||||
|
Transformer blockingTransformer = buildBlockingTransformer(countDownLatch::countDown);
|
||||||
|
String firstOutputPath = temporaryFolder.newFile("FirstOutput.mp4").getAbsolutePath();
|
||||||
|
InstrumentationRegistry.getInstrumentation()
|
||||||
|
.runOnMainSync(() -> blockingTransformer.start(composition, firstOutputPath));
|
||||||
|
// Block here until timeout reached or latch is counted down.
|
||||||
|
if (!countDownLatch.await(DEFAULT_TIMEOUT_SECONDS, SECONDS)) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"Transformer timed out after " + DEFAULT_TIMEOUT_SECONDS + " seconds.");
|
||||||
|
}
|
||||||
|
InstrumentationRegistry.getInstrumentation().runOnMainSync(blockingTransformer::cancel);
|
||||||
|
TransformerAndroidTestRunner testRunner =
|
||||||
|
new TransformerAndroidTestRunner.Builder(context, new Transformer.Builder(context).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ExportResult exportResultWithResume =
|
||||||
|
testRunner.run(testId, composition, firstOutputPath).exportResult;
|
||||||
|
|
||||||
|
assertThat(exportResultWithResume.processedInputs).hasSize(6);
|
||||||
|
assertThat(exportResultWithResume.audioEncoderName)
|
||||||
|
.isEqualTo(exportResultWithoutResume.audioEncoderName);
|
||||||
|
assertThat(exportResultWithResume.videoEncoderName)
|
||||||
|
.isEqualTo(exportResultWithoutResume.videoEncoderName);
|
||||||
|
assertThat(exportResultWithResume.videoFrameCount)
|
||||||
|
.isEqualTo(
|
||||||
|
exportResultWithoutResume.videoFrameCount - getDeviceSpecificMissingFrameCount());
|
||||||
|
int maxDiffExpectedInDurationMs = 2;
|
||||||
|
assertThat(exportResultWithResume.durationMs - exportResultWithoutResume.durationMs)
|
||||||
|
.isLessThan(maxDiffExpectedInDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Composition buildSingleSequenceComposition(
|
||||||
|
long clippingStartPositionMs, long clippingEndPositionMs, int mediaItemsInSequence) {
|
||||||
|
SonicAudioProcessor sonic = new SonicAudioProcessor();
|
||||||
|
sonic.setPitch(/* pitch= */ 2f);
|
||||||
|
ImmutableList<AudioProcessor> audioEffects = ImmutableList.of(sonic);
|
||||||
|
|
||||||
|
ImmutableList<Effect> videoEffects = ImmutableList.of(RgbFilter.createInvertedFilter());
|
||||||
|
|
||||||
|
EditedMediaItem editedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING)
|
||||||
|
.setClippingConfiguration(
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setStartPositionMs(clippingStartPositionMs)
|
||||||
|
.setEndPositionMs(clippingEndPositionMs)
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
.setEffects(new Effects(audioEffects, videoEffects))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
List<EditedMediaItem> editedMediaItemList = new ArrayList<>();
|
||||||
|
while (mediaItemsInSequence-- > 0) {
|
||||||
|
editedMediaItemList.add(editedMediaItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Composition.Builder(new EditedMediaItemSequence(editedMediaItemList)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transformer buildBlockingTransformer(FrameBlockingMuxer.Listener listener) {
|
||||||
|
return new Transformer.Builder(getApplicationContext())
|
||||||
|
.setMuxerFactory(new FrameBlockingMuxerFactory(listener))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean shouldSkipDevice(String testId) throws Exception {
|
||||||
|
// v26 emulators are not producing I-frames, due to which resuming export does not work as
|
||||||
|
// expected.
|
||||||
|
return AndroidTestUtil.skipAndLogIfFormatsUnsupported(
|
||||||
|
getApplicationContext(),
|
||||||
|
testId,
|
||||||
|
/* inputFormat= */ MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_FORMAT,
|
||||||
|
/* outputFormat= */ MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_FORMAT)
|
||||||
|
|| (Util.SDK_INT == 26
|
||||||
|
&& (Ascii.toLowerCase(Util.DEVICE).contains("emulator")
|
||||||
|
|| Ascii.toLowerCase(Util.DEVICE).contains("generic")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int getDeviceSpecificMissingFrameCount() {
|
||||||
|
// TODO: b/307700189 - Remove this after investigating pause/resume behaviour with B-frames.
|
||||||
|
return (Util.SDK_INT == 27
|
||||||
|
&& (Ascii.equalsIgnoreCase(Util.MODEL, "asus_x00td")
|
||||||
|
|| Ascii.equalsIgnoreCase(Util.MODEL, "tc77")))
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FrameBlockingMuxerFactory implements Muxer.Factory {
|
||||||
|
private final Muxer.Factory wrappedMuxerFactory;
|
||||||
|
private final FrameBlockingMuxer.Listener listener;
|
||||||
|
|
||||||
|
public FrameBlockingMuxerFactory(FrameBlockingMuxer.Listener listener) {
|
||||||
|
this.wrappedMuxerFactory = new DefaultMuxer.Factory();
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Muxer create(String path) throws Muxer.MuxerException {
|
||||||
|
return new FrameBlockingMuxer(wrappedMuxerFactory.create(path), listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImmutableList<String> getSupportedSampleMimeTypes(@C.TrackType int trackType) {
|
||||||
|
return wrappedMuxerFactory.getSupportedSampleMimeTypes(trackType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FrameBlockingMuxer implements Muxer {
|
||||||
|
interface Listener {
|
||||||
|
void onFrameBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Muxer wrappedMuxer;
|
||||||
|
private final FrameBlockingMuxer.Listener listener;
|
||||||
|
|
||||||
|
private boolean notifiedListener;
|
||||||
|
private int videoTrackIndex;
|
||||||
|
|
||||||
|
private FrameBlockingMuxer(Muxer wrappedMuxer, FrameBlockingMuxer.Listener listener) {
|
||||||
|
this.wrappedMuxer = wrappedMuxer;
|
||||||
|
this.listener = listener;
|
||||||
|
videoTrackIndex = C.INDEX_UNSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int addTrack(Format format) throws MuxerException {
|
||||||
|
int trackIndex = wrappedMuxer.addTrack(format);
|
||||||
|
if (MimeTypes.isVideo(format.sampleMimeType)) {
|
||||||
|
videoTrackIndex = trackIndex;
|
||||||
|
}
|
||||||
|
return trackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeSampleData(
|
||||||
|
int trackIndex, ByteBuffer data, long presentationTimeUs, @C.BufferFlags int flags)
|
||||||
|
throws MuxerException {
|
||||||
|
if (trackIndex == videoTrackIndex
|
||||||
|
&& presentationTimeUs >= DEFAULT_PRESENTATION_TIME_US_TO_BLOCK_FRAME) {
|
||||||
|
if (!notifiedListener) {
|
||||||
|
listener.onFrameBlocked();
|
||||||
|
notifiedListener = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrappedMuxer.writeSampleData(trackIndex, data, presentationTimeUs, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addMetadata(Metadata metadata) {
|
||||||
|
wrappedMuxer.addMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void release(boolean forCancellation) throws MuxerException {
|
||||||
|
wrappedMuxer.release(forCancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getMaxDelayBetweenSamplesMs() {
|
||||||
|
return wrappedMuxer.getMaxDelayBetweenSamplesMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -24,6 +24,7 @@ import androidx.media3.common.MediaItem;
|
|||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
|
||||||
@ -32,7 +33,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
|||||||
public final class ExportResult {
|
public final class ExportResult {
|
||||||
/** A builder for {@link ExportResult} instances. */
|
/** A builder for {@link ExportResult} instances. */
|
||||||
public static final class Builder {
|
public static final class Builder {
|
||||||
private ImmutableList<ProcessedInput> processedInputs;
|
private ImmutableList.Builder<ProcessedInput> processedInputsBuilder;
|
||||||
private long durationMs;
|
private long durationMs;
|
||||||
private long fileSizeBytes;
|
private long fileSizeBytes;
|
||||||
private int averageAudioBitrate;
|
private int averageAudioBitrate;
|
||||||
@ -48,22 +49,15 @@ public final class ExportResult {
|
|||||||
@Nullable private ExportException exportException;
|
@Nullable private ExportException exportException;
|
||||||
|
|
||||||
/** Creates a builder. */
|
/** Creates a builder. */
|
||||||
|
@SuppressWarnings({"initialization.fields.uninitialized", "nullness:method.invocation"})
|
||||||
public Builder() {
|
public Builder() {
|
||||||
processedInputs = ImmutableList.of();
|
reset();
|
||||||
durationMs = C.TIME_UNSET;
|
|
||||||
fileSizeBytes = C.LENGTH_UNSET;
|
|
||||||
averageAudioBitrate = C.RATE_UNSET_INT;
|
|
||||||
channelCount = C.LENGTH_UNSET;
|
|
||||||
sampleRate = C.RATE_UNSET_INT;
|
|
||||||
averageVideoBitrate = C.RATE_UNSET_INT;
|
|
||||||
height = C.LENGTH_UNSET;
|
|
||||||
width = C.LENGTH_UNSET;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the {@linkplain ProcessedInput processed inputs}. */
|
/** Adds {@linkplain ProcessedInput processed inputs} to the {@link ProcessedInput} list. */
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
public Builder setProcessedInputs(ImmutableList<ProcessedInput> processedInputs) {
|
public Builder addProcessedInputs(List<ProcessedInput> processedInputs) {
|
||||||
this.processedInputs = processedInputs;
|
this.processedInputsBuilder.addAll(processedInputs);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,7 +202,7 @@ public final class ExportResult {
|
|||||||
/** Builds an {@link ExportResult} instance. */
|
/** Builds an {@link ExportResult} instance. */
|
||||||
public ExportResult build() {
|
public ExportResult build() {
|
||||||
return new ExportResult(
|
return new ExportResult(
|
||||||
processedInputs,
|
processedInputsBuilder.build(),
|
||||||
durationMs,
|
durationMs,
|
||||||
fileSizeBytes,
|
fileSizeBytes,
|
||||||
averageAudioBitrate,
|
averageAudioBitrate,
|
||||||
@ -223,6 +217,24 @@ public final class ExportResult {
|
|||||||
videoEncoderName,
|
videoEncoderName,
|
||||||
exportException);
|
exportException);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resets all the fields to their default values. */
|
||||||
|
public void reset() {
|
||||||
|
processedInputsBuilder = new ImmutableList.Builder<>();
|
||||||
|
durationMs = C.TIME_UNSET;
|
||||||
|
fileSizeBytes = C.LENGTH_UNSET;
|
||||||
|
averageAudioBitrate = C.RATE_UNSET_INT;
|
||||||
|
channelCount = C.LENGTH_UNSET;
|
||||||
|
sampleRate = C.RATE_UNSET_INT;
|
||||||
|
audioEncoderName = null;
|
||||||
|
averageVideoBitrate = C.RATE_UNSET_INT;
|
||||||
|
colorInfo = null;
|
||||||
|
height = C.LENGTH_UNSET;
|
||||||
|
width = C.LENGTH_UNSET;
|
||||||
|
videoFrameCount = 0;
|
||||||
|
videoEncoderName = null;
|
||||||
|
exportException = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An input entirely or partially processed. */
|
/** An input entirely or partially processed. */
|
||||||
@ -333,7 +345,7 @@ public final class ExportResult {
|
|||||||
|
|
||||||
public Builder buildUpon() {
|
public Builder buildUpon() {
|
||||||
return new Builder()
|
return new Builder()
|
||||||
.setProcessedInputs(processedInputs)
|
.addProcessedInputs(processedInputs)
|
||||||
.setDurationMs(durationMs)
|
.setDurationMs(durationMs)
|
||||||
.setFileSizeBytes(fileSizeBytes)
|
.setFileSizeBytes(fileSizeBytes)
|
||||||
.setAverageAudioBitrate(averageAudioBitrate)
|
.setAverageAudioBitrate(averageAudioBitrate)
|
||||||
|
@ -0,0 +1,306 @@
|
|||||||
|
/*
|
||||||
|
* 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.util.Assertions.checkNotNull;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Pair;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.util.Util;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.io.ByteStreams;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Utility methods for resuming an export. */
|
||||||
|
/* package */ final class ExportResumeHelper {
|
||||||
|
|
||||||
|
/** Provides metadata required to resume an export. */
|
||||||
|
public static final class ResumeMetadata {
|
||||||
|
/** The last sync sample timestamp of the previous output file. */
|
||||||
|
public final long lastSyncSampleTimestampUs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link List} containing the index of the first {@link EditedMediaItem} to process and its
|
||||||
|
* {@linkplain androidx.media3.common.MediaItem.ClippingConfiguration#startPositionMs additional
|
||||||
|
* offset} for each {@link EditedMediaItemSequence} in a {@link Composition}.
|
||||||
|
*/
|
||||||
|
public final ImmutableList<Pair<Integer, Long>> firstMediaItemIndexAndOffsetInfo;
|
||||||
|
|
||||||
|
public ResumeMetadata(
|
||||||
|
long lastSyncSampleTimestampUs,
|
||||||
|
ImmutableList<Pair<Integer, Long>> firstMediaItemIndexAndOffsetInfo) {
|
||||||
|
this.lastSyncSampleTimestampUs = lastSyncSampleTimestampUs;
|
||||||
|
this.firstMediaItemIndexAndOffsetInfo = firstMediaItemIndexAndOffsetInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExportResumeHelper() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a video only {@link Composition} from the given {@code filePath} and {@code
|
||||||
|
* clippingEndPositionUs}.
|
||||||
|
*/
|
||||||
|
public static Composition createVideoOnlyComposition(
|
||||||
|
String filePath, long clippingEndPositionUs) {
|
||||||
|
MediaItem.ClippingConfiguration clippingConfiguration =
|
||||||
|
new MediaItem.ClippingConfiguration.Builder()
|
||||||
|
.setEndPositionMs(Util.usToMs(clippingEndPositionUs))
|
||||||
|
.build();
|
||||||
|
EditedMediaItem editedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setUri(filePath)
|
||||||
|
.setClippingConfiguration(clippingConfiguration)
|
||||||
|
.build())
|
||||||
|
.setRemoveAudio(true)
|
||||||
|
.build();
|
||||||
|
EditedMediaItemSequence sequence =
|
||||||
|
new EditedMediaItemSequence(ImmutableList.of(editedMediaItem));
|
||||||
|
return new Composition.Builder(ImmutableList.of(sequence)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link Composition} for transcoding audio and transmuxing video.
|
||||||
|
*
|
||||||
|
* @param composition The {@link Composition} to transcode audio from.
|
||||||
|
* @param videoFilePath The video only file path to transmux video.
|
||||||
|
* @return The {@link Composition}.
|
||||||
|
*/
|
||||||
|
public static Composition createAudioTranscodeAndVideoTransmuxComposition(
|
||||||
|
Composition composition, String videoFilePath) {
|
||||||
|
Composition audioOnlyComposition =
|
||||||
|
ExportResumeHelper.buildUponComposition(
|
||||||
|
checkNotNull(composition),
|
||||||
|
/* removeAudio= */ false,
|
||||||
|
/* removeVideo= */ true,
|
||||||
|
/* resumeMetadata= */ null);
|
||||||
|
|
||||||
|
Composition.Builder compositionBuilder = audioOnlyComposition.buildUpon();
|
||||||
|
List<EditedMediaItemSequence> sequences = new ArrayList<>(audioOnlyComposition.sequences);
|
||||||
|
|
||||||
|
// Video stream sequence.
|
||||||
|
EditedMediaItem videoOnlyEditedMediaItem =
|
||||||
|
new EditedMediaItem.Builder(new MediaItem.Builder().setUri(videoFilePath).build()).build();
|
||||||
|
EditedMediaItemSequence videoOnlySequence =
|
||||||
|
new EditedMediaItemSequence(ImmutableList.of(videoOnlyEditedMediaItem));
|
||||||
|
|
||||||
|
sequences.add(videoOnlySequence);
|
||||||
|
compositionBuilder.setSequences(sequences);
|
||||||
|
compositionBuilder.setTransmuxVideo(true);
|
||||||
|
return compositionBuilder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a new {@link Composition} from a given {@link Composition}.
|
||||||
|
*
|
||||||
|
* <p>The new {@link Composition} will be built based on {@link
|
||||||
|
* ResumeMetadata#firstMediaItemIndexAndOffsetInfo}.
|
||||||
|
*/
|
||||||
|
public static Composition buildUponComposition(
|
||||||
|
Composition composition,
|
||||||
|
boolean removeAudio,
|
||||||
|
boolean removeVideo,
|
||||||
|
@Nullable ResumeMetadata resumeMetadata) {
|
||||||
|
Composition.Builder compositionBuilder = composition.buildUpon();
|
||||||
|
ImmutableList<EditedMediaItemSequence> editedMediaItemSequenceList = composition.sequences;
|
||||||
|
List<EditedMediaItemSequence> newEditedMediaItemSequenceList = new ArrayList<>();
|
||||||
|
@Nullable
|
||||||
|
List<Pair<Integer, Long>> firstMediaItemIndexAndOffsetInfo =
|
||||||
|
resumeMetadata != null ? resumeMetadata.firstMediaItemIndexAndOffsetInfo : null;
|
||||||
|
|
||||||
|
for (int sequenceIndex = 0;
|
||||||
|
sequenceIndex < editedMediaItemSequenceList.size();
|
||||||
|
sequenceIndex++) {
|
||||||
|
EditedMediaItemSequence currentEditedMediaItemSequence =
|
||||||
|
editedMediaItemSequenceList.get(sequenceIndex);
|
||||||
|
ImmutableList<EditedMediaItem> editedMediaItemList =
|
||||||
|
currentEditedMediaItemSequence.editedMediaItems;
|
||||||
|
List<EditedMediaItem> newEditedMediaItemList = new ArrayList<>();
|
||||||
|
|
||||||
|
int firstMediaItemIndex = 0;
|
||||||
|
long firstMediaItemOffsetUs = 0L;
|
||||||
|
|
||||||
|
if (firstMediaItemIndexAndOffsetInfo != null) {
|
||||||
|
firstMediaItemIndex = firstMediaItemIndexAndOffsetInfo.get(sequenceIndex).first;
|
||||||
|
firstMediaItemOffsetUs = firstMediaItemIndexAndOffsetInfo.get(sequenceIndex).second;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int mediaItemIndex = firstMediaItemIndex;
|
||||||
|
mediaItemIndex < editedMediaItemList.size();
|
||||||
|
mediaItemIndex++) {
|
||||||
|
EditedMediaItem currentEditedMediaItem = editedMediaItemList.get(mediaItemIndex);
|
||||||
|
EditedMediaItem.Builder newEditedMediaItemBuilder = currentEditedMediaItem.buildUpon();
|
||||||
|
|
||||||
|
if (mediaItemIndex == firstMediaItemIndex) {
|
||||||
|
MediaItem.ClippingConfiguration clippingConfiguration =
|
||||||
|
currentEditedMediaItem
|
||||||
|
.mediaItem
|
||||||
|
.clippingConfiguration
|
||||||
|
.buildUpon()
|
||||||
|
.setStartPositionMs(
|
||||||
|
currentEditedMediaItem.mediaItem.clippingConfiguration.startPositionMs
|
||||||
|
+ Util.usToMs(firstMediaItemOffsetUs))
|
||||||
|
.build();
|
||||||
|
newEditedMediaItemBuilder.setMediaItem(
|
||||||
|
currentEditedMediaItem
|
||||||
|
.mediaItem
|
||||||
|
.buildUpon()
|
||||||
|
.setClippingConfiguration(clippingConfiguration)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeAudio) {
|
||||||
|
newEditedMediaItemBuilder.setRemoveAudio(true);
|
||||||
|
}
|
||||||
|
if (removeVideo) {
|
||||||
|
newEditedMediaItemBuilder.setRemoveVideo(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
newEditedMediaItemList.add(newEditedMediaItemBuilder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
newEditedMediaItemSequenceList.add(
|
||||||
|
new EditedMediaItemSequence(
|
||||||
|
newEditedMediaItemList, currentEditedMediaItemSequence.isLooping));
|
||||||
|
}
|
||||||
|
compositionBuilder.setSequences(newEditedMediaItemSequenceList);
|
||||||
|
return compositionBuilder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link ListenableFuture} that provides {@link ResumeMetadata} for given input.
|
||||||
|
*
|
||||||
|
* @param context The {@link Context}.
|
||||||
|
* @param filePath The old file path to resume the export from.
|
||||||
|
* @param composition The {@link Composition} to export.
|
||||||
|
* @return A {@link ListenableFuture} that provides {@link ResumeMetadata}.
|
||||||
|
*/
|
||||||
|
public static ListenableFuture<ResumeMetadata> getResumeMetadataAsync(
|
||||||
|
Context context, String filePath, Composition composition) {
|
||||||
|
SettableFuture<ResumeMetadata> resumeMetadataSettableFuture = SettableFuture.create();
|
||||||
|
new Thread("ExportResumeHelper:ResumeMetadata") {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
if (resumeMetadataSettableFuture.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long lastSyncSampleTimestampUs =
|
||||||
|
Mp4MetadataInfo.create(context, filePath).lastSyncSampleTimestampUs;
|
||||||
|
|
||||||
|
ImmutableList.Builder<Pair<Integer, Long>> firstMediaItemIndexAndOffsetInfoBuilder =
|
||||||
|
new ImmutableList.Builder<>();
|
||||||
|
if (lastSyncSampleTimestampUs != C.TIME_UNSET) {
|
||||||
|
for (int compositionSequenceIndex = 0;
|
||||||
|
compositionSequenceIndex < composition.sequences.size();
|
||||||
|
compositionSequenceIndex++) {
|
||||||
|
ImmutableList<EditedMediaItem> editedMediaItemList =
|
||||||
|
composition.sequences.get(compositionSequenceIndex).editedMediaItems;
|
||||||
|
long remainingDurationUsToSkip = lastSyncSampleTimestampUs;
|
||||||
|
int editedMediaItemIndex = 0;
|
||||||
|
long mediaItemOffset = 0L;
|
||||||
|
while (editedMediaItemIndex < editedMediaItemList.size()
|
||||||
|
&& remainingDurationUsToSkip > 0) {
|
||||||
|
long mediaItemDuration =
|
||||||
|
getMediaItemDurationUs(
|
||||||
|
context, editedMediaItemList.get(editedMediaItemIndex).mediaItem);
|
||||||
|
if (mediaItemDuration > remainingDurationUsToSkip) {
|
||||||
|
mediaItemOffset = remainingDurationUsToSkip;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingDurationUsToSkip -= mediaItemDuration;
|
||||||
|
editedMediaItemIndex++;
|
||||||
|
}
|
||||||
|
firstMediaItemIndexAndOffsetInfoBuilder.add(
|
||||||
|
new Pair<>(editedMediaItemIndex, mediaItemOffset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resumeMetadataSettableFuture.set(
|
||||||
|
new ResumeMetadata(
|
||||||
|
lastSyncSampleTimestampUs, firstMediaItemIndexAndOffsetInfoBuilder.build()));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
resumeMetadataSettableFuture.setException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start();
|
||||||
|
|
||||||
|
return resumeMetadataSettableFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies {@link File} content from source to destination asynchronously. */
|
||||||
|
public static ListenableFuture<Void> copyFileAsync(File source, File destination) {
|
||||||
|
SettableFuture<Void> copyFileSettableFuture = SettableFuture.create();
|
||||||
|
new Thread("ExportResumeHelper:CopyFile") {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (copyFileSettableFuture.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
InputStream input = null;
|
||||||
|
OutputStream output = null;
|
||||||
|
try {
|
||||||
|
input = new FileInputStream(source);
|
||||||
|
output = new FileOutputStream(destination);
|
||||||
|
ByteStreams.copy(input, output);
|
||||||
|
copyFileSettableFuture.set(null);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
copyFileSettableFuture.setException(ex);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (input != null) {
|
||||||
|
input.close();
|
||||||
|
}
|
||||||
|
if (output != null) {
|
||||||
|
output.close();
|
||||||
|
}
|
||||||
|
} catch (IOException exception) {
|
||||||
|
// If the file copy was successful then this exception can be ignored and if there
|
||||||
|
// was some other error during copy operation then that exception has already been
|
||||||
|
// propagated in the catch block.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start();
|
||||||
|
return copyFileSettableFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long getMediaItemDurationUs(Context context, MediaItem mediaItem)
|
||||||
|
throws IOException {
|
||||||
|
String filePath = checkNotNull(mediaItem.localConfiguration).uri.toString();
|
||||||
|
long startUs = Util.msToUs(mediaItem.clippingConfiguration.startPositionMs);
|
||||||
|
long endUs;
|
||||||
|
if (mediaItem.clippingConfiguration.endPositionMs != C.TIME_END_OF_SOURCE) {
|
||||||
|
endUs = Util.msToUs(mediaItem.clippingConfiguration.endPositionMs);
|
||||||
|
} else {
|
||||||
|
endUs = Mp4MetadataInfo.create(context, filePath).durationUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return endUs - startUs;
|
||||||
|
}
|
||||||
|
}
|
@ -238,12 +238,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
|||||||
// Ensure that video formats are the same. Some fields like codecs, averageBitrate, framerate,
|
// Ensure that video formats are the same. Some fields like codecs, averageBitrate, framerate,
|
||||||
// etc, don't match exactly in the Extractor output format and the Encoder output
|
// etc, don't match exactly in the Extractor output format and the Encoder output
|
||||||
// format but these fields can be ignored.
|
// format but these fields can be ignored.
|
||||||
|
// TODO: b/308180225 - Compare Format.colorInfo as well.
|
||||||
Format existingFormat = videoTrackInfo.format;
|
Format existingFormat = videoTrackInfo.format;
|
||||||
checkArgument(Util.areEqual(existingFormat.sampleMimeType, format.sampleMimeType));
|
checkArgument(Util.areEqual(existingFormat.sampleMimeType, format.sampleMimeType));
|
||||||
checkArgument(existingFormat.width == format.width);
|
checkArgument(existingFormat.width == format.width);
|
||||||
checkArgument(existingFormat.height == format.height);
|
checkArgument(existingFormat.height == format.height);
|
||||||
checkArgument(existingFormat.initializationDataEquals(format));
|
checkArgument(existingFormat.initializationDataEquals(format));
|
||||||
checkArgument(Util.areEqual(existingFormat.colorInfo, format.colorInfo));
|
|
||||||
|
|
||||||
checkNotNull(muxer);
|
checkNotNull(muxer);
|
||||||
resetAbortTimer();
|
resetAbortTimer();
|
||||||
|
@ -49,8 +49,13 @@ import androidx.media3.effect.DefaultVideoFrameProcessor;
|
|||||||
import androidx.media3.effect.Presentation;
|
import androidx.media3.effect.Presentation;
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.util.concurrent.FutureCallback;
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
||||||
import com.google.errorprone.annotations.InlineMe;
|
import com.google.errorprone.annotations.InlineMe;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.lang.annotation.Documented;
|
import java.lang.annotation.Documented;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
@ -653,6 +658,24 @@ public final class Transformer {
|
|||||||
/** Indicates that the progress is permanently unavailable. */
|
/** Indicates that the progress is permanently unavailable. */
|
||||||
public static final int PROGRESS_STATE_UNAVAILABLE = 3;
|
public static final int PROGRESS_STATE_UNAVAILABLE = 3;
|
||||||
|
|
||||||
|
@Documented
|
||||||
|
@Retention(RetentionPolicy.SOURCE)
|
||||||
|
@Target(TYPE_USE)
|
||||||
|
@IntDef({
|
||||||
|
TRANSFORMER_STATE_PROCESS_FULL_INPUT,
|
||||||
|
TRANSFORMER_STATE_REMUX_PROCESSED_VIDEO,
|
||||||
|
TRANSFORMER_STATE_PROCESS_REMAINING_VIDEO,
|
||||||
|
TRANSFORMER_STATE_PROCESS_AUDIO,
|
||||||
|
TRANSFORMER_STATE_COPY_OUTPUT
|
||||||
|
})
|
||||||
|
private @interface TransformerState {}
|
||||||
|
|
||||||
|
private static final int TRANSFORMER_STATE_PROCESS_FULL_INPUT = 0;
|
||||||
|
private static final int TRANSFORMER_STATE_REMUX_PROCESSED_VIDEO = 1;
|
||||||
|
private static final int TRANSFORMER_STATE_PROCESS_REMAINING_VIDEO = 2;
|
||||||
|
private static final int TRANSFORMER_STATE_PROCESS_AUDIO = 3;
|
||||||
|
private static final int TRANSFORMER_STATE_COPY_OUTPUT = 4;
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private final TransformationRequest transformationRequest;
|
private final TransformationRequest transformationRequest;
|
||||||
private final ImmutableList<AudioProcessor> audioProcessors;
|
private final ImmutableList<AudioProcessor> audioProcessors;
|
||||||
@ -669,8 +692,20 @@ public final class Transformer {
|
|||||||
private final Looper looper;
|
private final Looper looper;
|
||||||
private final DebugViewProvider debugViewProvider;
|
private final DebugViewProvider debugViewProvider;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final HandlerWrapper applicationHandler;
|
||||||
|
private final ComponentListener componentListener;
|
||||||
|
private final ExportResult.Builder exportResultBuilder;
|
||||||
|
|
||||||
@Nullable private TransformerInternal transformerInternal;
|
@Nullable private TransformerInternal transformerInternal;
|
||||||
|
@Nullable private MuxerWrapper remuxingMuxerWrapper;
|
||||||
|
private @MonotonicNonNull Composition composition;
|
||||||
|
private @MonotonicNonNull String outputFilePath;
|
||||||
|
private @MonotonicNonNull String oldFilePath;
|
||||||
|
private @TransformerState int transformerState;
|
||||||
|
private ExportResumeHelper.@MonotonicNonNull ResumeMetadata resumeMetadata;
|
||||||
|
private @MonotonicNonNull ListenableFuture<ExportResumeHelper.ResumeMetadata>
|
||||||
|
getResumeMetadataFuture;
|
||||||
|
private @MonotonicNonNull ListenableFuture<Void> copyOutputFuture;
|
||||||
|
|
||||||
private Transformer(
|
private Transformer(
|
||||||
Context context,
|
Context context,
|
||||||
@ -706,6 +741,9 @@ public final class Transformer {
|
|||||||
this.looper = looper;
|
this.looper = looper;
|
||||||
this.debugViewProvider = debugViewProvider;
|
this.debugViewProvider = debugViewProvider;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
|
applicationHandler = clock.createHandler(looper, /* callback= */ null);
|
||||||
|
componentListener = new ComponentListener();
|
||||||
|
exportResultBuilder = new ExportResult.Builder();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a {@link Transformer.Builder} initialized with the values of this instance. */
|
/** Returns a {@link Transformer.Builder} initialized with the values of this instance. */
|
||||||
@ -828,11 +866,12 @@ public final class Transformer {
|
|||||||
* @throws IllegalStateException If an export is already in progress.
|
* @throws IllegalStateException If an export is already in progress.
|
||||||
*/
|
*/
|
||||||
public void start(Composition composition, String path) {
|
public void start(Composition composition, String path) {
|
||||||
ComponentListener componentListener = new ComponentListener(composition);
|
initialize(composition, path);
|
||||||
startInternal(
|
startInternal(
|
||||||
composition,
|
composition,
|
||||||
new MuxerWrapper(path, muxerFactory, componentListener, MuxerWrapper.MUXER_MODE_DEFAULT),
|
new MuxerWrapper(path, muxerFactory, componentListener, MuxerWrapper.MUXER_MODE_DEFAULT),
|
||||||
componentListener);
|
componentListener,
|
||||||
|
/* initialTimestampOffsetUs= */ 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -927,6 +966,9 @@ public final class Transformer {
|
|||||||
* Returns the current {@link ProgressState} and updates {@code progressHolder} with the current
|
* Returns the current {@link ProgressState} and updates {@code progressHolder} with the current
|
||||||
* progress if it is {@link #PROGRESS_STATE_AVAILABLE available}.
|
* progress if it is {@link #PROGRESS_STATE_AVAILABLE available}.
|
||||||
*
|
*
|
||||||
|
* <p>If the export is {@linkplain #resume(Composition, String, String) resumed}, this method
|
||||||
|
* returns {@link #PROGRESS_STATE_UNAVAILABLE}.
|
||||||
|
*
|
||||||
* <p>After an export {@linkplain Listener#onCompleted(Composition, ExportResult) completes}, this
|
* <p>After an export {@linkplain Listener#onCompleted(Composition, ExportResult) completes}, this
|
||||||
* method returns {@link #PROGRESS_STATE_NOT_STARTED}.
|
* method returns {@link #PROGRESS_STATE_NOT_STARTED}.
|
||||||
*
|
*
|
||||||
@ -937,6 +979,9 @@ public final class Transformer {
|
|||||||
*/
|
*/
|
||||||
public @ProgressState int getProgress(ProgressHolder progressHolder) {
|
public @ProgressState int getProgress(ProgressHolder progressHolder) {
|
||||||
verifyApplicationThread();
|
verifyApplicationThread();
|
||||||
|
if (transformerState != TRANSFORMER_STATE_PROCESS_FULL_INPUT) {
|
||||||
|
return PROGRESS_STATE_UNAVAILABLE;
|
||||||
|
}
|
||||||
return transformerInternal == null
|
return transformerInternal == null
|
||||||
? PROGRESS_STATE_NOT_STARTED
|
? PROGRESS_STATE_NOT_STARTED
|
||||||
: transformerInternal.getProgress(progressHolder);
|
: transformerInternal.getProgress(progressHolder);
|
||||||
@ -959,6 +1004,159 @@ public final class Transformer {
|
|||||||
} finally {
|
} finally {
|
||||||
transformerInternal = null;
|
transformerInternal = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getResumeMetadataFuture != null && !getResumeMetadataFuture.isDone()) {
|
||||||
|
getResumeMetadataFuture.cancel(/* mayInterruptIfRunning= */ false);
|
||||||
|
}
|
||||||
|
if (copyOutputFuture != null && !copyOutputFuture.isDone()) {
|
||||||
|
copyOutputFuture.cancel(/* mayInterruptIfRunning= */ false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes a previously {@linkplain #cancel() cancelled} export.
|
||||||
|
*
|
||||||
|
* <p>An export can be resumed only when:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>The {@link Composition} contains a single {@link EditedMediaItemSequence} having
|
||||||
|
* continuous audio and video tracks.
|
||||||
|
* <li>The output is an MP4 file.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param composition The {@link Composition} to resume export.
|
||||||
|
* @param outputFilePath The path to the output file. This must be different from the output path
|
||||||
|
* of the cancelled export.
|
||||||
|
* @param oldFilePath The output path of the the cancelled export.
|
||||||
|
*/
|
||||||
|
public void resume(Composition composition, String outputFilePath, String oldFilePath) {
|
||||||
|
verifyApplicationThread();
|
||||||
|
initialize(composition, outputFilePath);
|
||||||
|
this.oldFilePath = oldFilePath;
|
||||||
|
remuxProcessedVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initialize(Composition composition, String outputFilePath) {
|
||||||
|
this.composition = composition;
|
||||||
|
this.outputFilePath = outputFilePath;
|
||||||
|
exportResultBuilder.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processFullInput() {
|
||||||
|
transformerState = TRANSFORMER_STATE_PROCESS_FULL_INPUT;
|
||||||
|
startInternal(
|
||||||
|
checkNotNull(composition),
|
||||||
|
new MuxerWrapper(
|
||||||
|
checkNotNull(outputFilePath),
|
||||||
|
muxerFactory,
|
||||||
|
componentListener,
|
||||||
|
MuxerWrapper.MUXER_MODE_DEFAULT),
|
||||||
|
componentListener,
|
||||||
|
/* initialTimestampOffsetUs= */ 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void remuxProcessedVideo() {
|
||||||
|
transformerState = TRANSFORMER_STATE_REMUX_PROCESSED_VIDEO;
|
||||||
|
getResumeMetadataFuture =
|
||||||
|
ExportResumeHelper.getResumeMetadataAsync(
|
||||||
|
context, checkNotNull(oldFilePath), checkNotNull(composition));
|
||||||
|
Futures.addCallback(
|
||||||
|
getResumeMetadataFuture,
|
||||||
|
new FutureCallback<ExportResumeHelper.ResumeMetadata>() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(ExportResumeHelper.ResumeMetadata resumeMetadata) {
|
||||||
|
// If there is no video track to remux or the last sync sample is actually the first
|
||||||
|
// sample, then start the normal Export.
|
||||||
|
if (resumeMetadata.lastSyncSampleTimestampUs == C.TIME_UNSET
|
||||||
|
|| resumeMetadata.lastSyncSampleTimestampUs == 0) {
|
||||||
|
processFullInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Transformer.this.resumeMetadata = resumeMetadata;
|
||||||
|
|
||||||
|
remuxingMuxerWrapper =
|
||||||
|
new MuxerWrapper(
|
||||||
|
checkNotNull(outputFilePath),
|
||||||
|
muxerFactory,
|
||||||
|
componentListener,
|
||||||
|
MuxerWrapper.MUXER_MODE_MUX_PARTIAL_VIDEO);
|
||||||
|
|
||||||
|
startInternal(
|
||||||
|
ExportResumeHelper.createVideoOnlyComposition(
|
||||||
|
oldFilePath,
|
||||||
|
/* clippingEndPositionUs= */ resumeMetadata.lastSyncSampleTimestampUs),
|
||||||
|
checkNotNull(remuxingMuxerWrapper),
|
||||||
|
componentListener,
|
||||||
|
/* initialTimestampOffsetUs= */ 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Throwable t) {
|
||||||
|
// In case of error fallback to normal Export.
|
||||||
|
processFullInput();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applicationHandler::post);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processRemainingVideo() {
|
||||||
|
transformerState = TRANSFORMER_STATE_PROCESS_REMAINING_VIDEO;
|
||||||
|
Composition videoOnlyComposition =
|
||||||
|
ExportResumeHelper.buildUponComposition(
|
||||||
|
checkNotNull(composition),
|
||||||
|
/* removeAudio= */ true,
|
||||||
|
/* removeVideo= */ false,
|
||||||
|
resumeMetadata);
|
||||||
|
|
||||||
|
checkNotNull(remuxingMuxerWrapper);
|
||||||
|
remuxingMuxerWrapper.changeToAppendVideoMode();
|
||||||
|
|
||||||
|
startInternal(
|
||||||
|
videoOnlyComposition,
|
||||||
|
remuxingMuxerWrapper,
|
||||||
|
componentListener,
|
||||||
|
/* initialTimestampOffsetUs= */ checkNotNull(resumeMetadata).lastSyncSampleTimestampUs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processAudio() {
|
||||||
|
transformerState = TRANSFORMER_STATE_PROCESS_AUDIO;
|
||||||
|
|
||||||
|
startInternal(
|
||||||
|
ExportResumeHelper.createAudioTranscodeAndVideoTransmuxComposition(
|
||||||
|
checkNotNull(composition), checkNotNull(outputFilePath)),
|
||||||
|
new MuxerWrapper(
|
||||||
|
checkNotNull(oldFilePath),
|
||||||
|
muxerFactory,
|
||||||
|
componentListener,
|
||||||
|
MuxerWrapper.MUXER_MODE_DEFAULT),
|
||||||
|
componentListener,
|
||||||
|
/* initialTimestampOffsetUs= */ 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: b/308253384 - Move copy output logic into MuxerWrapper.
|
||||||
|
private void copyOutput() {
|
||||||
|
transformerState = TRANSFORMER_STATE_COPY_OUTPUT;
|
||||||
|
copyOutputFuture =
|
||||||
|
ExportResumeHelper.copyFileAsync(
|
||||||
|
new File(checkNotNull(oldFilePath)), new File(checkNotNull(outputFilePath)));
|
||||||
|
|
||||||
|
Futures.addCallback(
|
||||||
|
copyOutputFuture,
|
||||||
|
new FutureCallback<Void>() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(Void result) {
|
||||||
|
onExportCompletedWithSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Throwable t) {
|
||||||
|
onExportCompletedWithError(
|
||||||
|
ExportException.createForUnexpected(
|
||||||
|
new IOException("Copy output task failed for the resumed export", t)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applicationHandler::post);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void verifyApplicationThread() {
|
private void verifyApplicationThread() {
|
||||||
@ -968,11 +1166,13 @@ public final class Transformer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void startInternal(
|
private void startInternal(
|
||||||
Composition composition, MuxerWrapper muxerWrapper, ComponentListener componentListener) {
|
Composition composition,
|
||||||
|
MuxerWrapper muxerWrapper,
|
||||||
|
ComponentListener componentListener,
|
||||||
|
long initialTimestampOffsetUs) {
|
||||||
checkArgument(composition.effects.audioProcessors.isEmpty());
|
checkArgument(composition.effects.audioProcessors.isEmpty());
|
||||||
verifyApplicationThread();
|
verifyApplicationThread();
|
||||||
checkState(transformerInternal == null, "There is already an export in progress.");
|
checkState(transformerInternal == null, "There is already an export in progress.");
|
||||||
HandlerWrapper applicationHandler = clock.createHandler(looper, /* callback= */ null);
|
|
||||||
TransformationRequest transformationRequest = this.transformationRequest;
|
TransformationRequest transformationRequest = this.transformationRequest;
|
||||||
if (composition.hdrMode != Composition.HDR_MODE_KEEP_HDR) {
|
if (composition.hdrMode != Composition.HDR_MODE_KEEP_HDR) {
|
||||||
transformationRequest =
|
transformationRequest =
|
||||||
@ -1006,21 +1206,28 @@ public final class Transformer {
|
|||||||
applicationHandler,
|
applicationHandler,
|
||||||
debugViewProvider,
|
debugViewProvider,
|
||||||
clock,
|
clock,
|
||||||
/* videoSampleTimestampOffsetUs= */ 0);
|
initialTimestampOffsetUs);
|
||||||
transformerInternal.start();
|
transformerInternal.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void onExportCompletedWithSuccess() {
|
||||||
|
listeners.queueEvent(
|
||||||
|
/* eventFlag= */ C.INDEX_UNSET,
|
||||||
|
listener -> listener.onCompleted(checkNotNull(composition), exportResultBuilder.build()));
|
||||||
|
listeners.flushEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onExportCompletedWithError(ExportException exception) {
|
||||||
|
listeners.queueEvent(
|
||||||
|
/* eventFlag= */ C.INDEX_UNSET,
|
||||||
|
listener ->
|
||||||
|
listener.onError(checkNotNull(composition), exportResultBuilder.build(), exception));
|
||||||
|
listeners.flushEvents();
|
||||||
|
}
|
||||||
|
|
||||||
private final class ComponentListener
|
private final class ComponentListener
|
||||||
implements TransformerInternal.Listener, MuxerWrapper.Listener {
|
implements TransformerInternal.Listener, MuxerWrapper.Listener {
|
||||||
|
|
||||||
private final Composition composition;
|
|
||||||
private final ExportResult.Builder exportResultBuilder;
|
|
||||||
|
|
||||||
public ComponentListener(Composition composition) {
|
|
||||||
this.composition = composition;
|
|
||||||
this.exportResultBuilder = new ExportResult.Builder();
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransformerInternal.Listener implementation
|
// TransformerInternal.Listener implementation
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1028,17 +1235,29 @@ public final class Transformer {
|
|||||||
ImmutableList<ExportResult.ProcessedInput> processedInputs,
|
ImmutableList<ExportResult.ProcessedInput> processedInputs,
|
||||||
@Nullable String audioEncoderName,
|
@Nullable String audioEncoderName,
|
||||||
@Nullable String videoEncoderName) {
|
@Nullable String videoEncoderName) {
|
||||||
exportResultBuilder
|
exportResultBuilder.addProcessedInputs(processedInputs);
|
||||||
.setProcessedInputs(processedInputs)
|
|
||||||
.setAudioEncoderName(audioEncoderName)
|
// When an export is resumed, the audio and video encoder name (if any) can comes from
|
||||||
.setVideoEncoderName(videoEncoderName);
|
// different intermittent exports, so set encoder names only when they are available.
|
||||||
|
if (audioEncoderName != null) {
|
||||||
|
exportResultBuilder.setAudioEncoderName(audioEncoderName);
|
||||||
|
}
|
||||||
|
if (videoEncoderName != null) {
|
||||||
|
exportResultBuilder.setVideoEncoderName(videoEncoderName);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(b/213341814): Add event flags for Transformer events.
|
// TODO(b/213341814): Add event flags for Transformer events.
|
||||||
transformerInternal = null;
|
transformerInternal = null;
|
||||||
listeners.queueEvent(
|
if (transformerState == TRANSFORMER_STATE_REMUX_PROCESSED_VIDEO) {
|
||||||
/* eventFlag= */ C.INDEX_UNSET,
|
processRemainingVideo();
|
||||||
listener -> listener.onCompleted(composition, exportResultBuilder.build()));
|
} else if (transformerState == TRANSFORMER_STATE_PROCESS_REMAINING_VIDEO) {
|
||||||
listeners.flushEvents();
|
remuxingMuxerWrapper = null;
|
||||||
|
processAudio();
|
||||||
|
} else if (transformerState == TRANSFORMER_STATE_PROCESS_AUDIO) {
|
||||||
|
copyOutput();
|
||||||
|
} else {
|
||||||
|
onExportCompletedWithSuccess();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1048,17 +1267,20 @@ public final class Transformer {
|
|||||||
@Nullable String audioEncoderName,
|
@Nullable String audioEncoderName,
|
||||||
@Nullable String videoEncoderName,
|
@Nullable String videoEncoderName,
|
||||||
ExportException exportException) {
|
ExportException exportException) {
|
||||||
exportResultBuilder
|
exportResultBuilder.addProcessedInputs(processedInputs);
|
||||||
.setProcessedInputs(processedInputs)
|
|
||||||
.setAudioEncoderName(audioEncoderName)
|
|
||||||
.setVideoEncoderName(videoEncoderName)
|
|
||||||
.setExportException(exportException);
|
|
||||||
|
|
||||||
|
// When an export is resumed, the audio and video encoder name (if any) can comes from
|
||||||
|
// different intermittent exports, so set encoder names only when they are available.
|
||||||
|
if (audioEncoderName != null) {
|
||||||
|
exportResultBuilder.setAudioEncoderName(audioEncoderName);
|
||||||
|
}
|
||||||
|
if (videoEncoderName != null) {
|
||||||
|
exportResultBuilder.setVideoEncoderName(videoEncoderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportResultBuilder.setExportException(exportException);
|
||||||
transformerInternal = null;
|
transformerInternal = null;
|
||||||
listeners.queueEvent(
|
onExportCompletedWithError(exportException);
|
||||||
/* eventFlag= */ C.INDEX_UNSET,
|
|
||||||
listener -> listener.onError(composition, exportResultBuilder.build(), exportException));
|
|
||||||
listeners.flushEvents();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MuxerWrapper.Listener implementation
|
// MuxerWrapper.Listener implementation
|
||||||
|
Loading…
x
Reference in New Issue
Block a user