mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-22 03:58:48 +00:00
Expose StreamingTranscoder configuration options in sample app.
This commit is contained in:
committed by
Greyson Parrelli
parent
c7609f9a2a
commit
caa5e233df
@@ -9,13 +9,8 @@ import androidx.annotation.Nullable;
|
|||||||
import androidx.annotation.WorkerThread;
|
import androidx.annotation.WorkerThread;
|
||||||
import androidx.media3.common.MimeTypes;
|
import androidx.media3.common.MimeTypes;
|
||||||
|
|
||||||
import com.google.common.io.ByteStreams;
|
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
import org.greenrobot.eventbus.EventBus;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.signal.libsignal.media.Mp4Sanitizer;
|
|
||||||
import org.signal.libsignal.media.ParseException;
|
|
||||||
import org.signal.libsignal.media.SanitizedMetadata;
|
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||||
@@ -47,15 +42,16 @@ import org.thoughtcrime.securesms.video.InMemoryTranscoder;
|
|||||||
import org.thoughtcrime.securesms.video.StreamingTranscoder;
|
import org.thoughtcrime.securesms.video.StreamingTranscoder;
|
||||||
import org.thoughtcrime.securesms.video.TranscoderCancelationSignal;
|
import org.thoughtcrime.securesms.video.TranscoderCancelationSignal;
|
||||||
import org.thoughtcrime.securesms.video.TranscoderOptions;
|
import org.thoughtcrime.securesms.video.TranscoderOptions;
|
||||||
|
import org.thoughtcrime.securesms.video.exceptions.VideoPostProcessingException;
|
||||||
|
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
||||||
|
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor;
|
||||||
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.SequenceInputStream;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@@ -289,24 +285,18 @@ public final class AttachmentCompressionJob extends BaseJob {
|
|||||||
100,
|
100,
|
||||||
100));
|
100));
|
||||||
|
|
||||||
InputStream transcodedFileStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0);
|
final Mp4FaststartPostProcessor postProcessor = new Mp4FaststartPostProcessor(() -> {
|
||||||
SanitizedMetadata metadata = null;
|
|
||||||
try {
|
try {
|
||||||
metadata = Mp4Sanitizer.sanitize(transcodedFileStream, file.length());
|
return ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0);
|
||||||
} catch (ParseException e) {
|
} catch (IOException e) {
|
||||||
Log.e(TAG, "Could not parse MP4 file.", e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
}, file.length());
|
||||||
|
|
||||||
if (metadata != null && metadata.getSanitizedMetadata() != null) {
|
try (MediaStream mediaStream = new MediaStream(postProcessor.process(), MimeTypes.VIDEO_MP4, 0, 0, true)) {
|
||||||
try (MediaStream mediaStream = new MediaStream(new SequenceInputStream(new ByteArrayInputStream(metadata.getSanitizedMetadata()), ByteStreams.limit(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, metadata.getDataOffset()), metadata.getDataLength())), MimeTypes.VIDEO_MP4, 0, 0, true)) {
|
|
||||||
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
|
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
|
||||||
faststart = true;
|
faststart = true;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
try (MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0)) {
|
|
||||||
attachmentDatabase.updateAttachmentData(attachment, mediaStream, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!file.delete()) {
|
if (!file.delete()) {
|
||||||
Log.w(TAG, "Failed to delete temp file");
|
Log.w(TAG, "Failed to delete temp file");
|
||||||
@@ -360,6 +350,12 @@ public final class AttachmentCompressionJob extends BaseJob {
|
|||||||
}
|
}
|
||||||
} catch (IOException | MmsException e) {
|
} catch (IOException | MmsException e) {
|
||||||
throw new UndeliverableMessageException("Failed to transcode", e);
|
throw new UndeliverableMessageException("Failed to transcode", e);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (e.getCause() instanceof IOException) {
|
||||||
|
throw new UndeliverableMessageException("Failed to transcode", e);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.R;
|
|||||||
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
||||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
|
import org.thoughtcrime.securesms.video.TranscodingQuality;
|
||||||
import org.thoughtcrime.securesms.video.VideoBitRateCalculator;
|
import org.thoughtcrime.securesms.video.VideoBitRateCalculator;
|
||||||
import org.thoughtcrime.securesms.video.VideoUtil;
|
import org.thoughtcrime.securesms.video.VideoUtil;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView;
|
import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView;
|
||||||
@@ -134,7 +135,7 @@ public final class VideoEditorHud extends LinearLayout {
|
|||||||
public VideoThumbnailsRangeSelectorView.Quality getQuality(long clipDurationUs, long totalDurationUs) {
|
public VideoThumbnailsRangeSelectorView.Quality getQuality(long clipDurationUs, long totalDurationUs) {
|
||||||
int inputBitRate = VideoBitRateCalculator.bitRate(size, TimeUnit.MICROSECONDS.toMillis(totalDurationUs));
|
int inputBitRate = VideoBitRateCalculator.bitRate(size, TimeUnit.MICROSECONDS.toMillis(totalDurationUs));
|
||||||
|
|
||||||
VideoBitRateCalculator.Quality targetQuality = videoBitRateCalculator.getTargetQuality(TimeUnit.MICROSECONDS.toMillis(clipDurationUs), inputBitRate);
|
TranscodingQuality targetQuality = videoBitRateCalculator.getTargetQuality(TimeUnit.MICROSECONDS.toMillis(clipDurationUs), inputBitRate);
|
||||||
return new VideoThumbnailsRangeSelectorView.Quality(targetQuality.getFileSizeEstimate(), (int) (100 * targetQuality.getQuality()));
|
return new VideoThumbnailsRangeSelectorView.Quality(targetQuality.getFileSizeEstimate(), (int) (100 * targetQuality.getQuality()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import android.media.MediaMetadataRetriever;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
import androidx.media3.common.MimeTypes;
|
import androidx.media3.common.MimeTypes;
|
||||||
import com.google.common.io.ByteStreams;
|
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.signal.libsignal.media.Mp4Sanitizer;
|
import org.signal.libsignal.media.Mp4Sanitizer;
|
||||||
@@ -17,18 +15,18 @@ import org.signal.libsignal.media.ParseException;
|
|||||||
import org.signal.libsignal.media.SanitizedMetadata;
|
import org.signal.libsignal.media.SanitizedMetadata;
|
||||||
import org.thoughtcrime.securesms.mms.MediaStream;
|
import org.thoughtcrime.securesms.mms.MediaStream;
|
||||||
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
|
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
|
||||||
|
import org.thoughtcrime.securesms.video.exceptions.VideoPostProcessingException;
|
||||||
import org.thoughtcrime.securesms.video.exceptions.VideoSizeException;
|
import org.thoughtcrime.securesms.video.exceptions.VideoSizeException;
|
||||||
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
||||||
|
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter;
|
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput;
|
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
import java.io.FileDescriptor;
|
import java.io.FileDescriptor;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.SequenceInputStream;
|
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@ public final class InMemoryTranscoder implements Closeable {
|
|||||||
private final long inSize;
|
private final long inSize;
|
||||||
private final long duration;
|
private final long duration;
|
||||||
private final int inputBitRate;
|
private final int inputBitRate;
|
||||||
private final VideoBitRateCalculator.Quality targetQuality;
|
private final TranscodingQuality targetQuality;
|
||||||
private final long memoryFileEstimate;
|
private final long memoryFileEstimate;
|
||||||
private final boolean transcodeRequired;
|
private final boolean transcodeRequired;
|
||||||
private final long fileSizeEstimate;
|
private final long fileSizeEstimate;
|
||||||
@@ -148,13 +146,6 @@ public final class InMemoryTranscoder implements Closeable {
|
|||||||
|
|
||||||
memoryFile.seek(0);
|
memoryFile.seek(0);
|
||||||
|
|
||||||
SanitizedMetadata metadata = null;
|
|
||||||
try {
|
|
||||||
metadata = Mp4Sanitizer.sanitize(new FileInputStream(memoryFileFileDescriptor), memoryFile.size());
|
|
||||||
} catch (ParseException e) {
|
|
||||||
Log.e(TAG, "Could not parse MP4 file.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// output details of the transcoding
|
// output details of the transcoding
|
||||||
long outSize = memoryFile.size();
|
long outSize = memoryFile.size();
|
||||||
float encodeDurationSec = (System.currentTimeMillis() - startTime) / 1000f;
|
float encodeDurationSec = (System.currentTimeMillis() - startTime) / 1000f;
|
||||||
@@ -179,15 +170,31 @@ public final class InMemoryTranscoder implements Closeable {
|
|||||||
throw new VideoSizeException("Size constraints could not be met!");
|
throw new VideoSizeException("Size constraints could not be met!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Mp4FaststartPostProcessor postProcessor = new Mp4FaststartPostProcessor(() -> {
|
||||||
|
try {
|
||||||
|
memoryFile.seek(0);
|
||||||
|
return new FileInputStream(memoryFileFileDescriptor);
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "IOException thrown while creating FileInputStream.", e);
|
||||||
|
throw new VideoPostProcessingException("Exception while opening InputStream!", e);
|
||||||
|
}
|
||||||
|
}, memoryFile.size());
|
||||||
|
|
||||||
|
return new MediaStream(postProcessor.process(), MimeTypes.VIDEO_MP4, 0, 0, true);
|
||||||
|
} catch (VideoPostProcessingException e) {
|
||||||
|
Log.w(TAG, "Exception thrown during post processing.", e);
|
||||||
|
final Throwable cause = e.getCause();
|
||||||
|
if (cause instanceof IOException) {
|
||||||
|
throw (IOException) cause;
|
||||||
|
} else if ( cause instanceof EncodingException) {
|
||||||
|
throw (EncodingException) cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata != null && metadata.getSanitizedMetadata() != null) {
|
|
||||||
memoryFile.seek(metadata.getDataOffset());
|
|
||||||
return new MediaStream(new SequenceInputStream(new ByteArrayInputStream(metadata.getSanitizedMetadata()), ByteStreams.limit(new FileInputStream(memoryFileFileDescriptor), metadata.getDataLength())), MimeTypes.VIDEO_MP4, 0, 0, true);
|
|
||||||
} else {
|
|
||||||
memoryFile.seek(0);
|
memoryFile.seek(0);
|
||||||
return new MediaStream(new FileInputStream(memoryFileFileDescriptor), MimeTypes.VIDEO_MP4, 0, 0);
|
return new MediaStream(new FileInputStream(memoryFileFileDescriptor), MimeTypes.VIDEO_MP4, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isTranscodeRequired() {
|
public boolean isTranscodeRequired() {
|
||||||
return transcodeRequired;
|
return transcodeRequired;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -24,14 +27,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
|
|
||||||
android:name=".transcode.TranscodeTestActivity"
|
android:name=".transcode.TranscodeTestActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
android:parentActivityName=".MainActivity"
|
||||||
android:theme="@style/Theme.Signal" />
|
android:theme="@style/Theme.Signal" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".playback.PlaybackTestActivity"
|
android:name=".playback.PlaybackTestActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
android:parentActivityName=".MainActivity"
|
||||||
android:theme="@style/Theme.Signal" />
|
android:theme="@style/Theme.Signal" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.video.app.transcode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dumping ground for constants that should be referenced across the sample app.
|
||||||
|
*/
|
||||||
|
internal const val MIN_VIDEO_MEGABITRATE = 2f
|
||||||
|
internal const val DEFAULT_VIDEO_MEGABITRATE = 2f
|
||||||
|
internal const val MAX_VIDEO_MEGABITRATE = 10f
|
||||||
|
|
||||||
|
enum class VideoResolution(val longEdge: Int, val shortEdge: Int) {
|
||||||
|
SD(854, 480),
|
||||||
|
HD(1280, 720),
|
||||||
|
FHD(1920, 1080),
|
||||||
|
WQHD(2560, 1440),
|
||||||
|
UHD(3840, 2160);
|
||||||
|
|
||||||
|
fun getContentDescription(): String {
|
||||||
|
return "Resolution with a long edge of $longEdge and a short edge of $shortEdge."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2024 Signal Messenger, LLC
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.thoughtcrime.video.app.transcode
|
|
||||||
|
|
||||||
class TestTranscoder
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2024 Signal Messenger, LLC
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.thoughtcrime.video.app.transcode
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
data class TranscodeJobSnapshot(val media: Uri, val jobId: UUID)
|
|
||||||
@@ -5,8 +5,11 @@
|
|||||||
|
|
||||||
package org.thoughtcrime.video.app.transcode
|
package org.thoughtcrime.video.app.transcode
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -15,73 +18,64 @@ import androidx.activity.result.PickVisualMediaRequest
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ComposeView
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import org.thoughtcrime.video.app.R
|
||||||
import androidx.compose.ui.unit.dp
|
import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParameters
|
||||||
import androidx.compose.ui.unit.sp
|
import org.thoughtcrime.video.app.transcode.composables.SelectInput
|
||||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
import org.thoughtcrime.video.app.transcode.composables.SelectOutput
|
||||||
|
import org.thoughtcrime.video.app.transcode.composables.TranscodingJobProgress
|
||||||
import org.thoughtcrime.video.app.ui.theme.SignalTheme
|
import org.thoughtcrime.video.app.ui.theme.SignalTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual entry point for testing transcoding in the video sample app.
|
||||||
|
*/
|
||||||
class TranscodeTestActivity : AppCompatActivity() {
|
class TranscodeTestActivity : AppCompatActivity() {
|
||||||
private val viewModel: TranscodeTestViewModel by viewModels()
|
private val viewModel: TranscodeTestViewModel by viewModels()
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
viewModel.initialize(this)
|
viewModel.initialize(this)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val name = applicationContext.getString(R.string.channel_name)
|
||||||
|
val descriptionText = applicationContext.getString(R.string.channel_description)
|
||||||
|
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
val mChannel = NotificationChannel(getString(R.string.notification_channel_id), name, importance)
|
||||||
|
mChannel.description = descriptionText
|
||||||
|
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(mChannel)
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SignalTheme {
|
SignalTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
val videoUris = viewModel.selectedVideos
|
val videoUris = viewModel.selectedVideos
|
||||||
val outputDir = viewModel.outputDirectory
|
val outputDir = viewModel.outputDirectory
|
||||||
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList())
|
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList())
|
||||||
if (transcodingJobs.value.isNotEmpty()) {
|
if (transcodingJobs.value.isNotEmpty()) {
|
||||||
transcodingJobs.value.forEach { workInfo ->
|
TranscodingJobProgress(transcodingJobs = transcodingJobs.value, resetButtonOnClick = { viewModel.reset() })
|
||||||
val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(text = "...${workInfo.id.toString().takeLast(4)}", modifier = Modifier.padding(end = 16.dp).weight(1f))
|
|
||||||
if (workInfo.state.isFinished) {
|
|
||||||
LinearProgressIndicator(progress = 1f, trackColor = MaterialTheme.colorScheme.secondary, modifier = Modifier.weight(3f))
|
|
||||||
} else if (currentProgress >= 0) {
|
|
||||||
LinearProgressIndicator(progress = currentProgress / 100f, modifier = Modifier.weight(3f))
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.weight(3f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LabeledButton("Reset/Cancel") { viewModel.reset() }
|
|
||||||
} else if (videoUris.isEmpty()) {
|
} else if (videoUris.isEmpty()) {
|
||||||
LabeledButton("Select Videos") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
|
SelectInput { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
|
||||||
} else if (outputDir == null) {
|
} else if (outputDir == null) {
|
||||||
LabeledButton("Select Output Directory") { outputDirRequest.launch(null) }
|
SelectOutput { outputDirRequest.launch(null) }
|
||||||
} else {
|
} else {
|
||||||
Text(text = "Selected videos:", modifier = Modifier.align(Alignment.Start).padding(16.dp))
|
ConfigureEncodingParameters(
|
||||||
videoUris.forEach {
|
videoUris = videoUris,
|
||||||
Text(text = it.toString(), fontSize = 8.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.align(Alignment.Start).padding(horizontal = 16.dp))
|
onAutoSettingsCheckChanged = { viewModel.useAutoTranscodingSettings = it },
|
||||||
}
|
onRadioButtonSelected = { viewModel.videoResolution = it },
|
||||||
LabeledButton(buttonLabel = "Transcode") {
|
onSliderValueChanged = { viewModel.videoMegaBitrate = it },
|
||||||
|
onFastStartSettingCheckChanged = { viewModel.enableFastStart = it },
|
||||||
|
onSequentialSettingCheckChanged = { viewModel.forceSequentialQueueProcessing = it },
|
||||||
|
buttonClickListener = {
|
||||||
viewModel.transcode()
|
viewModel.transcode()
|
||||||
viewModel.selectedVideos = emptyList()
|
viewModel.selectedVideos = emptyList()
|
||||||
viewModel.resetOutputDirectory()
|
viewModel.resetOutputDirectory()
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import java.util.UUID
|
|||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository to perform various transcoding functions.
|
||||||
|
*/
|
||||||
class TranscodeTestRepository(context: Context) {
|
class TranscodeTestRepository(context: Context) {
|
||||||
private val workManager = WorkManager.getInstance(context)
|
private val workManager = WorkManager.getInstance(context)
|
||||||
private val usedNotificationIds = emptySet<Int>()
|
private val usedNotificationIds = emptySet<Int>()
|
||||||
|
|
||||||
fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri): Map<UUID, Uri> {
|
fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map<UUID, Uri> {
|
||||||
if (selectedVideos.isEmpty()) {
|
if (selectedVideos.isEmpty()) {
|
||||||
return emptyMap()
|
return emptyMap()
|
||||||
}
|
}
|
||||||
@@ -34,20 +37,35 @@ class TranscodeTestRepository(context: Context) {
|
|||||||
while (usedNotificationIds.contains(notificationId)) {
|
while (usedNotificationIds.contains(notificationId)) {
|
||||||
notificationId = Random.nextInt().absoluteValue
|
notificationId = Random.nextInt().absoluteValue
|
||||||
}
|
}
|
||||||
|
val inputData = Data.Builder()
|
||||||
|
.putString(TranscodeWorker.KEY_INPUT_URI, it.toString())
|
||||||
|
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString())
|
||||||
|
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
||||||
|
|
||||||
|
if (customTranscodingOptions != null) {
|
||||||
|
inputData.putInt(TranscodeWorker.KEY_LONG_EDGE, customTranscodingOptions.videoResolution.longEdge)
|
||||||
|
inputData.putInt(TranscodeWorker.KEY_SHORT_EDGE, customTranscodingOptions.videoResolution.shortEdge)
|
||||||
|
inputData.putInt(TranscodeWorker.KEY_BIT_RATE, customTranscodingOptions.bitrate)
|
||||||
|
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_FASTSTART, customTranscodingOptions.enableFastStart)
|
||||||
|
}
|
||||||
|
|
||||||
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
|
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
|
||||||
.setInputData(createInputDataForWorkRequest(it, outputDirectory, notificationId))
|
.setInputData(inputData.build())
|
||||||
.addTag(TRANSCODING_WORK_TAG)
|
.addTag(TRANSCODING_WORK_TAG)
|
||||||
.build()
|
.build()
|
||||||
it to transcodeRequest
|
it to transcodeRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
|
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
|
||||||
val requests = urisAndRequests.map { it.second }
|
val requests = urisAndRequests.map { it.second }
|
||||||
|
if (forceSequentialProcessing) {
|
||||||
var continuation = workManager.beginWith(requests.first())
|
var continuation = workManager.beginWith(requests.first())
|
||||||
for (request in requests.drop(1)) {
|
for (request in requests.drop(1)) {
|
||||||
continuation = continuation.then(request)
|
continuation = continuation.then(request)
|
||||||
}
|
}
|
||||||
continuation.enqueue()
|
continuation.enqueue()
|
||||||
|
} else {
|
||||||
|
workManager.enqueue(requests)
|
||||||
|
}
|
||||||
return idsToUris
|
return idsToUris
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,19 +76,6 @@ class TranscodeTestRepository(context: Context) {
|
|||||||
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
|
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the input data bundle which includes the blur level to
|
|
||||||
* update the amount of blur to be applied and the Uri to operate on
|
|
||||||
* @return Data which contains the Image Uri as a String and blur level as an Integer
|
|
||||||
*/
|
|
||||||
private fun createInputDataForWorkRequest(selectedVideo: Uri, outputUri: Uri, notificationId: Int): Data {
|
|
||||||
return Data.Builder()
|
|
||||||
.putString(TranscodeWorker.KEY_INPUT_URI, selectedVideo.toString())
|
|
||||||
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputUri.toString())
|
|
||||||
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelAllTranscodes() {
|
fun cancelAllTranscodes() {
|
||||||
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
|
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
|
||||||
workManager.pruneWork()
|
workManager.pruneWork()
|
||||||
@@ -112,6 +117,8 @@ class TranscodeTestRepository(context: Context) {
|
|||||||
|
|
||||||
private data class FileMetadata(val documentId: String, val label: String, val size: Long)
|
private data class FileMetadata(val documentId: String, val label: String, val size: Long)
|
||||||
|
|
||||||
|
data class CustomTranscodingOptions(val videoResolution: VideoResolution, val bitrate: Int, val enableFastStart: Boolean)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TranscodingTestRepository"
|
private const val TAG = "TranscodingTestRepository"
|
||||||
const val TRANSCODING_WORK_TAG = "transcoding"
|
const val TRANSCODING_WORK_TAG = "transcoding"
|
||||||
|
|||||||
@@ -15,14 +15,24 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel for the transcoding screen of the video sample app. See [TranscodeTestActivity].
|
||||||
|
*/
|
||||||
class TranscodeTestViewModel : ViewModel() {
|
class TranscodeTestViewModel : ViewModel() {
|
||||||
private lateinit var repository: TranscodeTestRepository
|
private lateinit var repository: TranscodeTestRepository
|
||||||
private var backPressedRunnable = {}
|
private var backPressedRunnable = {}
|
||||||
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
|
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
|
||||||
|
|
||||||
var outputDirectory: Uri? by mutableStateOf(null)
|
var outputDirectory: Uri? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
|
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
|
||||||
|
var videoMegaBitrate = DEFAULT_VIDEO_MEGABITRATE
|
||||||
|
var videoResolution = VideoResolution.HD
|
||||||
|
var useAutoTranscodingSettings = true
|
||||||
|
var enableFastStart = true
|
||||||
|
var forceSequentialQueueProcessing = false
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
repository = TranscodeTestRepository(context)
|
repository = TranscodeTestRepository(context)
|
||||||
@@ -31,7 +41,11 @@ class TranscodeTestViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun transcode() {
|
fun transcode() {
|
||||||
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
|
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
|
||||||
transcodingJobs = repository.transcode(selectedVideos, output)
|
if (useAutoTranscodingSettings) {
|
||||||
|
transcodingJobs = repository.transcode(selectedVideos, output, forceSequentialQueueProcessing, null)
|
||||||
|
} else {
|
||||||
|
transcodingJobs = repository.transcode(selectedVideos, output, forceSequentialQueueProcessing, TranscodeTestRepository.CustomTranscodingOptions(videoResolution, (videoMegaBitrate * MEGABIT).roundToInt(), enableFastStart))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
|
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
|
||||||
@@ -61,4 +75,8 @@ class TranscodeTestViewModel : ViewModel() {
|
|||||||
fun resetOutputDirectory() {
|
fun resetOutputDirectory() {
|
||||||
outputDirectory = null
|
outputDirectory = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MEGABIT = 1000000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,15 @@
|
|||||||
|
|
||||||
package org.thoughtcrime.video.app.transcode
|
package org.thoughtcrime.video.app.transcode
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
import android.app.PendingIntent
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.NOTIFICATION_SERVICE
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.TaskStackBuilder
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
@@ -24,70 +23,118 @@ import androidx.work.WorkManager
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import org.signal.core.util.getLength
|
import org.signal.core.util.getLength
|
||||||
import org.thoughtcrime.securesms.video.StreamingTranscoder
|
import org.thoughtcrime.securesms.video.StreamingTranscoder
|
||||||
|
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.VideoConstants
|
import org.thoughtcrime.securesms.video.videoconverter.VideoConstants
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
|
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
|
||||||
import org.thoughtcrime.video.app.R
|
import org.thoughtcrime.video.app.R
|
||||||
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
/**
|
||||||
|
* A WorkManager worker to transcode videos in the background. This utilizes [StreamingTranscoder].
|
||||||
|
*/
|
||||||
|
class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
val logPrefix = "[Job ${id.toString().takeLast(4)}]"
|
||||||
val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1)
|
val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1)
|
||||||
if (notificationId < 0) {
|
if (notificationId < 0) {
|
||||||
Log.w(TAG, "Notification ID was null!")
|
Log.w(TAG, "$logPrefix Notification ID was null!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val inputUri = inputData.getString(KEY_INPUT_URI)
|
val inputUri = inputData.getString(KEY_INPUT_URI)
|
||||||
if (inputUri == null) {
|
if (inputUri == null) {
|
||||||
Log.w(TAG, "Input URI was null!")
|
Log.w(TAG, "$logPrefix Input URI was null!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputDirUri = inputData.getString(KEY_OUTPUT_URI)
|
val outputDirUri = inputData.getString(KEY_OUTPUT_URI)
|
||||||
if (outputDirUri == null) {
|
if (outputDirUri == null) {
|
||||||
Log.w(TAG, "Output URI was null!")
|
Log.w(TAG, "$logPrefix Output URI was null!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val input = DocumentFile.fromSingleUri(ctx, Uri.parse(inputUri))?.name
|
val postProcessForFastStart = inputData.getBoolean(KEY_ENABLE_FASTSTART, true)
|
||||||
|
val resolution = inputData.getInt(KEY_SHORT_EDGE, -1)
|
||||||
|
val desiredBitrate = inputData.getInt(KEY_BIT_RATE, -1)
|
||||||
|
|
||||||
|
val input = DocumentFile.fromSingleUri(applicationContext, Uri.parse(inputUri))?.name
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
Log.w(TAG, "Could not read input file name!")
|
Log.w(TAG, "$logPrefix Could not read input file name!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputFileUri = createFile(Uri.parse(outputDirUri), "transcoded-${Instant.now()}-$input$OUTPUT_FILE_EXTENSION")
|
val filenameBase = "transcoded-${Instant.now()}-$input"
|
||||||
|
val tempFilename = "$filenameBase$TEMP_FILE_EXTENSION"
|
||||||
|
val finalFilename = "$filenameBase$OUTPUT_FILE_EXTENSION"
|
||||||
|
|
||||||
if (outputFileUri == null) {
|
val tempFile = createFile(Uri.parse(outputDirUri), tempFilename)
|
||||||
Log.w(TAG, "Could not create output file!")
|
if (tempFile == null) {
|
||||||
|
Log.w(TAG, "$logPrefix Could not create temp file!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val datasource = WorkerMediaDataSource(ctx, Uri.parse(inputUri))
|
val datasource = WorkerMediaDataSource(applicationContext, Uri.parse(inputUri))
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
Log.w(TAG, "Transcoder is only supported on API 26+!")
|
Log.w(TAG, "$logPrefix Transcoder is only supported on API 26+!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
val transcoder = StreamingTranscoder(datasource, null, 50 * 1024 * 1024) // TODO: set options
|
|
||||||
|
val transcoder = if (resolution > 0 && desiredBitrate > 0) {
|
||||||
|
StreamingTranscoder(datasource, null, desiredBitrate, resolution)
|
||||||
|
} else {
|
||||||
|
StreamingTranscoder(datasource, null, DEFAULT_FILE_SIZE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
setForeground(createForegroundInfo(-1, notificationId))
|
setForeground(createForegroundInfo(-1, notificationId))
|
||||||
ctx.contentResolver.openFileDescriptor(outputFileUri, "w").use { it: ParcelFileDescriptor? ->
|
|
||||||
if (it == null) {
|
applicationContext.contentResolver.openFileDescriptor(tempFile.uri, "w").use { tempFd ->
|
||||||
Log.w(TAG, "Could not open output file for writing!")
|
if (tempFd == null) {
|
||||||
|
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
transcoder.transcode(
|
transcoder.transcode({ percent: Int ->
|
||||||
{ percent: Int ->
|
|
||||||
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
|
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
|
||||||
setForegroundAsync(createForegroundInfo(percent, notificationId))
|
setForegroundAsync(createForegroundInfo(percent, notificationId))
|
||||||
},
|
}, FileOutputStream(tempFd.fileDescriptor), { isStopped })
|
||||||
FileOutputStream(it.fileDescriptor),
|
|
||||||
{ isStopped }
|
|
||||||
)
|
|
||||||
return Result.success()
|
|
||||||
}
|
}
|
||||||
|
Log.v(TAG, "$logPrefix Initial transcode completed successfully!")
|
||||||
|
if (!postProcessForFastStart) {
|
||||||
|
tempFile.renameTo(finalFilename)
|
||||||
|
Log.v(TAG, "$logPrefix Rename successful.")
|
||||||
|
} else {
|
||||||
|
applicationContext.contentResolver.openFileDescriptor(tempFile.uri, "r").use { tempFd ->
|
||||||
|
if (tempFd == null) {
|
||||||
|
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalFile = createFile(Uri.parse(outputDirUri), finalFilename)
|
||||||
|
if (finalFile == null) {
|
||||||
|
Log.w(TAG, "$logPrefix Could not create final file for faststart processing!")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
applicationContext.contentResolver.openFileDescriptor(finalFile.uri, "w").use { finalFd ->
|
||||||
|
if (finalFd == null) {
|
||||||
|
Log.w(TAG, "$logPrefix Could not open output file for I/O!")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
Mp4FaststartPostProcessor({ FileInputStream(tempFd.fileDescriptor) }, tempFd.statSize).processAndWriteTo(FileOutputStream(finalFd.fileDescriptor))
|
||||||
|
|
||||||
|
if (!tempFile.delete()) {
|
||||||
|
Log.w(TAG, "$logPrefix Failed to delete temp file after processing!")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.v(TAG, "$logPrefix Faststart postprocess successful.")
|
||||||
|
}
|
||||||
|
Log.v(TAG, "$logPrefix Overall transcode job successful.")
|
||||||
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo {
|
private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo {
|
||||||
@@ -96,23 +143,19 @@ class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : Coro
|
|||||||
val cancel = applicationContext.getString(R.string.cancel_transcode)
|
val cancel = applicationContext.getString(R.string.cancel_transcode)
|
||||||
val intent = WorkManager.getInstance(applicationContext)
|
val intent = WorkManager.getInstance(applicationContext)
|
||||||
.createCancelPendingIntent(getId())
|
.createCancelPendingIntent(getId())
|
||||||
|
val transcodeActivityIntent = Intent(applicationContext, TranscodeTestActivity::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
val pendingIntent: PendingIntent? = TaskStackBuilder.create(applicationContext).run {
|
||||||
val name = applicationContext.getString(R.string.channel_name)
|
addNextIntentWithParentStack(transcodeActivityIntent)
|
||||||
val descriptionText = applicationContext.getString(R.string.channel_description)
|
getPendingIntent(0,
|
||||||
val importance = NotificationManager.IMPORTANCE_LOW
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
val mChannel = NotificationChannel(id, name, importance)
|
|
||||||
mChannel.description = descriptionText
|
|
||||||
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
notificationManager.createNotificationChannel(mChannel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, id)
|
val notification = NotificationCompat.Builder(applicationContext, id)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setTicker(title)
|
.setTicker(title)
|
||||||
.setProgress(100, progress, progress >= 0)
|
.setProgress(100, progress, progress <= 0)
|
||||||
.setSmallIcon(R.drawable.ic_work_notification)
|
.setSmallIcon(R.drawable.ic_work_notification)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_delete, cancel, intent)
|
.addAction(android.R.drawable.ic_delete, cancel, intent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -123,8 +166,8 @@ class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : Coro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createFile(treeUri: Uri, filename: String): Uri? {
|
private fun createFile(treeUri: Uri, filename: String): DocumentFile? {
|
||||||
return DocumentFile.fromTreeUri(ctx, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)?.uri
|
return DocumentFile.fromTreeUri(applicationContext, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() {
|
private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() {
|
||||||
@@ -154,9 +197,15 @@ class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : Coro
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TranscodeWorker"
|
private const val TAG = "TranscodeWorker"
|
||||||
private const val OUTPUT_FILE_EXTENSION = ".mp4"
|
private const val OUTPUT_FILE_EXTENSION = ".mp4"
|
||||||
|
private const val TEMP_FILE_EXTENSION = ".tmp"
|
||||||
|
private const val DEFAULT_FILE_SIZE_LIMIT: Long = 50 * 1024 * 1024
|
||||||
const val KEY_INPUT_URI = "input_uri"
|
const val KEY_INPUT_URI = "input_uri"
|
||||||
const val KEY_OUTPUT_URI = "output_uri"
|
const val KEY_OUTPUT_URI = "output_uri"
|
||||||
const val KEY_PROGRESS = "progress"
|
const val KEY_PROGRESS = "progress"
|
||||||
|
const val KEY_LONG_EDGE = "resolution_long_edge"
|
||||||
|
const val KEY_SHORT_EDGE = "resolution_short_edge"
|
||||||
|
const val KEY_BIT_RATE = "video_bit_rate"
|
||||||
|
const val KEY_ENABLE_FASTSTART = "video_enable_faststart"
|
||||||
const val KEY_NOTIFICATION_ID = "notification_id"
|
const val KEY_NOTIFICATION_ID = "notification_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.video.app.transcode.composables
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.thoughtcrime.video.app.transcode.DEFAULT_VIDEO_MEGABITRATE
|
||||||
|
import org.thoughtcrime.video.app.transcode.MAX_VIDEO_MEGABITRATE
|
||||||
|
import org.thoughtcrime.video.app.transcode.MIN_VIDEO_MEGABITRATE
|
||||||
|
import org.thoughtcrime.video.app.transcode.VideoResolution
|
||||||
|
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view that shows the queue of video URIs to encode, and allows you to change the encoding options.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConfigureEncodingParameters(
|
||||||
|
videoUris: List<Uri>,
|
||||||
|
onAutoSettingsCheckChanged: (Boolean) -> Unit,
|
||||||
|
onRadioButtonSelected: (VideoResolution) -> Unit,
|
||||||
|
onSliderValueChanged: (Float) -> Unit,
|
||||||
|
onFastStartSettingCheckChanged: (Boolean) -> Unit,
|
||||||
|
onSequentialSettingCheckChanged: (Boolean) -> Unit,
|
||||||
|
buttonClickListener: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
initialSettingsAutoSelected: Boolean = true
|
||||||
|
) {
|
||||||
|
var sliderPosition by remember { mutableFloatStateOf(DEFAULT_VIDEO_MEGABITRATE) }
|
||||||
|
var selectedResolution by remember { mutableStateOf(VideoResolution.HD) }
|
||||||
|
val autoSettingsChecked = remember { mutableStateOf(initialSettingsAutoSelected) }
|
||||||
|
val fastStartChecked = remember { mutableStateOf(true) }
|
||||||
|
val sequentialProcessingChecked = remember { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Selected videos:",
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Start)
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
videoUris.forEach {
|
||||||
|
Text(
|
||||||
|
text = it.toString(),
|
||||||
|
fontSize = 8.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Start)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = autoSettingsChecked.value,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
autoSettingsChecked.value = isChecked
|
||||||
|
onAutoSettingsCheckChanged(isChecked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Calculate Output Settings Automatically",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!autoSettingsChecked.value) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup()
|
||||||
|
) {
|
||||||
|
VideoResolution.values().forEach {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.selectable(
|
||||||
|
selected = selectedResolution == it,
|
||||||
|
onClick = {
|
||||||
|
selectedResolution = it
|
||||||
|
onRadioButtonSelected(it)
|
||||||
|
},
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedResolution == it,
|
||||||
|
onClick = null,
|
||||||
|
modifier = Modifier.semantics { contentDescription = it.getContentDescription() }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${it.shortEdge}p",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = sliderPosition,
|
||||||
|
onValueChange = {
|
||||||
|
sliderPosition = it
|
||||||
|
onSliderValueChanged(it)
|
||||||
|
},
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
activeTrackColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
),
|
||||||
|
steps = 5,
|
||||||
|
valueRange = MIN_VIDEO_MEGABITRATE..MAX_VIDEO_MEGABITRATE,
|
||||||
|
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
Text(text = "${sliderPosition.roundToInt()} Mbit/s")
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = fastStartChecked.value,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
fastStartChecked.value = isChecked
|
||||||
|
onFastStartSettingCheckChanged(isChecked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(text = "Enable postprocessing for faststart", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = sequentialProcessingChecked.value,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
sequentialProcessingChecked.value = isChecked
|
||||||
|
onSequentialSettingCheckChanged(isChecked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(text = "Force Sequential Queue Processing", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LabeledButton(buttonLabel = "Transcode", onClick = buttonClickListener, modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ConfigurationScreenPreviewChecked() {
|
||||||
|
ConfigureEncodingParameters(
|
||||||
|
videoUris = listOf(Uri.parse("content://1"), Uri.parse("content://2")),
|
||||||
|
onAutoSettingsCheckChanged = {},
|
||||||
|
onRadioButtonSelected = {},
|
||||||
|
onSliderValueChanged = {},
|
||||||
|
onFastStartSettingCheckChanged = {},
|
||||||
|
onSequentialSettingCheckChanged = {},
|
||||||
|
buttonClickListener = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ConfigurationScreenPreviewUnchecked() {
|
||||||
|
ConfigureEncodingParameters(
|
||||||
|
videoUris = listOf(Uri.parse("content://1"), Uri.parse("content://2")),
|
||||||
|
onAutoSettingsCheckChanged = {},
|
||||||
|
onRadioButtonSelected = {},
|
||||||
|
onSliderValueChanged = {},
|
||||||
|
onFastStartSettingCheckChanged = {},
|
||||||
|
onSequentialSettingCheckChanged = {},
|
||||||
|
buttonClickListener = {},
|
||||||
|
initialSettingsAutoSelected = false
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.video.app.transcode.composables
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view that prompts you to select input videos for transcoding.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SelectInput(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
LabeledButton("Select Videos", onClick = onClick, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun InputSelectionPreview() {
|
||||||
|
SelectInput { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.video.app.transcode.composables
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view that prompts you to select an output directory that transcoded videos will be saved to.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SelectOutput(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
LabeledButton("Select Output Directory", onClick = onClick, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun OutputSelectionPreview() {
|
||||||
|
SelectOutput { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.video.app.transcode.composables
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import org.thoughtcrime.video.app.transcode.TranscodeWorker
|
||||||
|
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view that shows the current encodes in progress.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TranscodingJobProgress(transcodingJobs: List<WorkInfo>, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
transcodingJobs.forEach { workInfo ->
|
||||||
|
val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
val progressIndicatorModifier = Modifier.weight(3f)
|
||||||
|
Text(
|
||||||
|
text = "Job ${workInfo.id.toString().takeLast(4)}",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
if (workInfo.state.isFinished) {
|
||||||
|
Text(text = workInfo.state.toString(), textAlign = TextAlign.Center, modifier = progressIndicatorModifier)
|
||||||
|
} else if (currentProgress >= 0) {
|
||||||
|
LinearProgressIndicator(progress = currentProgress / 100f, modifier = progressIndicatorModifier)
|
||||||
|
} else {
|
||||||
|
LinearProgressIndicator(modifier = progressIndicatorModifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LabeledButton("Reset/Cancel", onClick = resetButtonOnClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ProgressScreenPreview() {
|
||||||
|
TranscodingJobProgress(emptyList(), resetButtonOnClick = {})
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core-util"))
|
implementation(project(":core-util"))
|
||||||
|
implementation(libs.libsignal.android)
|
||||||
|
implementation(libs.google.guava.android)
|
||||||
|
|
||||||
implementation(libs.bundles.mp4parser) {
|
implementation(libs.bundles.mp4parser) {
|
||||||
exclude(group = "junit", module = "junit")
|
exclude(group = "junit", module = "junit")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.video.exceptions.VideoSizeException;
|
|||||||
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
import org.thoughtcrime.securesms.video.videoconverter.EncodingException;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter;
|
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter;
|
||||||
|
import org.thoughtcrime.securesms.video.videoconverter.VideoConstants;
|
||||||
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput;
|
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput;
|
||||||
|
|
||||||
import java.io.FilterOutputStream;
|
import java.io.FilterOutputStream;
|
||||||
@@ -30,7 +31,7 @@ public final class StreamingTranscoder {
|
|||||||
private final long inSize;
|
private final long inSize;
|
||||||
private final long duration;
|
private final long duration;
|
||||||
private final int inputBitRate;
|
private final int inputBitRate;
|
||||||
private final VideoBitRateCalculator.Quality targetQuality;
|
private final TranscodingQuality targetQuality;
|
||||||
private final boolean transcodeRequired;
|
private final boolean transcodeRequired;
|
||||||
private final long fileSizeEstimate;
|
private final long fileSizeEstimate;
|
||||||
private final @Nullable TranscoderOptions options;
|
private final @Nullable TranscoderOptions options;
|
||||||
@@ -68,6 +69,34 @@ public final class StreamingTranscoder {
|
|||||||
this.fileSizeEstimate = targetQuality.getFileSizeEstimate();
|
this.fileSizeEstimate = targetQuality.getFileSizeEstimate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public StreamingTranscoder(@NonNull MediaDataSource dataSource,
|
||||||
|
@Nullable TranscoderOptions options,
|
||||||
|
int videoBitrate,
|
||||||
|
int shortEdge)
|
||||||
|
throws IOException, VideoSourceException
|
||||||
|
{
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.options = options;
|
||||||
|
|
||||||
|
final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
|
||||||
|
try {
|
||||||
|
mediaMetadataRetriever.setDataSource(dataSource);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
Log.w(TAG, "Unable to read datasource", e);
|
||||||
|
throw new VideoSourceException("Unable to read datasource", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inSize = dataSource.getSize();
|
||||||
|
this.duration = getDuration(mediaMetadataRetriever);
|
||||||
|
this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration);
|
||||||
|
this.targetQuality = new TranscodingQuality(videoBitrate, VideoConstants.AUDIO_BIT_RATE, 1.0, duration, shortEdge);
|
||||||
|
this.upperSizeLimit = Long.MAX_VALUE;
|
||||||
|
|
||||||
|
this.transcodeRequired = true;
|
||||||
|
|
||||||
|
this.fileSizeEstimate = targetQuality.getFileSizeEstimate();
|
||||||
|
}
|
||||||
|
|
||||||
public void transcode(@NonNull Progress progress,
|
public void transcode(@NonNull Progress progress,
|
||||||
@NonNull OutputStream stream,
|
@NonNull OutputStream stream,
|
||||||
@Nullable TranscoderCancelationSignal cancelationSignal)
|
@Nullable TranscoderCancelationSignal cancelationSignal)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
package org.thoughtcrime.securesms.video
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data class to hold various video transcoding parameters, such as bitrate.
|
||||||
|
*/
|
||||||
|
data class TranscodingQuality(val targetVideoBitRate: Int, val targetAudioBitRate: Int, val quality: Double, private val duration: Long, val outputResolution: Int) {
|
||||||
|
init {
|
||||||
|
if (quality < 0.0 || quality > 1.0) {
|
||||||
|
throw IllegalArgumentException("Quality $quality is outside of accepted range [0.0, 1.0]!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetTotalBitRate = targetVideoBitRate + targetAudioBitRate
|
||||||
|
val fileSizeEstimate = targetTotalBitRate * duration / 8000
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "Quality{" +
|
||||||
|
"targetVideoBitRate=" + targetVideoBitRate +
|
||||||
|
", targetAudioBitRate=" + targetAudioBitRate +
|
||||||
|
", quality=" + quality +
|
||||||
|
", duration=" + duration +
|
||||||
|
", filesize=" + fileSizeEstimate +
|
||||||
|
'}'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,6 @@ public final class VideoBitRateCalculator {
|
|||||||
private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoConstants.VIDEO_BIT_RATE;
|
private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoConstants.VIDEO_BIT_RATE;
|
||||||
private static final int LOW_RES_TARGET_VIDEO_BITRATE = 1_750_000;
|
private static final int LOW_RES_TARGET_VIDEO_BITRATE = 1_750_000;
|
||||||
private static final int MINIMUM_TARGET_VIDEO_BITRATE = 500_000;
|
private static final int MINIMUM_TARGET_VIDEO_BITRATE = 500_000;
|
||||||
private static final int AUDIO_BITRATE = VideoConstants.AUDIO_BIT_RATE;
|
|
||||||
private static final int OUTPUT_FORMAT = VideoConstants.VIDEO_SHORT_EDGE;
|
|
||||||
private static final int LOW_RES_OUTPUT_FORMAT = 480;
|
private static final int LOW_RES_OUTPUT_FORMAT = 480;
|
||||||
|
|
||||||
private final long upperFileSizeLimitWithMargin;
|
private final long upperFileSizeLimitWithMargin;
|
||||||
@@ -23,21 +21,22 @@ public final class VideoBitRateCalculator {
|
|||||||
/**
|
/**
|
||||||
* Gets the output quality of a video of the given {@param duration}.
|
* Gets the output quality of a video of the given {@param duration}.
|
||||||
*/
|
*/
|
||||||
public Quality getTargetQuality(long duration, int inputTotalBitRate) {
|
public TranscodingQuality getTargetQuality(long duration, int inputTotalBitRate) {
|
||||||
int maxVideoBitRate = Math.min(MAXIMUM_TARGET_VIDEO_BITRATE, inputTotalBitRate - AUDIO_BITRATE);
|
int maxVideoBitRate = Math.min(MAXIMUM_TARGET_VIDEO_BITRATE, inputTotalBitRate - VideoConstants.AUDIO_BIT_RATE);
|
||||||
int minVideoBitRate = Math.min(MINIMUM_TARGET_VIDEO_BITRATE, maxVideoBitRate);
|
int minVideoBitRate = Math.min(MINIMUM_TARGET_VIDEO_BITRATE, maxVideoBitRate);
|
||||||
|
|
||||||
int targetVideoBitRate = Math.max(minVideoBitRate, Math.min(getTargetVideoBitRate(upperFileSizeLimitWithMargin, duration), maxVideoBitRate));
|
int targetVideoBitRate = Math.max(minVideoBitRate, Math.min(getTargetVideoBitRate(upperFileSizeLimitWithMargin, duration), maxVideoBitRate));
|
||||||
int bitRateRange = maxVideoBitRate - minVideoBitRate;
|
int bitRateRange = maxVideoBitRate - minVideoBitRate;
|
||||||
double quality = bitRateRange == 0 ? 1 : (targetVideoBitRate - minVideoBitRate) / (double) bitRateRange;
|
double quality = bitRateRange == 0 ? 1 : (targetVideoBitRate - minVideoBitRate) / (double) bitRateRange;
|
||||||
|
int outputResolution = targetVideoBitRate < LOW_RES_TARGET_VIDEO_BITRATE ? LOW_RES_OUTPUT_FORMAT : VideoConstants.VIDEO_SHORT_EDGE;
|
||||||
|
|
||||||
return new Quality(targetVideoBitRate, AUDIO_BITRATE, quality, duration);
|
return new TranscodingQuality(targetVideoBitRate, VideoConstants.AUDIO_BIT_RATE, Math.max(0, Math.min(quality, 1)), duration, outputResolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getTargetVideoBitRate(long sizeGuideBytes, long duration) {
|
private int getTargetVideoBitRate(long sizeGuideBytes, long duration) {
|
||||||
double durationSeconds = duration / 1000d;
|
double durationSeconds = duration / 1000d;
|
||||||
|
|
||||||
sizeGuideBytes -= durationSeconds * AUDIO_BITRATE / 8;
|
sizeGuideBytes -= durationSeconds * VideoConstants.AUDIO_BIT_RATE / 8;
|
||||||
|
|
||||||
double targetAttachmentSizeBits = sizeGuideBytes * 8L;
|
double targetAttachmentSizeBits = sizeGuideBytes * 8L;
|
||||||
|
|
||||||
@@ -48,63 +47,4 @@ public final class VideoBitRateCalculator {
|
|||||||
return (int) (bytes * 8 / (durationMs / 1000f));
|
return (int) (bytes * 8 / (durationMs / 1000f));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Quality {
|
|
||||||
private final int targetVideoBitRate;
|
|
||||||
private final int targetAudioBitRate;
|
|
||||||
private final double quality;
|
|
||||||
private final long duration;
|
|
||||||
|
|
||||||
private Quality(int targetVideoBitRate, int targetAudioBitRate, double quality, long duration) {
|
|
||||||
this.targetVideoBitRate = targetVideoBitRate;
|
|
||||||
this.targetAudioBitRate = targetAudioBitRate;
|
|
||||||
this.quality = Math.max(0, Math.min(quality, 1));
|
|
||||||
this.duration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [0..1]
|
|
||||||
* <p>
|
|
||||||
* 0 = {@link #MINIMUM_TARGET_VIDEO_BITRATE}
|
|
||||||
* 1 = {@link #MAXIMUM_TARGET_VIDEO_BITRATE}
|
|
||||||
*/
|
|
||||||
public double getQuality() {
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTargetVideoBitRate() {
|
|
||||||
return targetVideoBitRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTargetAudioBitRate() {
|
|
||||||
return targetAudioBitRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTargetTotalBitRate() {
|
|
||||||
return targetVideoBitRate + targetAudioBitRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean useLowRes() {
|
|
||||||
return targetVideoBitRate < LOW_RES_TARGET_VIDEO_BITRATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getOutputResolution() {
|
|
||||||
return useLowRes() ? LOW_RES_OUTPUT_FORMAT
|
|
||||||
: OUTPUT_FORMAT;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getFileSizeEstimate() {
|
|
||||||
return getTargetTotalBitRate() * duration / 8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "Quality{" +
|
|
||||||
"targetVideoBitRate=" + targetVideoBitRate +
|
|
||||||
", targetAudioBitRate=" + targetAudioBitRate +
|
|
||||||
", quality=" + quality +
|
|
||||||
", duration=" + duration +
|
|
||||||
", filesize=" + getFileSizeEstimate() +
|
|
||||||
'}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.securesms.video.exceptions
|
||||||
|
|
||||||
|
class VideoPostProcessingException : RuntimeException {
|
||||||
|
internal constructor(message: String?) : super(message)
|
||||||
|
internal constructor(message: String?, inner: Exception?) : super(message, inner)
|
||||||
|
}
|
||||||
@@ -9,4 +9,4 @@ import java.io.IOException
|
|||||||
/**
|
/**
|
||||||
* Exception to denote when video processing has been unable to meet its output file size requirements.
|
* Exception to denote when video processing has been unable to meet its output file size requirements.
|
||||||
*/
|
*/
|
||||||
class VideoSizeException internal constructor(message: String?) : IOException(message)
|
class VideoSizeException(message: String?) : IOException(message)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.securesms.video.postprocessing
|
||||||
|
|
||||||
|
import com.google.common.io.ByteStreams
|
||||||
|
import org.signal.libsignal.media.Mp4Sanitizer
|
||||||
|
import org.signal.libsignal.media.SanitizedMetadata
|
||||||
|
import org.thoughtcrime.securesms.video.exceptions.VideoPostProcessingException
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.io.SequenceInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post processor that takes a stream of bytes, and using [Mp4Sanitizer], moves the metadata to the front of the file.
|
||||||
|
*
|
||||||
|
* @property inputStreamFactory factory for the [InputStream]. May be called multiple times.
|
||||||
|
* @property inputLength the exact stream of the [InputStream]
|
||||||
|
*/
|
||||||
|
class Mp4FaststartPostProcessor(private val inputStreamFactory: () -> InputStream, private val inputLength: Long) {
|
||||||
|
fun process(): InputStream {
|
||||||
|
val metadata: SanitizedMetadata? = Mp4Sanitizer.sanitize(inputStreamFactory(), inputLength)
|
||||||
|
|
||||||
|
if (metadata?.sanitizedMetadata == null) {
|
||||||
|
throw VideoPostProcessingException("Mp4Sanitizer could not parse media metadata!")
|
||||||
|
}
|
||||||
|
|
||||||
|
val inputStream = inputStreamFactory()
|
||||||
|
inputStream.skip(metadata.dataOffset)
|
||||||
|
return SequenceInputStream(ByteArrayInputStream(metadata.sanitizedMetadata), ByteStreams.limit(inputStream, metadata.dataLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processAndWriteTo(outputStream: OutputStream) {
|
||||||
|
process().copyTo(outputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "Mp4Faststart"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user