Files
LibreMediaConverter/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.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

87 lines
3.2 KiB
Kotlin

package org.libremediaconverter.ffmpeg
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Software conversion via the bundled FFmpeg.
*
* Handles everything Media3 structurally cannot: Matroska, MP3, GIF, frame sequences,
* inputs with no platform decoder, and the CRF quality tier.
*
* Like [org.libremediaconverter.convert.Media3Engine], input arrives as a real
* filesystem path and output is written to app-private cache. FFmpeg is perfectly able
* to write through a SAF descriptor via its ffkitsaf protocol, but MP4 faststart has
* to seek backwards to rewrite the moov atom, which a SAF descriptor does not reliably
* support — so staging is the safe default for every format rather than a special case.
*/
class FFmpegEngine : SoftwareTranscoder {
init {
FFmpegKitConfig.setLogLevel(Level.AV_LOG_WARNING)
}
/**
* Runs a conversion, reporting progress 0..100.
*
* Progress is derived from the statistics callback's timestamp against the known
* input duration. FFmpeg has no native notion of percentage complete, so a
* [durationMs] of zero means progress simply cannot be reported — the caller gets
* an indeterminate job rather than a fabricated number.
*/
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
): Unit = suspendCancellableCoroutine { cont ->
val args = FFmpegCommandBuilder.build(request, inputPath, output.absolutePath)
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
val session = FFmpegKit.executeWithArgumentsAsync(
args.toTypedArray(),
{ completed ->
val outcome = sessionOutcome(
rc = completed.getReturnCode(),
prefix = "FFmpeg",
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(FFmpegException(outcome.message))
}
},
{ log -> Log.d(TAG, log.message.trimEnd()) },
{ stats ->
if (durationMs > 0) {
val percent = (stats.time / durationMs * 100).toInt().coerceIn(0, 100)
onProgress(percent)
}
},
)
cont.invokeOnCancellation {
FFmpegKit.cancel(session.getSessionId())
output.delete()
}
}
class FFmpegException(message: String) : RuntimeException(message)
private companion object {
const val TAG = "FFmpegEngine"
const val LOG_TAIL_LIMIT = 40
}
}