Add Media3 hardware conversion path with end-to-end tests
First working conversion: SAF input -> hardware transcode -> staged cache file -> SAF export, driven from a Compose screen. Media3 Transformer is the engine for this path rather than FFmpeg. It is Apache-2.0, needs no native build, consumes content:// URIs directly, and runs MediaCodec decode -> GL surface -> MediaCodec encode without frames round-tripping through the CPU. FFmpeg remains necessary for the long tail (MP3, GIF, MKV, exotic containers) but is not the right tool here. Two hazards are designed against rather than discovered later: Transformer must be driven from a single thread that has a Looper, and start()/cancel() throw IllegalStateException from anywhere else. The Looper it binds to is whichever the Builder saw, silently falling back to the main one. A WorkManager Worker runs on a Looper-less executor thread, so the naive arrangement builds against the main Looper and then throws on start. Media3Engine owns a dedicated HandlerThread, passes its Looper explicitly, and marshals every call onto it, so callers get a plain suspending function and cannot reintroduce the bug. Media3EngineTest covers this directly by driving a conversion from a Looper-less thread. Output never goes through a SAF file descriptor. MP4 faststart rewrites the moov atom at the end and needs to seek backwards, which a SAF fd does not reliably support. OutputPublisher stages to app-private cache, a real POSIX path, and copies out afterwards. That costs transient double disk usage, so it checks free space before starting. Input uses ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video only, offers no audio at all, and does not reliably surface .mkv/.flac/.webm. SAF needs no runtime permission. Tests run on an API 37 emulator and assert the output codec by reading the muxed file with MediaExtractor, so a silent fallback to H.264 fails rather than passing. Progress reporting is deliberately not asserted as non-empty: a 3 s fixture can finish inside one 250 ms poll tick, which would be an intermittent failure rather than a real defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,128 @@
|
||||
package dev.jasonmross.mediaconverter.convert
|
||||
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* End-to-end hardware transcode through [Media3Engine].
|
||||
*
|
||||
* This is the Phase 1 verification: it proves the MediaCodec pipeline actually runs on
|
||||
* a device, and — more importantly — that the engine can be driven from a thread with
|
||||
* no Looper of its own without tripping Transformer's single-thread requirement.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class Media3EngineTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var engine: Media3Engine
|
||||
private lateinit var input: File
|
||||
private lateinit var output: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
engine = Media3Engine(context)
|
||||
input = File(context.cacheDir, "sample_h264.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
output = File(context.cacheDir, "out_hevc.mp4")
|
||||
output.delete()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
engine.close()
|
||||
input.delete()
|
||||
output.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcodesH264ToH265AndReportsProgress() = runBlocking {
|
||||
val seen = mutableListOf<Int>()
|
||||
|
||||
val result = engine.transcode(
|
||||
input = Uri.fromFile(input),
|
||||
output = output,
|
||||
videoMimeType = MimeTypes.VIDEO_H265,
|
||||
) { percent -> seen += percent }
|
||||
|
||||
assertTrue("export produced no file", output.exists())
|
||||
assertTrue("export produced an empty file", output.length() > 0)
|
||||
// Assert against the muxed file, not just the reported result: this is what
|
||||
// actually proves the output is HEVC rather than a silent fallback to H.264.
|
||||
assertEquals(MimeTypes.VIDEO_H265, videoMimeTypeOf(output))
|
||||
assertTrue("no duration reported", result.approximateDurationMs > 0)
|
||||
assertTrue("no frames encoded", result.videoFrameCount > 0)
|
||||
// Deliberately NOT asserting that progress fired. Polling is on a 250 ms tick,
|
||||
// and a 3 s 320x240 clip can finish inside one tick on fast hardware, which
|
||||
// would make the assertion fail intermittently for no real defect.
|
||||
seen.forEach { assertTrue("progress out of range: $it", it in 0..100) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the Transformer threading trap.
|
||||
*
|
||||
* Transformer binds to the Looper of the thread that built it, falling back to the
|
||||
* main Looper when that thread has none — and then throws IllegalStateException
|
||||
* when start() is called from elsewhere. A WorkManager Worker runs on exactly such
|
||||
* a Looper-less thread, so this test drives the engine from one to prove the
|
||||
* HandlerThread indirection holds before any of that lands in Phase 2.
|
||||
*/
|
||||
@Test
|
||||
fun runsFromAThreadWithNoLooper() {
|
||||
val pool = Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
val task = pool.submit<Throwable?> {
|
||||
check(android.os.Looper.myLooper() == null) {
|
||||
"precondition failed: this thread should have no Looper"
|
||||
}
|
||||
runCatching {
|
||||
runBlocking { engine.transcode(Uri.fromFile(input), output) }
|
||||
}.exceptionOrNull()
|
||||
}
|
||||
val failure = task.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
assertTrue(
|
||||
"transcode from a Looper-less thread failed: $failure",
|
||||
failure == null,
|
||||
)
|
||||
assertTrue(output.exists() && output.length() > 0)
|
||||
} finally {
|
||||
pool.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoMimeTypeOf(file: File): String? {
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(file.absolutePath)
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/")) return mime
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 120L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.jasonmross.mediaconverter
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dev.jasonmross.mediaconverter.convert.ConverterScreen
|
||||
import dev.jasonmross.mediaconverter.ui.theme.MediaConverterTheme
|
||||
|
||||
@UnstableApi
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+,
|
||||
// so opt in explicitly rather than relying on the default.
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MediaConverterTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
ConverterScreen(modifier = Modifier.padding(innerPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package dev.jasonmross.mediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
data class InputFile(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
|
||||
sealed interface ConversionState {
|
||||
data object Idle : ConversionState
|
||||
data class Ready(val input: InputFile) : ConversionState
|
||||
data class Converting(val input: InputFile, val percent: Int) : ConversionState
|
||||
data class Converted(
|
||||
val input: InputFile,
|
||||
val staged: File,
|
||||
val elapsedMs: Long,
|
||||
) : ConversionState
|
||||
data class Saved(val displayName: String) : ConversionState
|
||||
data class Failed(val message: String) : ConversionState
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val engine = Media3Engine(app)
|
||||
private val publisher = OutputPublisher(app)
|
||||
|
||||
private val _state = MutableStateFlow<ConversionState>(ConversionState.Idle)
|
||||
val state: StateFlow<ConversionState> = _state.asStateFlow()
|
||||
|
||||
fun onInputPicked(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
val info = withContext(Dispatchers.IO) { queryFile(uri) }
|
||||
_state.value = ConversionState.Ready(info)
|
||||
}
|
||||
}
|
||||
|
||||
fun convert() {
|
||||
val input = when (val s = _state.value) {
|
||||
is ConversionState.Ready -> s.input
|
||||
is ConversionState.Converted -> s.input
|
||||
else -> return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// Staging plus the source means peak usage is roughly both at once.
|
||||
if (!publisher.hasSpaceFor(input.sizeBytes)) {
|
||||
_state.value = ConversionState.Failed(
|
||||
"Not enough free space to convert this file."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_state.value = ConversionState.Converting(input, 0)
|
||||
val staged = publisher.createStagingFile(outputNameFor(input.displayName))
|
||||
val startedAt = System.currentTimeMillis()
|
||||
|
||||
runCatching {
|
||||
engine.transcode(input.uri, staged) { percent ->
|
||||
_state.update { current ->
|
||||
if (current is ConversionState.Converting) {
|
||||
current.copy(percent = percent)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onSuccess {
|
||||
_state.value = ConversionState.Converted(
|
||||
input = input,
|
||||
staged = staged,
|
||||
elapsedMs = System.currentTimeMillis() - startedAt,
|
||||
)
|
||||
}.onFailure { e ->
|
||||
staged.delete()
|
||||
_state.value = ConversionState.Failed(e.message ?: "Conversion failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the staged result out to the destination the user chose. */
|
||||
fun save(destination: Uri) {
|
||||
val converted = _state.value as? ConversionState.Converted ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(converted.staged, destination)
|
||||
converted.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
_state.value = ConversionState.Saved(
|
||||
outputNameFor(converted.input.displayName)
|
||||
)
|
||||
}.onFailure { e ->
|
||||
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
_state.value = ConversionState.Idle
|
||||
}
|
||||
|
||||
fun suggestedOutputName(): String {
|
||||
val s = _state.value
|
||||
val base = when (s) {
|
||||
is ConversionState.Converted -> s.input.displayName
|
||||
is ConversionState.Ready -> s.input.displayName
|
||||
else -> "output"
|
||||
}
|
||||
return outputNameFor(base)
|
||||
}
|
||||
|
||||
private fun queryFile(uri: Uri): InputFile {
|
||||
var name = "input"
|
||||
var size = 0L
|
||||
getApplication<Application>().contentResolver
|
||||
.query(uri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
.takeIf { it >= 0 }
|
||||
?.let { name = cursor.getString(it) ?: name }
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
.takeIf { it >= 0 }
|
||||
?.let { size = cursor.getLong(it) }
|
||||
}
|
||||
}
|
||||
return InputFile(uri, name, size)
|
||||
}
|
||||
|
||||
private fun outputNameFor(inputName: String): String =
|
||||
inputName.substringBeforeLast('.', inputName) + "_converted.mp4"
|
||||
|
||||
override fun onCleared() {
|
||||
engine.close()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package dev.jasonmross.mediaconverter.convert
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import java.util.Locale
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun ConverterScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ConversionViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video
|
||||
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
|
||||
// SAF needs no runtime permission.
|
||||
val pickInput = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri -> uri?.let(viewModel::onInputPicked) }
|
||||
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("video/mp4")
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Media Converter", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
when (val s = state) {
|
||||
is ConversionState.Idle -> {
|
||||
Text(
|
||||
"Pick a video to transcode to H.265.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = { pickInput.launch(arrayOf("video/*")) }) {
|
||||
Text("Choose file")
|
||||
}
|
||||
}
|
||||
|
||||
is ConversionState.Ready -> {
|
||||
FileCard(s.input)
|
||||
Button(onClick = viewModel::convert) { Text("Convert") }
|
||||
OutlinedButton(onClick = { pickInput.launch(arrayOf("video/*")) }) {
|
||||
Text("Choose a different file")
|
||||
}
|
||||
}
|
||||
|
||||
is ConversionState.Converting -> {
|
||||
FileCard(s.input)
|
||||
Text("Converting… ${s.percent}%")
|
||||
LinearProgressIndicator(
|
||||
progress = { s.percent / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
is ConversionState.Converted -> {
|
||||
FileCard(s.input)
|
||||
Text(
|
||||
"Done in ${formatSeconds(s.elapsedMs)} — " +
|
||||
"${formatBytes(s.staged.length())} output.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) }) {
|
||||
Text("Save file")
|
||||
}
|
||||
}
|
||||
|
||||
is ConversionState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(onClick = viewModel::reset) { Text("Convert another") }
|
||||
}
|
||||
|
||||
is ConversionState.Failed -> {
|
||||
Text(
|
||||
s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = viewModel::reset) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileCard(input: InputFile) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(formatBytes(input.sizeBytes), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String = when {
|
||||
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
|
||||
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
|
||||
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
|
||||
private fun formatSeconds(ms: Long): String =
|
||||
String.format(Locale.US, "%.1f s", ms / 1000.0)
|
||||
@@ -0,0 +1,130 @@
|
||||
package dev.jasonmross.mediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.transformer.Composition
|
||||
import androidx.media3.transformer.EditedMediaItem
|
||||
import androidx.media3.transformer.ExportException
|
||||
import androidx.media3.transformer.ExportResult
|
||||
import androidx.media3.transformer.ProgressHolder
|
||||
import androidx.media3.transformer.Transformer
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Hardware conversion via Media3 Transformer.
|
||||
*
|
||||
* ## Why the HandlerThread
|
||||
*
|
||||
* Transformer documents that instances "must be accessed from a single application
|
||||
* thread", and both [Transformer.start] and [Transformer.cancel] throw
|
||||
* [IllegalStateException] when called from anywhere else. The thread it binds to is
|
||||
* whichever one had a Looper when the Builder was constructed — falling back to the
|
||||
* *main* Looper if the constructing thread has none.
|
||||
*
|
||||
* That default is a trap for background execution. A WorkManager `Worker` runs on an
|
||||
* executor thread with no Looper, so a Transformer built there silently binds to the
|
||||
* main thread, and the subsequent `start()` from the worker thread throws.
|
||||
*
|
||||
* This class therefore owns a dedicated [HandlerThread], passes its Looper explicitly
|
||||
* via `setLooper`, and marshals every Transformer call onto it. Callers get a plain
|
||||
* suspending function and never have to think about it.
|
||||
*/
|
||||
@UnstableApi
|
||||
class Media3Engine(private val context: Context) : AutoCloseable {
|
||||
|
||||
private val thread = HandlerThread("media3-transformer").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
|
||||
/**
|
||||
* Transcodes [input] to [output], reporting progress 0..100.
|
||||
*
|
||||
* [output] must be a real filesystem path, not a SAF document. Writing through a
|
||||
* SAF file descriptor breaks MP4 muxing, because faststart needs to seek back and
|
||||
* rewrite the moov atom, and a SAF fd is not reliably seekable. Callers stage into
|
||||
* app-private storage and publish afterwards.
|
||||
*/
|
||||
suspend fun transcode(
|
||||
input: Uri,
|
||||
output: File,
|
||||
videoMimeType: String = MimeTypes.VIDEO_H265,
|
||||
onProgress: (Int) -> Unit = {},
|
||||
): ExportResult = suspendCancellableCoroutine { cont ->
|
||||
handler.post {
|
||||
val transformer = buildTransformer(videoMimeType, cont)
|
||||
val item = EditedMediaItem.Builder(MediaItem.fromUri(input)).build()
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
// cancel() has the same single-thread requirement as start().
|
||||
handler.post { runCatching { transformer.cancel() } }
|
||||
}
|
||||
|
||||
runCatching { transformer.start(item, output.absolutePath) }
|
||||
.onFailure { cont.resumeWithException(it); return@post }
|
||||
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTransformer(
|
||||
videoMimeType: String,
|
||||
cont: CancellableContinuation<ExportResult>,
|
||||
): Transformer = Transformer.Builder(context)
|
||||
.setLooper(thread.looper)
|
||||
.setVideoMimeType(videoMimeType)
|
||||
.addListener(object : Transformer.Listener {
|
||||
override fun onCompleted(composition: Composition, result: ExportResult) {
|
||||
if (cont.isActive) cont.resume(result)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
composition: Composition,
|
||||
result: ExportResult,
|
||||
exception: ExportException,
|
||||
) {
|
||||
if (cont.isActive) cont.resumeWithException(exception)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Polls export progress on the Transformer's own thread.
|
||||
*
|
||||
* Deliberately ~4x/second: the underlying value updates far more often than a UI
|
||||
* or a notification can usefully consume, and over-frequent notification updates
|
||||
* will jank the system UI.
|
||||
*/
|
||||
private fun pollProgress(
|
||||
transformer: Transformer,
|
||||
cont: CancellableContinuation<ExportResult>,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val holder = ProgressHolder()
|
||||
val tick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!cont.isActive) return
|
||||
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
|
||||
onProgress(holder.progress)
|
||||
}
|
||||
handler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(tick, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PROGRESS_INTERVAL_MS = 250L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.jasonmross.mediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Staging and publication of conversion output.
|
||||
*
|
||||
* Conversions never write directly to the destination the user picked. FFmpeg and the
|
||||
* MP4 muxer both need to seek backwards to finalise a file — faststart rewrites the
|
||||
* moov atom at the end — and a SAF file descriptor is not reliably seekable. Writing
|
||||
* through one produces a truncated or unplayable file.
|
||||
*
|
||||
* So every job writes to app-private cache, which is a real POSIX path with no
|
||||
* permissions and no scoped-storage rules, and the finished file is copied out to the
|
||||
* user's chosen destination afterwards.
|
||||
*
|
||||
* The cost is one extra copy and transient double disk usage, which is why
|
||||
* [hasSpaceFor] exists.
|
||||
*/
|
||||
class OutputPublisher(private val context: Context) {
|
||||
|
||||
private val stagingDir: File
|
||||
get() = File(context.cacheDir, "conversions").apply { mkdirs() }
|
||||
|
||||
fun createStagingFile(name: String): File = File(stagingDir, name)
|
||||
|
||||
/**
|
||||
* True if there is room for a further [bytes], including headroom.
|
||||
*
|
||||
* Staging means peak usage is roughly input + output at once, so a job that would
|
||||
* just barely fit is rejected rather than failing partway through.
|
||||
*/
|
||||
fun hasSpaceFor(bytes: Long): Boolean =
|
||||
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
|
||||
|
||||
/** Copies a finished staging file into a user-chosen SAF destination. */
|
||||
fun publish(staged: File, destination: Uri) {
|
||||
context.contentResolver.openOutputStream(destination)?.use { out ->
|
||||
staged.inputStream().use { it.copyTo(out) }
|
||||
} ?: error("Could not open destination for writing: $destination")
|
||||
}
|
||||
|
||||
fun clearStaging() {
|
||||
stagingDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SPACE_HEADROOM_BYTES = 128L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user