Files
LibreMediaConverter/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt
T
JMR-devandClaude Opus 5 ec2cae256f Give both engines one rc-to-outcome function, and unify the failure message (#203)
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>
2026-09-05 19:36:39 -05:00

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
}
}