diff --git a/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java b/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java index c4511f1a..4fa35061 100644 --- a/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java +++ b/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java @@ -364,10 +364,13 @@ public void transcode(@NonNull TranscoderOptions options) throws InterruptedExce // Now step for transcoders that are not completed. audioCompleted = isCompleted(TrackType.AUDIO); videoCompleted = isCompleted(TrackType.VIDEO); - if (!audioCompleted) { + if (!audioCompleted && !videoCompleted) { + final TrackTranscoder videoTranscoder = getCurrentTrackTranscoder(TrackType.VIDEO, options); + final TrackTranscoder audioTranscoder = getCurrentTrackTranscoder(TrackType.AUDIO, options); + stepped |= videoTranscoder.transcode(forceVideoEos) | audioTranscoder.transcode(forceAudioEos); + } else if (!audioCompleted) { stepped |= getCurrentTrackTranscoder(TrackType.AUDIO, options).transcode(forceAudioEos); - } - if (!videoCompleted) { + } else if (!videoCompleted) { stepped |= getCurrentTrackTranscoder(TrackType.VIDEO, options).transcode(forceVideoEos); } if (++loopCount % PROGRESS_INTERVAL_STEPS == 0) { diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java index e9140dca..0b333bb0 100644 --- a/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java +++ b/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java @@ -18,7 +18,7 @@ /** * A DataSource implementation that uses Android's Media APIs. */ -public abstract class DefaultDataSource implements DataSource { +public abstract class DefaultDataSource extends MediaExtractorDataSource { private final static String TAG = DefaultDataSource.class.getSimpleName(); private final static Logger LOG = new Logger(TAG); @@ -214,4 +214,10 @@ public void rewind() { mMetadata = new MediaMetadataRetriever(); mMetadataApplied = false; } + + @Override + protected MediaExtractor requireExtractor() { + ensureExtractor(); + return mExtractor; + } } diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/MediaExtractorDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/MediaExtractorDataSource.java new file mode 100644 index 00000000..31bae1c6 --- /dev/null +++ b/lib/src/main/java/com/otaliastudios/transcoder/source/MediaExtractorDataSource.java @@ -0,0 +1,10 @@ +package com.otaliastudios.transcoder.source; + +import android.media.MediaExtractor; + +/** + * DataSource that allows access to its MediaExtractor. + */ +abstract class MediaExtractorDataSource implements DataSource { + abstract protected MediaExtractor requireExtractor(); +} diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java new file mode 100644 index 00000000..b6be47e4 --- /dev/null +++ b/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java @@ -0,0 +1,177 @@ +package com.otaliastudios.transcoder.source; + + +import android.media.MediaExtractor; +import android.media.MediaFormat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.otaliastudios.transcoder.engine.TrackType; +import com.otaliastudios.transcoder.internal.Logger; + +import org.jetbrains.annotations.Contract; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * A {@link DataSource} wrapper that trims source at both ends. + */ +public class TrimDataSource implements DataSource { + private static final String TAG = "TrimDataSource"; + private static final Logger LOG = new Logger(TAG); + private static final int UNKNOWN = -1; + + @NonNull + private MediaExtractorDataSource source; + private long trimStartUs; + private long trimDurationUs; + private boolean isVideoTrackReady = false; + private boolean hasSelectedVideoTrack = false; + + public TrimDataSource(@NonNull MediaExtractorDataSource source, long trimStartMillis, long trimEndMillis) { + this.source = source; + this.trimStartUs = MILLISECONDS.toMicros(trimStartMillis); + final long trimEndUs = MILLISECONDS.toMicros(trimEndMillis); + this.trimDurationUs = computeTrimDuration(source.getDurationUs(), trimStartUs, trimEndUs); + } + + @Contract(pure = true) + private static long computeTrimDuration(long duration, long trimStart, long trimEnd) { + if (duration == UNKNOWN) { + return UNKNOWN; + } else { + final long result = duration - trimStart - trimEnd; + return result >= 0 ? result : UNKNOWN; + } + } + + @Override + public int getOrientation() { + return source.getOrientation(); + } + + @Nullable + @Override + public double[] getLocation() { + return source.getLocation(); + } + + @Override + public long getDurationUs() { + return trimDurationUs; + } + + @Nullable + @Override + public MediaFormat getTrackFormat(@NonNull TrackType type) { + final MediaFormat trackFormat = source.getTrackFormat(type); + if (trackFormat != null) { + trackFormat.setLong(MediaFormat.KEY_DURATION, trimDurationUs); + } + return trackFormat; + } + + @Override + public void selectTrack(@NonNull TrackType type) { + if (trimStartUs > 0) { + switch (type) { + case AUDIO: + if (hasTrack(TrackType.VIDEO) && !hasSelectedVideoTrack) { + selectAndSeekVideoTrack(); + } + source.selectTrack(TrackType.AUDIO); + break; + case VIDEO: + if (!hasSelectedVideoTrack) { + selectAndSeekVideoTrack(); + } + break; + } + } else { + source.selectTrack(type); + } + } + + private boolean hasTrack(@NonNull TrackType type) { + return source.getTrackFormat(type) != null; + } + + private void selectAndSeekVideoTrack() { + source.selectTrack(TrackType.VIDEO); + source.requireExtractor().seekTo(trimStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC); + hasSelectedVideoTrack = true; + } + + /** + * Check if trim operation was completed successfully for selected track. + * We apply the seek operation for the video track only, so all audio frames are skipped + * until MediaExtractor reaches the first video key frame. + */ + private boolean isTrackReady(@NonNull TrackType type) { + if (isVideoTrackReady) { + return true; + } + final MediaExtractor extractor = source.requireExtractor(); + if (type == TrackType.VIDEO) { + final boolean isKeyFrame = (extractor.getSampleFlags() & MediaExtractor.SAMPLE_FLAG_SYNC) != 0; + if (isKeyFrame) { + final long originalTrimStartUs = trimStartUs; + trimStartUs = extractor.getSampleTime(); + trimDurationUs += originalTrimStartUs - trimStartUs; + LOG.v("First video key frame is at " + trimStartUs + ", actual duration will be " + trimDurationUs); + isVideoTrackReady = true; + return true; + } + } + extractor.advance(); + return false; + } + + @Override + public boolean canReadTrack(@NonNull TrackType type) { + boolean canRead = source.canReadTrack(type); + + if (canRead) { + return isTrackReady(type); + } else { + return false; + } + } + + @Override + public void readTrack(@NonNull Chunk chunk) { + source.readTrack(chunk); + chunk.timestampUs -= trimStartUs; + } + + @Override + public long getReadUs() { + return source.getReadUs(); + } + + @Override + public boolean isDrained() { + return source.isDrained(); + } + + @Override + public void releaseTrack(@NonNull TrackType type) { + switch (type) { + case AUDIO: + hasSelectedVideoTrack = false; + break; + case VIDEO: + isVideoTrackReady = false; + break; + } + source.releaseTrack(type); + } + + @Override + public void rewind() { + hasSelectedVideoTrack = false; + isVideoTrackReady = false; + source.rewind(); + } +}