Let the user pick a container and codecs independently, and remux without re-encoding

OutputFormat was a closed enum of twelve (container, videoCodec, audioCodec)
triples, defended on the grounds that a closed set was what made routing
decidable. Two things it could not express: changing the container while copying
the streams, and choosing codecs per track.

OutputSpec replaces it as the vocabulary; OutputFormat stays as presets over it.
Decidability moves to ContainerCapabilities, which is explicit and unit-tested
rather than implicit in whichever combinations somebody enumerated.

The matrix is indexed by (container, codec, trackType, mode), not one boolean.
"Can MP4 carry AV1" and "can this app encode AV1" have different answers, and
copy is where the difference shows: a single flag would refuse a legitimate
remux or promise an encode neither engine can deliver.

COPY is a codec value rather than a flag, so every exhaustive `when` in the
codebase had to say what it does about copying. CopyPlanner resolves it before
anything else reads the request, and inherits ConcatPlanner's rule that an
unproven match is never a copy — a needless re-encode costs time, a wrong stream
copy costs a file that will not play.

Container now drives -f, the extension and the SAF MIME type, so Matroska
without video is .mka and MP4 without video is .m4a without a preset for each.
FLAC was declared as Container.MKV with a .flac extension, inert only while
nothing read the container; it now has its own. Six containers added: MOV, MKV
audio, MPEG-TS, AVI, FLV and WMV/ASF.

Routing asks the plan, never the request. COPY belongs to none of the capability
sets, so testing the request directly sends every remux to FFmpeg on the first
check — and nothing notices, because -c copy produces a correct file, just on
the CPU. The router also learns what Media3 can *carry* as opposed to encode:
its MP4 muxer takes AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC.

MediaProbe now separates "no video track" from "could not parse" and reports the
source container, which MediaExtractor cannot supply at all. FFprobe runs on
every pick for that reason, not as a fallback.

The Advanced picker shows the whole matrix and lets an impossible combination be
selected on purpose, then explains it and offers alternatives. Convert is what
blocks the job. ConversionWorker validates too, so a stale queued spec fails with
the reason rather than being coerced into something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 07:07:52 -05:00
co-authored by Claude Opus 5
parent 00c422f317
commit 2e0cf2f5d6
35 changed files with 2824 additions and 263 deletions
@@ -8,15 +8,25 @@ 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
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -25,15 +35,27 @@ import java.util.UUID
/** User-chosen conversion settings. */
data class ConversionSettings(
val format: OutputFormat = OutputFormat.MP4_H265,
val spec: OutputSpec = OutputFormat.MP4_H265.spec,
val quality: QualityTier = QualityTier.FAST,
val enginePreference: EnginePreference = EnginePreference.AUTO,
)
) {
/** The preset this spec corresponds to, or null once it has been edited past all of them. */
val matchingPreset: OutputFormat?
get() = OutputFormat.entries.firstOrNull { it.spec == spec }
}
data class InputFile(
val uri: Uri,
val displayName: String,
val sizeBytes: Long,
/**
* What probing found. Null only while the probe is still running.
*
* Held here rather than recomputed because three things need it: the source-info card, the
* validity check for the chosen output, and the copy planner's decision about whether a track
* can be stream-copied.
*/
val probe: InputProbe? = null,
)
sealed interface ConversionState {
@@ -68,15 +90,52 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
private val _settings = MutableStateFlow(ConversionSettings())
val settings: StateFlow<ConversionSettings> = _settings.asStateFlow()
fun setFormat(format: OutputFormat) = _settings.update { it.copy(format = format) }
/**
* Whether the chosen output can actually be produced from the chosen input.
*
* Derived rather than stored so it cannot go stale: it recomputes when either the settings or
* the picked file changes. The Advanced picker deliberately allows an invalid combination to be
* selected, so this is what turns that into an explanation and a disabled Convert button.
*/
val validation: StateFlow<Validation> = combine(_state, _settings) { state, settings ->
ContainerCapabilities.validate(settings.spec, state.probe() ?: InputProbe())
}.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 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 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 onInputPicked(uri: Uri) {
viewModelScope.launch {
val info = withContext(Dispatchers.IO) { queryFile(uri) }
_state.value = ConversionState.Ready(info)
// Both the metadata query and the probe touch disk, and the probe spawns FFprobe.
// Neither belongs on the main thread.
val file = withContext(Dispatchers.IO) { queryFile(uri) }
// Show the file as soon as its name and size are known. Probing now runs FFprobe on
// every pick, which is a native process spawn, and making the whole screen wait on it
// would read as the app having ignored the tap.
_state.value = ConversionState.Ready(file)
val probe = withContext(Dispatchers.IO) { MediaProbe.probe(getApplication(), uri) }
// Only fill in the probe if the user has not moved on in the meantime.
_state.update { current ->
if (current is ConversionState.Ready && current.input.uri == uri) {
ConversionState.Ready(file.copy(probe = probe))
} else {
current
}
}
}
}
@@ -95,7 +154,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
inputUri = input.uri,
displayName = input.displayName,
sizeBytes = input.sizeBytes,
format = settings.format,
spec = settings.spec,
quality = settings.quality,
enginePreference = settings.enginePreference,
)
@@ -169,7 +228,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
_state.value = ConversionState.Saved(
ConversionWorker.outputNameFor(
converted.input.displayName,
_settings.value.format,
_settings.value.spec,
)
)
}.onFailure { e ->
@@ -188,9 +247,17 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
fun suggestedOutputName(): String =
ConversionWorker.outputNameFor(
currentInput()?.displayName ?: "output",
_settings.value.format,
_settings.value.spec,
)
private fun ConversionState.probe(): InputProbe? = when (this) {
is ConversionState.Ready -> input.probe
is ConversionState.Converting -> input.probe
is ConversionState.Waiting -> input.probe
is ConversionState.Converted -> input.probe
else -> null
}
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converting -> s.input