FFmpegEngine and ConcatEngine each carried their own copy of the same `when`, and the copies had drifted: one preferred the fail stack trace and fell back to the log tail, the other only ever read the log tail. Neither was tested -- both live inside a callback handed to FFmpegKit, which does not run on the JVM -- so nothing could see that the two disagreed about what a failed session says. sessionOutcome() now holds the rule and each engine maps Success/Cancelled/Failed onto its continuation. Verified JVM-safe rather than assumed: javap over the committed AAR shows ReturnCode(int) as a plain public constructor with pure static isSuccess/isCancel and a <clinit> that loads no native library. Per #203's decision this unifies on the stack trace, so a join failure now carries the diagnostics a conversion failure always did. The PREFIX stays per-engine: unifying the strategy must not unify the sentence, since a join reporting "FFmpeg failed" would be a worse message than the one it replaces. There is a test for exactly that. The two message sources are lambdas rather than values, and that is load-bearing. getFailStackTrace and getAllLogsAsString are calls onto a native session, and only the failure arm needs either; taking them by value would put both on the happy path of every successful conversion, which the shape this replaces did not -- it read them inside the else branch. Same reasoning as capabilitiesFrom taking a Sequence in #194: a seam should not change what runs when. There is a test that counts the reads, and the eager mutation reddens it. A null return code is a real input rather than a defensive one -- getReturnCode() is nullable and a session killed before reporting has none -- so it fails, with "null" where the number would be. Nothing asserted the old join text: `grep -rn 'Joining failed|FFmpeg failed' app/src/` returns only main, plus ConcatWorker.GENERIC_FAILURE_MESSAGE, which is a different constant this does not touch. Re-run immediately before committing, as the ticket asked. Mutations, all run and restored: swap the ifBlank operands 1 red treat cancellation as a failure 2 red read both message sources eagerly 1 red hardcode the prefix 1 red Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
88 lines
3.6 KiB
Kotlin
88 lines
3.6 KiB
Kotlin
package org.libremediaconverter.ffmpeg
|
|
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import android.util.Log
|
|
import com.arthenica.ffmpegkit.FFmpegKit
|
|
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
import org.libremediaconverter.convert.ConcatJoiner
|
|
import org.libremediaconverter.convert.MediaProbe
|
|
import org.libremediaconverter.convert.StagingNames
|
|
import org.libremediaconverter.model.ConcatPlanner
|
|
import org.libremediaconverter.model.ConcatStrategy
|
|
import org.libremediaconverter.model.OutputFormat
|
|
import java.io.File
|
|
import kotlin.coroutines.resume
|
|
import kotlin.coroutines.resumeWithException
|
|
|
|
/**
|
|
* Joins several inputs into one file.
|
|
*
|
|
* Picks between a stream copy and a full re-encode by inspecting the inputs, because
|
|
* the `concat` demuxer requires matching codec, resolution and timebase and does not
|
|
* reliably fail when they differ — it can emit a file whose later segments are
|
|
* garbled. See [ConcatPlanner].
|
|
*/
|
|
class ConcatEngine(private val context: Context) : ConcatJoiner {
|
|
|
|
data class Result(val strategy: ConcatStrategy, val output: File)
|
|
|
|
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): Result {
|
|
require(inputs.size >= 2) { "Joining needs at least two files." }
|
|
|
|
val paths = inputs.map { uri ->
|
|
if (uri.scheme == "content") {
|
|
FFmpegKitConfig.getSafParameterForRead(context, uri)
|
|
} else {
|
|
uri.path
|
|
} ?: error("Could not open one of the input files.")
|
|
}
|
|
|
|
val strategy = ConcatPlanner.plan(inputs.map { MediaProbe.probeForConcat(context, it) })
|
|
Log.i(TAG, "Joining ${inputs.size} files using $strategy")
|
|
|
|
// The demuxer reads its input list from a file, which must live somewhere
|
|
// FFmpeg can read; app cache is a real path, so it just works.
|
|
//
|
|
// Named after the output rather than by the constant "concat_list.txt" it used to use.
|
|
// The constant meant any two joins running at once shared one list file, so one of them
|
|
// read the other's inputs -- and it is why a blanket sweep of the staging directory was
|
|
// never safe. See StagingNames.
|
|
val listFile = File(output.parentFile, StagingNames.concatListFor(output.name)).apply {
|
|
writeText(FFmpegConcatCommand.listFileContents(paths))
|
|
}
|
|
|
|
val args = FFmpegConcatCommand.build(strategy, paths, listFile, output, format)
|
|
try {
|
|
execute(args)
|
|
} finally {
|
|
listFile.delete()
|
|
}
|
|
return Result(strategy, output)
|
|
}
|
|
|
|
private suspend fun execute(args: List<String>) = suspendCancellableCoroutine { cont ->
|
|
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
|
|
val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
|
|
val outcome = sessionOutcome(
|
|
rc = completed.getReturnCode(),
|
|
prefix = "Joining",
|
|
failStackTrace = { completed.getFailStackTrace() },
|
|
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
|
|
)
|
|
when (outcome) {
|
|
SessionOutcome.Success -> cont.resume(Unit)
|
|
SessionOutcome.Cancelled -> cont.cancel()
|
|
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegEngine.FFmpegException(outcome.message))
|
|
}
|
|
}
|
|
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
|
|
}
|
|
|
|
private companion object {
|
|
const val TAG = "ConcatEngine"
|
|
const val LOG_TAIL_LIMIT = 40
|
|
}
|
|
}
|