Apply ktlintFormat

Tool output only, no hand edits, so this is safe to read with whitespace
diffing off. It is its own commit for exactly that reason: a whole-repo
reformat folded into the commit that configured the formatter would have
made both unreviewable.

What it did, mostly: trailing commas on wrapped argument lists, signatures
collapsed onto one line where they fit inside 120 columns, import order, and
four genuinely unused imports removed. Unit tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:10:44 -05:00
co-authored by Claude Opus 5
parent 93b1fbd5a9
commit 496f1c7e73
35 changed files with 327 additions and 304 deletions
@@ -4,10 +4,13 @@ import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.util.Log
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.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.convert.Media3Engine
import org.libremediaconverter.convert.MediaProbe
@@ -17,10 +20,6 @@ import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
@@ -45,6 +44,7 @@ import java.io.File
class RealMediaBenchmark {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Internal storage, not the external files dir.
*
@@ -55,8 +55,7 @@ class RealMediaBenchmark {
*/
private val samples: File get() = context.filesDir
private fun sample(name: String): File? =
File(samples, name).takeIf { it.exists() && it.length() > 0 }
private fun sample(name: String): File? = File(samples, name).takeIf { it.exists() && it.length() > 0 }
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
@@ -20,7 +20,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.OutputSpec
@@ -84,14 +83,13 @@ class RemuxTest {
WorkManager.getInstance(context).cancelAllWork()
}
private fun asset(name: String): File =
File(context.cacheDir, name).also { file ->
file.delete()
staged += file
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
}
private fun asset(name: String): File = File(context.cacheDir, name).also { file ->
file.delete()
staged += file
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
}
// --- probing ------------------------------------------------------------
@@ -255,8 +253,7 @@ class RemuxTest {
}
}
private fun videoMimeOf(file: File): String? =
trackMimesOf(file).firstOrNull { it.startsWith("video/") }
private fun videoMimeOf(file: File): String? = trackMimesOf(file).firstOrNull { it.startsWith("video/") }
private fun durationMsOf(file: File): Long {
val extractor = MediaExtractor()
@@ -7,7 +7,6 @@ import org.libremediaconverter.convert.HardwareTranscoder
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import java.io.File
/**
@@ -6,13 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -22,6 +15,13 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
@@ -61,14 +61,13 @@ class ForcedFailureTest {
}
}
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) =
ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
spec = format.spec,
quality = QualityTier.FAST,
)
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
spec = format.spec,
quality = QualityTier.FAST,
)
// --- the dynamic fallback, forced rather than provoked -------------------
@@ -179,11 +178,9 @@ class ForcedFailureTest {
assertEquals(WorkInfo.State.FAILED, terminal?.state)
}
private fun assertTrue(message: String, condition: Boolean) =
org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(message: String, condition: Boolean) = org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(condition: Boolean, message: String) =
org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(condition: Boolean, message: String) = org.junit.Assert.assertTrue(message, condition)
private companion object {
const val SAMPLE = "sample_h264.mp4"
@@ -6,9 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -18,6 +15,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
@@ -8,10 +8,6 @@ import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -21,6 +17,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
@@ -4,9 +4,6 @@ import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
@@ -14,6 +11,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import java.io.File
/**
@@ -46,8 +46,10 @@ class FFmpegEngineTest {
outputs.forEach { it.delete() }
}
private fun outputFor(name: String) =
File(context.cacheDir, name).also { it.delete(); outputs += it }
private fun outputFor(name: String) = File(context.cacheDir, name).also {
it.delete()
outputs += it
}
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
@@ -5,9 +5,6 @@ import android.media.MediaFormat
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
@@ -15,6 +12,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import java.io.File
/**
@@ -57,8 +57,10 @@ class ConcatEngineTest {
return out
}
private fun output(name: String) =
File(context.cacheDir, name).also { it.delete(); staged += it }
private fun output(name: String) = File(context.cacheDir, name).also {
it.delete()
staged += it
}
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
@@ -6,8 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -17,6 +15,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import java.io.File
/**
@@ -8,14 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.QualityTier
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -25,6 +17,14 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import java.io.File
/**
@@ -170,8 +170,11 @@ class ConversionWorkerTest {
terminal?.state,
)
assertEquals(
if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path"
else "no hardware HEVC encoder, expected the FFmpeg path",
if (hasHardwareHevc) {
"hardware HEVC present, expected the Media3 path"
} else {
"no hardware HEVC encoder, expected the FFmpeg path"
},
if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
@@ -18,8 +18,8 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -1,6 +1,5 @@
package org.libremediaconverter.codec
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.util.Log
@@ -28,8 +27,7 @@ class AndroidDeviceCodecs private constructor(
private val decodeMimes: Set<String>,
) : DeviceCodecs {
override fun canEncode(codec: VideoCodec): Boolean =
mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
override fun canEncode(codec: VideoCodec): Boolean = mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
override fun canDecode(codecName: String): Boolean {
// The platform already failed to parse this input, so there is nothing to
@@ -46,8 +44,7 @@ class AndroidDeviceCodecs private constructor(
@Volatile
private var cached: AndroidDeviceCodecs? = null
fun get(): AndroidDeviceCodecs =
cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
private fun probe(): AndroidDeviceCodecs {
val encoders = mutableSetOf<String>()
@@ -104,7 +101,6 @@ class AndroidDeviceCodecs private constructor(
}
/** Test seam: lets instrumented tests build a probe from explicit sets. */
fun forTesting(encoders: Set<String>, decoders: Set<String>) =
AndroidDeviceCodecs(encoders, decoders)
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
}
}
@@ -8,17 +8,6 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +19,17 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import java.io.File
import java.util.UUID
@@ -62,6 +62,7 @@ sealed interface ConversionState {
data object Idle : ConversionState
data class Ready(val input: InputFile) : ConversionState
data class Converting(val input: InputFile, val percent: Int) : ConversionState
/** Budget for foreground work ran out; WorkManager will retry when it can. */
data class Waiting(val input: InputFile) : ConversionState
data class Converted(
@@ -102,20 +103,16 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid)
fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) }
fun setContainer(container: Container) =
_settings.update { it.copy(spec = it.spec.copy(container = container)) }
fun setContainer(container: Container) = _settings.update { it.copy(spec = it.spec.copy(container = container)) }
fun setVideoCodec(codec: VideoCodec) =
_settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
fun setVideoCodec(codec: VideoCodec) = _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
fun setAudioCodec(codec: AudioCodec) =
_settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
fun setAudioCodec(codec: AudioCodec) = _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) }
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
fun setEnginePreference(preference: EnginePreference) =
_settings.update { it.copy(enginePreference = preference) }
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
fun onInputPicked(uri: Uri) {
viewModelScope.launch {
@@ -202,7 +199,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
WorkInfo.State.FAILED -> ConversionState.Failed(
info.outputData.getString(ConversionWorker.KEY_ERROR)
?: "Conversion failed."
?: "Conversion failed.",
)
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
@@ -229,7 +226,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
ConversionWorker.outputNameFor(
converted.input.displayName,
_settings.value.spec,
)
),
)
}.onFailure { e ->
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
@@ -244,11 +241,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
_state.value = ConversionState.Idle
}
fun suggestedOutputName(): String =
ConversionWorker.outputNameFor(
currentInput()?.displayName ?: "output",
_settings.value.spec,
)
fun suggestedOutputName(): String = ConversionWorker.outputNameFor(
currentInput()?.displayName ?: "output",
_settings.value.spec,
)
private fun ConversionState.probe(): InputProbe? = when (this) {
is ConversionState.Ready -> input.probe
@@ -34,9 +34,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
@@ -50,14 +47,14 @@ import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import java.util.Locale
@UnstableApi
@Composable
fun ConverterScreen(
modifier: Modifier = Modifier,
viewModel: ConversionViewModel = viewModel(),
) {
fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val validation by viewModel.validation.collectAsStateWithLifecycle()
@@ -66,11 +63,11 @@ fun ConverterScreen(
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
// SAF needs no runtime permission.
val pickInput = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(viewModel::onInputPicked) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(settings.spec.mimeType)
ActivityResultContracts.CreateDocument(settings.spec.mimeType),
) { uri -> uri?.let(viewModel::save) }
// Requested at the point of use rather than on first launch, so the ask carries its
@@ -78,7 +75,7 @@ fun ConverterScreen(
// foreground service still runs, but its progress notification is confined to the
// Task Manager instead of the shade.
val requestNotifications = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
ActivityResultContracts.RequestPermission(),
) { viewModel.convert() }
Column(
@@ -69,7 +69,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
val plan = CopyPlanner.plan(request.spec, request.probe)
handler.post {
val transformer = runCatching { buildTransformer(plan, cont) }
.getOrElse { cont.resumeWithException(it); return@post }
.getOrElse {
cont.resumeWithException(it)
return@post
}
// Dropping the tracks the target does not have is what stops an audio-only export
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
@@ -92,7 +95,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
}
runCatching { transformer.start(composition, output.absolutePath) }
.onFailure { cont.resumeWithException(it); return@post }
.onFailure {
cont.resumeWithException(it)
return@post
}
pollProgress(transformer, cont, onProgress)
}
@@ -104,10 +110,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
* is supposed to have sent such a job to FFmpeg — so it fails loudly instead of quietly
* writing MP4, which is what the old code did.
*/
private fun buildTransformer(
plan: ConversionPlan,
cont: CancellableContinuation<Unit>,
): Transformer {
private fun buildTransformer(plan: ConversionPlan, cont: CancellableContinuation<Unit>): Transformer {
val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) {
"Media3 cannot mux ${plan.container}; this job should have routed to FFmpeg."
}
@@ -128,11 +131,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
if (cont.isActive) cont.resume(Unit)
}
override fun onError(
composition: Composition,
result: ExportResult,
exception: ExportException,
) {
override fun onError(composition: Composition, result: ExportResult, exception: ExportException) {
if (cont.isActive) cont.resumeWithException(exception)
}
})
@@ -193,7 +192,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
thread.quitSafely()
}
private companion object {
const val PROGRESS_INTERVAL_MS = 250L
}
@@ -32,8 +32,7 @@ open class OutputPublisher(private val context: Context) {
* 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.
*/
open fun hasSpaceFor(bytes: Long): Boolean =
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
/** Copies a finished staging file into a user-chosen SAF destination. */
open fun publish(staged: File, destination: Uri) {
@@ -2,11 +2,11 @@ package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.codec.AndroidDeviceCodecs
import java.io.File
/** The hardware conversion path. Implemented by [Media3Engine]. */
@@ -6,11 +6,11 @@ import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.model.ConcatPlanner
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -27,11 +27,7 @@ class ConcatEngine(private val context: Context) {
data class Result(val strategy: ConcatStrategy, val output: File)
suspend fun join(
inputs: List<Uri>,
output: File,
format: OutputFormat = OutputFormat.MP4_H264,
): Result {
suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result {
require(inputs.size >= 2) { "Joining needs at least two files." }
val paths = inputs.map { uri ->
@@ -70,8 +66,8 @@ class ConcatEngine(private val context: Context) {
else -> cont.resumeWithException(
FFmpegEngine.FFmpegException(
"Joining failed (${rc?.value}): " +
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
)
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(),
),
)
}
}
@@ -53,17 +53,14 @@ object FFmpegCommandBuilder {
/** Containers in the ISO base-media family, where HEVC needs the hvc1 brand. */
private val MP4_FAMILY = setOf(Container.MP4, Container.MOV)
fun build(
request: ConversionRequest,
inputPath: String,
outputPath: String,
): List<String> {
fun build(request: ConversionRequest, inputPath: String, outputPath: String): List<String> {
val plan = CopyPlanner.plan(request.spec, request.probe)
return buildList {
add("-hide_banner")
// Overwrite: the output path is one we just created in our own cache.
add("-y")
add("-i"); add(inputPath)
add("-i")
add(inputPath)
if (request.spec.isImageOutput) {
addAll(imageArgs(request))
@@ -86,7 +83,8 @@ object FFmpegCommandBuilder {
"-vf",
"fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
"[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
"-loop", "0",
"-loop",
"0",
)
else -> listOf("-an", "-vf", "fps=1", "-vsync", "0")
@@ -99,7 +97,8 @@ object FFmpegCommandBuilder {
VideoPlan.Drop -> listOf("-vn")
VideoPlan.Copy -> buildList {
add("-c:v"); add("copy")
add("-c:v")
add("copy")
// The hvc1 brand matters on the copy path too, not just when encoding: remuxing
// HEVC out of Matroska into MP4 otherwise produces a file Apple devices and many
// hardware players refuse, even though the samples are byte-identical.
@@ -128,9 +127,15 @@ object FFmpegCommandBuilder {
// that hardware encoding is unlikely to work for that input. Fast therefore means a
// fast *preset*, not a different encoder.
VideoCodec.H265 -> listOf(
"-c:v", "libx265", "-crf", "$CRF_H265", "-preset", preset,
"-c:v",
"libx265",
"-crf",
"$CRF_H265",
"-preset",
preset,
// Without this, many players and Apple devices refuse HEVC in MP4.
"-tag:v", "hvc1",
"-tag:v",
"hvc1",
) + PIX_FMT
VideoCodec.VP9 -> buildList {
@@ -140,7 +145,12 @@ object FFmpegCommandBuilder {
}
VideoCodec.H264 -> listOf(
"-c:v", "libx264", "-crf", "$CRF_H264", "-preset", preset,
"-c:v",
"libx264",
"-crf",
"$CRF_H264",
"-preset",
preset,
) + PIX_FMT
// No silent substitution. A trailing `else -> libx264` would hand back H.264 for a
@@ -151,11 +161,11 @@ object FFmpegCommandBuilder {
// about rather than papering over.
VideoCodec.VP8, VideoCodec.AV1 -> error(
"This app cannot encode ${codec.label}; it can only copy an existing " +
"${codec.label} stream."
"${codec.label} stream.",
)
VideoCodec.COPY, VideoCodec.NONE -> error(
"encodeVideo called for $codec, which is not an encode"
"encodeVideo called for $codec, which is not an encode",
)
}
}
@@ -167,31 +177,32 @@ object FFmpegCommandBuilder {
return if (sourceIsHevc) listOf("-tag:v", "hvc1") else emptyList()
}
private fun audioArgs(plan: ConversionPlan): List<String> =
when (val audio = plan.audio) {
AudioPlan.Drop -> listOf("-an")
AudioPlan.Copy -> listOf("-c:a", "copy")
is AudioPlan.Encode -> when (audio.codec) {
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
AudioCodec.FLAC -> listOf("-c:a", "flac")
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
else -> listOf("-c:a", "aac", "-b:a", "192k")
}
private fun audioArgs(plan: ConversionPlan): List<String> = when (val audio = plan.audio) {
AudioPlan.Drop -> listOf("-an")
AudioPlan.Copy -> listOf("-c:a", "copy")
is AudioPlan.Encode -> when (audio.codec) {
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
AudioCodec.FLAC -> listOf("-c:a", "flac")
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
else -> listOf("-c:a", "aac", "-b:a", "192k")
}
}
private fun containerArgs(plan: ConversionPlan): List<String> = buildList {
// Name the muxer rather than letting FFmpeg infer it from the output path. Inference is
// unreliable for MPEG-TS and ASF, and the app now lets the user pick a container
// independently of the preset that used to imply it.
add("-f"); add(plan.container.ffmpegFormat)
add("-f")
add(plan.container.ffmpegFormat)
if (plan.container in MP4_FAMILY) {
// Move the moov atom to the front so the file starts playing before it is
// fully downloaded. This is also the reason output never goes through a SAF
// file descriptor: faststart has to seek backwards to rewrite the header.
add("-movflags"); add("+faststart")
add("-movflags")
add("+faststart")
}
}
@@ -28,21 +28,31 @@ object FFmpegConcatCommand {
format: OutputFormat,
): List<String> = when (strategy) {
ConcatStrategy.STREAM_COPY -> buildList {
add("-hide_banner"); add("-y")
add("-hide_banner")
add("-y")
// -safe 0 permits absolute paths in the list file, which ours are.
add("-f"); add("concat")
add("-safe"); add("0")
add("-i"); add(listFile.absolutePath)
add("-c"); add("copy")
add("-f")
add("concat")
add("-safe")
add("0")
add("-i")
add(listFile.absolutePath)
add("-c")
add("copy")
if (format.container == org.libremediaconverter.model.Container.MP4) {
add("-movflags"); add("+faststart")
add("-movflags")
add("+faststart")
}
add(output.absolutePath)
}
ConcatStrategy.REENCODE -> buildList {
add("-hide_banner"); add("-y")
inputPaths.forEach { add("-i"); add(it) }
add("-hide_banner")
add("-y")
inputPaths.forEach {
add("-i")
add(it)
}
// Normalise every input to a common size and frame rate before joining,
// otherwise the concat filter refuses mismatched inputs.
val filter = buildString {
@@ -53,15 +63,23 @@ object FFmpegConcatCommand {
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
}
add("-filter_complex"); add(filter)
add("-map"); add("[v]")
add("-map"); add("[a]")
add("-c:v"); add("libx264")
add("-crf"); add("20")
add("-pix_fmt"); add("yuv420p")
add("-c:a"); add("aac")
add("-filter_complex")
add(filter)
add("-map")
add("[v]")
add("-map")
add("[a]")
add("-c:v")
add("libx264")
add("-crf")
add("20")
add("-pix_fmt")
add("yuv420p")
add("-c:a")
add("aac")
if (format.container == org.libremediaconverter.model.Container.MP4) {
add("-movflags"); add("+faststart")
add("-movflags")
add("+faststart")
}
add(output.absolutePath)
}
@@ -5,9 +5,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -61,8 +61,8 @@ class FFmpegEngine : SoftwareTranscoder {
"FFmpeg failed (${rc?.value}): " +
completed.getFailStackTrace().orEmpty().ifBlank {
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
}
)
},
),
)
}
},
@@ -33,18 +33,15 @@ import org.libremediaconverter.ui.ScreenPaddingVertical
@UnstableApi
@Composable
fun JoinScreen(
modifier: Modifier = Modifier,
viewModel: JoinViewModel = viewModel(),
) {
fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pickInputs = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
ActivityResultContracts.OpenMultipleDocuments(),
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("video/mp4")
ActivityResultContracts.CreateDocument("video/mp4"),
) { uri -> uri?.let(viewModel::save) }
Column(
@@ -8,10 +8,6 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,6 +15,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import java.io.File
import java.util.UUID
@@ -72,8 +72,11 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
_state.value = when (info.state) {
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
else JoinState.Joining(inputs)
if (info.runAttemptCount > 0) {
JoinState.Waiting(inputs)
} else {
JoinState.Joining(inputs)
}
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
@@ -87,7 +90,7 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
}
WorkInfo.State.FAILED -> JoinState.Failed(
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed."
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed.",
)
WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
@@ -27,7 +27,11 @@ object ContainerCapabilities {
// Matroska is the permissive one: it is a general-purpose container and takes essentially
// any codec. That is what makes it the natural remux target.
Container.MKV to setOf(
VideoCodec.H264, VideoCodec.H265, VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1,
VideoCodec.H264,
VideoCodec.H265,
VideoCodec.VP8,
VideoCodec.VP9,
VideoCodec.AV1,
),
Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1),
Container.MPEG_TS to setOf(VideoCodec.H264, VideoCodec.H265),
@@ -48,8 +52,12 @@ object ContainerCapabilities {
Container.MP4 to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.OPUS, AudioCodec.FLAC),
Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM),
Container.MKV to setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS,
AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
AudioCodec.AAC,
AudioCodec.OPUS,
AudioCodec.VORBIS,
AudioCodec.MP3,
AudioCodec.FLAC,
AudioCodec.PCM,
),
Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
Container.MPEG_TS to setOf(AudioCodec.AAC, AudioCodec.MP3),
@@ -75,7 +83,11 @@ object ContainerCapabilities {
/** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */
private val ENCODABLE_AUDIO = setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
AudioCodec.AAC,
AudioCodec.OPUS,
AudioCodec.MP3,
AudioCodec.FLAC,
AudioCodec.PCM,
)
fun accepts(container: Container, codec: VideoCodec, mode: CodecMode): Boolean = when (codec) {
@@ -57,7 +57,10 @@ object ConversionRouter {
internal val MEDIA3_MUXABLE_AUDIO: Map<Container, Set<AudioCodec>> = mapOf(
Container.MP4 to setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM,
AudioCodec.AAC,
AudioCodec.OPUS,
AudioCodec.VORBIS,
AudioCodec.PCM,
),
)
@@ -18,14 +18,11 @@ sealed interface AudioPlan {
/**
* A resolved conversion: no [VideoCodec.COPY] left to interpret, every track decided.
*/
data class ConversionPlan(
val container: Container,
val video: VideoPlan,
val audio: AudioPlan,
) {
data class ConversionPlan(val container: Container, val video: VideoPlan, val audio: AudioPlan) {
/** No track is re-encoded and at least one is copied: a container change and nothing more. */
val isPureRemux: Boolean
get() = video !is VideoPlan.Encode && audio !is AudioPlan.Encode &&
get() = video !is VideoPlan.Encode &&
audio !is AudioPlan.Encode &&
(video is VideoPlan.Copy || audio is AudioPlan.Copy)
val hasVideo: Boolean get() = video != VideoPlan.Drop
@@ -45,11 +45,9 @@ enum class Container(
/** Whether this container can hold a video track at all. */
val canHoldVideo: Boolean get() = videoExtension != null
fun extensionFor(hasVideo: Boolean): String =
if (hasVideo) videoExtension ?: audioExtension else audioExtension
fun extensionFor(hasVideo: Boolean): String = if (hasVideo) videoExtension ?: audioExtension else audioExtension
fun mimeTypeFor(hasVideo: Boolean): String =
if (hasVideo) videoMime ?: audioMime else audioMime
fun mimeTypeFor(hasVideo: Boolean): String = if (hasVideo) videoMime ?: audioMime else audioMime
}
/**
@@ -88,11 +86,7 @@ enum class AudioCodec(val label: String) {
* now comes from [ContainerCapabilities] instead, which is explicit and unit-tested rather than
* implicit in which combinations someone remembered to enumerate.
*/
data class OutputSpec(
val container: Container,
val videoCodec: VideoCodec,
val audioCodec: AudioCodec,
) {
data class OutputSpec(val container: Container, val videoCodec: VideoCodec, val audioCodec: AudioCodec) {
/** Whether the output keeps a video track — the thing extension and MIME type turn on. */
val hasVideo: Boolean get() = videoCodec != VideoCodec.NONE
@@ -104,7 +98,8 @@ data class OutputSpec(
/** True when neither track is re-encoded, i.e. this is a pure container change. */
val isPureRemux: Boolean
get() = videoCodec.isCopyOrAbsent() && audioCodec.isCopyOrAbsent() &&
get() = videoCodec.isCopyOrAbsent() &&
audioCodec.isCopyOrAbsent() &&
(videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY)
val extension: String get() = container.extensionFor(hasVideo)
@@ -1,6 +1,5 @@
package org.libremediaconverter.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import org.libremediaconverter.convert.ConversionDependencies
@@ -27,10 +26,7 @@ import org.libremediaconverter.model.OutputFormat
* concatenated; showing a fabricated percentage would be worse than showing none.
*/
@UnstableApi
class ConcatWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
private val publisher = ConversionDependencies.publisher(applicationContext)
@@ -43,7 +39,7 @@ class ConcatWorker(
}
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
val format = OutputFormat.valueOf(
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name,
)
if (!publisher.hasSpaceFor(totalBytes)) {
@@ -55,7 +51,7 @@ class ConcatWorker(
NOTIFICATION_ID,
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
ConversionForegroundType.current(),
)
),
)
val staged = publisher.createStagingFile("joined.${format.extension}")
@@ -65,7 +61,7 @@ class ConcatWorker(
workDataOf(
KEY_OUTPUT_PATH to staged.absolutePath,
KEY_STRATEGY to result.strategy.name,
)
),
)
} catch (e: Throwable) {
staged.delete()
@@ -99,18 +95,15 @@ class ConcatWorker(
private const val NOTIFICATION_ID = 1002
private const val TAG = "ConcatWorker"
fun request(
inputs: List<Uri>,
totalBytes: Long,
format: OutputFormat = OutputFormat.MP4_H264,
) = OneTimeWorkRequestBuilder<ConcatWorker>()
.setInputData(
Data.Builder()
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
.putLong(KEY_TOTAL_BYTES, totalBytes)
.putString(KEY_FORMAT, format.name)
.build()
)
.build()
fun request(inputs: List<Uri>, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) =
OneTimeWorkRequestBuilder<ConcatWorker>()
.setInputData(
Data.Builder()
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
.putLong(KEY_TOTAL_BYTES, totalBytes)
.putString(KEY_FORMAT, format.name)
.build(),
)
.build()
}
}
@@ -35,7 +35,7 @@ class ConversionNotifications(private val context: Context) {
context.getString(R.string.notification_preparing)
} else {
context.getString(R.string.notification_progress, percent)
}
},
)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
@@ -57,10 +57,9 @@ class ConversionNotifications(private val context: Context) {
* notification appears only in the Task Manager rather than the shade — so
* progress silently vanishes from the user's point of view.
*/
fun areEnabled(): Boolean =
context.getSystemService(NotificationManager::class.java)
.areNotificationsEnabled()
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java)
.areNotificationsEnabled()
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
companion object {
const val CHANNEL_ID = "conversions"
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.arthenica.ffmpegkit.FFmpegKitConfig
@@ -40,12 +39,10 @@ import java.io.File
* with a short quota, which is the wrong shape for a multi-minute transcode.
*/
@UnstableApi
class ConversionWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
// Resolved through ConversionDependencies so tests can force the failure paths.
private val publisher = ConversionDependencies.publisher(applicationContext)
@@ -56,10 +53,10 @@ class ConversionWorker(
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
val spec = readSpec()
val quality = QualityTier.valueOf(
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name,
)
val preference = EnginePreference.valueOf(
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name,
)
if (!publisher.hasSpaceFor(sizeBytes)) {
@@ -102,7 +99,7 @@ class ConversionWorker(
KEY_OUTPUT_PATH to staged.absolutePath,
KEY_ENGINE_USED to decision.engine.name,
KEY_ROUTE_REASON to decision.reason.explanation,
)
),
)
} catch (e: Throwable) {
staged.delete()
@@ -142,12 +139,7 @@ class ConversionWorker(
runFFmpeg(request, inputUri, staged, displayName)
}
private suspend fun runFFmpeg(
request: ConversionRequest,
inputUri: Uri,
staged: File,
displayName: String,
) {
private suspend fun runFFmpeg(request: ConversionRequest, inputUri: Uri, staged: File, displayName: String) {
// FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
// side is seekable for local providers, which is all the demuxer needs. Output
// still goes to a real cache path — see OutputPublisher.
@@ -177,8 +169,7 @@ class ConversionWorker(
}
}
private fun isCancellation(e: Throwable): Boolean =
e is kotlinx.coroutines.CancellationException || isStopped
private fun isCancellation(e: Throwable): Boolean = e is kotlinx.coroutines.CancellationException || isStopped
/**
* Distinguishes a genuine failure from the foreground-service budget expiring.
@@ -189,17 +180,16 @@ class ConversionWorker(
* later rather than tell the user the conversion failed — the work is still valid,
* there is simply no budget right now.
*/
private fun handleTimeoutIfNeeded(cause: Throwable): Result =
when (FailureOutcome.forStopReason(stopReason)) {
FailureOutcome.RETRY -> {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
Result.retry()
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
}
private fun handleTimeoutIfNeeded(cause: Throwable): Result = when (FailureOutcome.forStopReason(stopReason)) {
FailureOutcome.RETRY -> {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
Result.retry()
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
}
}
/**
* Reads the output spec out of the worker's input Data.
@@ -223,19 +213,17 @@ class ConversionWorker(
return OutputSpec(container, video, audio)
}
override suspend fun getForegroundInfo(): ForegroundInfo =
foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
percent = 0,
indeterminate = true,
)
override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
percent = 0,
indeterminate = true,
)
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, title, percent, indeterminate),
ConversionForegroundType.current(),
)
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) = ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, title, percent, indeterminate),
ConversionForegroundType.current(),
)
companion object {
const val KEY_INPUT_URI = "input_uri"
@@ -263,9 +251,8 @@ class ConversionWorker(
* yields `.mkv` or `.mka` and MP4 yields `.mp4` or `.m4a` without a preset having to
* enumerate both.
*/
fun outputNameFor(inputName: String, spec: OutputSpec): String =
inputName.substringBeforeLast('.', inputName) +
"_converted.${spec.extension}"
fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) +
"_converted.${spec.extension}"
fun request(
inputUri: Uri,
@@ -285,7 +272,7 @@ class ConversionWorker(
.putString(KEY_AUDIO_CODEC, spec.audioCodec.name)
.putString(KEY_QUALITY, quality.name)
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
.build()
.build(),
)
.build()
}
@@ -16,7 +16,9 @@ enum class FailureOutcome {
RETRY,
/** A genuine failure; report it to the user. */
FAIL;
FAIL,
;
companion object {
fun forStopReason(stopReason: Int): FailureOutcome =
@@ -1,17 +1,17 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.QualityTier
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
class FFmpegCommandBuilderTest {
@@ -94,8 +94,10 @@ class FFmpegCommandBuilderTest {
@Test
fun `every video encode path forces yuv420p`() {
val videoFormats = listOf(
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
OutputFormat.MP4_H264,
OutputFormat.MP4_H265,
OutputFormat.MKV_H264,
OutputFormat.MKV_H265,
OutputFormat.WEBM_VP9,
)
videoFormats.forEach { format ->
@@ -1,11 +1,11 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import java.io.File
class FFmpegConcatCommandTest {
@@ -31,7 +31,11 @@ class FFmpegConcatCommandTest {
@Test
fun `stream copy uses the concat demuxer and copies codecs`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
assertTrue(args.contains("concat"))
assertEquals("copy", args[args.indexOf("-c") + 1])
@@ -42,7 +46,11 @@ class FFmpegConcatCommandTest {
@Test
fun `stream copy allows absolute paths in the list file`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
// Without -safe 0 the demuxer rejects the absolute paths we generate.
assertEquals("0", args[args.indexOf("-safe") + 1])
@@ -51,7 +59,11 @@ class FFmpegConcatCommandTest {
@Test
fun `re-encode passes every input separately and builds a filter graph`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.REENCODE,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
assertEquals(2, args.count { it == "-i" })
val filter = args[args.indexOf("-filter_complex") + 1]
@@ -64,7 +76,11 @@ class FFmpegConcatCommandTest {
@Test
fun `re-encode maps the filter outputs rather than raw streams`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.REENCODE,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
assertTrue(args.contains("[v]"))
assertTrue(args.contains("[a]"))
@@ -81,7 +97,11 @@ class FFmpegConcatCommandTest {
@Test
fun `mkv output does not get faststart`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
File("/cache/j.mkv"),
OutputFormat.MKV_H264,
)
assertFalse(args.contains("+faststart"))
}
@@ -1,8 +1,8 @@
package org.libremediaconverter.model
import org.libremediaconverter.model.ConversionRouter.Reason
import org.junit.Assert.assertEquals
import org.junit.Test
import org.libremediaconverter.model.ConversionRouter.Reason
/**
* One test per routing predicate.
@@ -291,8 +291,12 @@ class ConversionRouterTest {
@Test
fun `the new containers are all FFmpeg-only`() {
listOf(
Container.MOV, Container.MKV, Container.MPEG_TS,
Container.AVI, Container.FLV, Container.ASF,
Container.MOV,
Container.MKV,
Container.MPEG_TS,
Container.AVI,
Container.FLV,
Container.ASF,
).forEach { container ->
val d = route(
OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY),