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>
87 lines
3.2 KiB
Kotlin
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
|
|
}
|
|
}
|