StreamingTranscoder sample app.

This commit is contained in:
Nicholas Tinsley
2024-01-12 18:18:01 -05:00
committed by Greyson Parrelli
parent 750fd4efe1
commit c7609f9a2a
46 changed files with 880 additions and 254 deletions

View File

@@ -3,8 +3,9 @@
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<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">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -23,6 +24,7 @@
</intent-filter>
</activity>
<activity
android:name=".transcode.TranscodeTestActivity"
android:exported="false"
android:theme="@style/Theme.Signal" />
@@ -30,6 +32,10 @@
android:name=".playback.PlaybackTestActivity"
android:exported="false"
android:theme="@style/Theme.Signal" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

@@ -76,11 +76,11 @@ class PlaybackTestActivity : AppCompatActivity() {
*/
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
Log.d("PhotoPicker", "Selected URI: $uri")
Log.d("PlaybackPicker", "Selected URI: $uri")
viewModel.selectedVideo = uri
viewModel.updateMediaSource(this)
} else {
Log.d("PhotoPicker", "No media selected")
Log.d("PlaybackPicker", "No media selected")
}
}
}

View File

@@ -0,0 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
class TestTranscoder

View File

@@ -0,0 +1,11 @@
/*
* 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)

View File

@@ -1,10 +1,117 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
* 2SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
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.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.thoughtcrime.video.app.ui.composables.LabeledButton
import org.thoughtcrime.video.app.ui.theme.SignalTheme
class TranscodeTestActivity : AppCompatActivity()
class TranscodeTestActivity : AppCompatActivity() {
private val viewModel: TranscodeTestViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.initialize(this)
setContent {
SignalTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val videoUris = viewModel.selectedVideos
val outputDir = viewModel.outputDirectory
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList())
if (transcodingJobs.value.isNotEmpty()) {
transcodingJobs.value.forEach { workInfo ->
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()) {
LabeledButton("Select Videos") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
} else if (outputDir == null) {
LabeledButton("Select Output Directory") { outputDirRequest.launch(null) }
} else {
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))
}
LabeledButton(buttonLabel = "Transcode") {
viewModel.transcode()
viewModel.selectedVideos = emptyList()
viewModel.resetOutputDirectory()
}
}
}
}
}
}
getComposeView()?.keepScreenOn = true
}
/**
* This launches the system media picker and stores the resulting URI.
*/
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
Log.d("VideoPicker", "Selected URI: $uris")
viewModel.selectedVideos = uris
viewModel.resetOutputDirectory()
} else {
Log.d("VideoPicker", "No media selected")
}
}
private val outputDirRequest = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
uri?.let {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
viewModel.setOutputDirectoryAndCleanFailedTranscodes(this, it)
}
}
private fun getComposeView(): ComposeView? {
return window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.signal.core.util.readToList
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.random.Random
class TranscodeTestRepository(context: Context) {
private val workManager = WorkManager.getInstance(context)
private val usedNotificationIds = emptySet<Int>()
fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri): Map<UUID, Uri> {
if (selectedVideos.isEmpty()) {
return emptyMap()
}
val urisAndRequests = selectedVideos.map {
var notificationId = Random.nextInt().absoluteValue
while (usedNotificationIds.contains(notificationId)) {
notificationId = Random.nextInt().absoluteValue
}
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
.setInputData(createInputDataForWorkRequest(it, outputDirectory, notificationId))
.addTag(TRANSCODING_WORK_TAG)
.build()
it to transcodeRequest
}
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
val requests = urisAndRequests.map { it.second }
var continuation = workManager.beginWith(requests.first())
for (request in requests.drop(1)) {
continuation = continuation.then(request)
}
continuation.enqueue()
return idsToUris
}
fun getTranscodingJobsAsFlow(jobIds: List<UUID>): Flow<MutableList<WorkInfo>> {
if (jobIds.isEmpty()) {
return emptyFlow()
}
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() {
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
workManager.pruneWork()
}
fun cleanFailedTranscodes(context: Context, folderUri: Uri) {
val docs = queryChildDocuments(context, folderUri)
docs.filter { it.documentId.endsWith(".mp4") && it.size == 0L }.forEach {
val fileUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, it.documentId)
DocumentsContract.deleteDocument(context.contentResolver, fileUri)
}
}
private fun queryChildDocuments(context: Context, folderUri: Uri): List<FileMetadata> {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
folderUri,
DocumentsContract.getTreeDocumentId(folderUri)
)
context.contentResolver.query(
childrenUri,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE),
null,
null,
null
).use { cursor ->
if (cursor == null) {
return emptyList()
}
return cursor.readToList {
FileMetadata(
documentId = it.getString(0),
label = it.getString(1),
size = it.getLong(2)
)
}
}
}
private data class FileMetadata(val documentId: String, val label: String, val size: Long)
companion object {
private const val TAG = "TranscodingTestRepository"
const val TRANSCODING_WORK_TAG = "transcoding"
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import kotlinx.coroutines.flow.Flow
import java.util.UUID
class TranscodeTestViewModel : ViewModel() {
private lateinit var repository: TranscodeTestRepository
private var backPressedRunnable = {}
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
var outputDirectory: Uri? by mutableStateOf(null)
private set
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
fun initialize(context: Context) {
repository = TranscodeTestRepository(context)
backPressedRunnable = { Toast.makeText(context, "Cancelling all transcoding jobs!", Toast.LENGTH_LONG).show() }
}
fun transcode() {
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
transcodingJobs = repository.transcode(selectedVideos, output)
}
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList())
}
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
outputDirectory = folderUri
repository.cleanFailedTranscodes(context, folderUri)
}
fun getUriFromJobId(jobId: UUID): Uri? {
return transcodingJobs[jobId]
}
fun reset() {
cancelAllTranscodes()
resetOutputDirectory()
selectedVideos = emptyList()
}
fun cancelAllTranscodes() {
repository.cancelAllTranscodes()
transcodingJobs = emptyMap()
}
fun resetOutputDirectory() {
outputDirectory = null
}
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Context.NOTIFICATION_SERVICE
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.video.StreamingTranscoder
import org.thoughtcrime.securesms.video.videoconverter.VideoConstants
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
import org.thoughtcrime.video.app.R
import java.io.FileOutputStream
import java.io.InputStream
import java.time.Instant
class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
@UnstableApi
override suspend fun doWork(): Result {
val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1)
if (notificationId < 0) {
Log.w(TAG, "Notification ID was null!")
return Result.failure()
}
val inputUri = inputData.getString(KEY_INPUT_URI)
if (inputUri == null) {
Log.w(TAG, "Input URI was null!")
return Result.failure()
}
val outputDirUri = inputData.getString(KEY_OUTPUT_URI)
if (outputDirUri == null) {
Log.w(TAG, "Output URI was null!")
return Result.failure()
}
val input = DocumentFile.fromSingleUri(ctx, Uri.parse(inputUri))?.name
if (input == null) {
Log.w(TAG, "Could not read input file name!")
return Result.failure()
}
val outputFileUri = createFile(Uri.parse(outputDirUri), "transcoded-${Instant.now()}-$input$OUTPUT_FILE_EXTENSION")
if (outputFileUri == null) {
Log.w(TAG, "Could not create output file!")
return Result.failure()
}
val datasource = WorkerMediaDataSource(ctx, Uri.parse(inputUri))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Log.w(TAG, "Transcoder is only supported on API 26+!")
return Result.failure()
}
val transcoder = StreamingTranscoder(datasource, null, 50 * 1024 * 1024) // TODO: set options
setForeground(createForegroundInfo(-1, notificationId))
ctx.contentResolver.openFileDescriptor(outputFileUri, "w").use { it: ParcelFileDescriptor? ->
if (it == null) {
Log.w(TAG, "Could not open output file for writing!")
return Result.failure()
}
transcoder.transcode(
{ percent: Int ->
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
setForegroundAsync(createForegroundInfo(percent, notificationId))
},
FileOutputStream(it.fileDescriptor),
{ isStopped }
)
return Result.success()
}
}
private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo {
val id = applicationContext.getString(R.string.notification_channel_id)
val title = applicationContext.getString(R.string.notification_title)
val cancel = applicationContext.getString(R.string.cancel_transcode)
val intent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(getId())
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_LOW
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)
.setContentTitle(title)
.setTicker(title)
.setProgress(100, progress, progress >= 0)
.setSmallIcon(R.drawable.ic_work_notification)
.setOngoing(true)
.addAction(android.R.drawable.ic_delete, cancel, intent)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(notificationId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(notificationId, notification)
}
}
private fun createFile(treeUri: Uri, filename: String): Uri? {
return DocumentFile.fromTreeUri(ctx, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)?.uri
}
private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() {
private val contentResolver = context.contentResolver
private val size = contentResolver.getLength(uri) ?: throw IllegalStateException()
private var inputStream: InputStream? = null
override fun close() {
inputStream?.close()
}
override fun getSize(): Long {
return size
}
override fun createInputStream(position: Long): InputStream {
inputStream?.close()
val openedInputStream = contentResolver.openInputStream(uri) ?: throw IllegalStateException()
openedInputStream.skip(position)
inputStream = openedInputStream
return openedInputStream
}
}
companion object {
private const val TAG = "TranscodeWorker"
private const val OUTPUT_FILE_EXTENSION = ".mp4"
const val KEY_INPUT_URI = "input_uri"
const val KEY_OUTPUT_URI = "output_uri"
const val KEY_PROGRESS = "progress"
const val KEY_NOTIFICATION_ID = "notification_id"
}
}

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.92"
android:scaleY="0.92"
android:translateX="0.96"
android:translateY="0.96">
<path
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"
android:fillColor="#FF000000"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

View File

@@ -5,4 +5,9 @@
<resources>
<string name="app_name">Video Framework Tester</string>
<string name="notification_channel_id">transcode-progress</string>
<string name="notification_title">Encoding video…</string>
<string name="cancel_transcode">Cancel</string>
<string name="channel_name">Transcoding progress updates.</string>
<string name="channel_description">Persistent notifications that allow the transcode job to complete when the app is in the background.</string>
</resources>