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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user