Files
LibreMediaConverter/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt
T

330 lines
14 KiB
Kotlin

package org.libremediaconverter.join
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegSession
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.SessionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import java.io.File
/**
* The join path, end to end on a device.
*
* The point of these tests is the strategy decision, not merely that a file appears.
* FFmpeg's `concat` demuxer does not reliably reject mismatched inputs -- it can emit a
* file whose later segments are garbled -- so "it produced output" is not evidence of
* correctness. Each test therefore checks which strategy ran *and* that the result is
* long enough to contain both inputs.
*/
@RunWith(AndroidJUnit4::class)
class ConcatEngineTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val engine = ConcatEngine(context)
private val staged = mutableListOf<File>()
private lateinit var clipA: File
private lateinit var clipB: File
private lateinit var clipMismatched: File
@Before
fun setUp() {
clipA = copyAsset("clip_a.mp4")
clipB = copyAsset("clip_b.mp4")
clipMismatched = copyAsset("clip_c_mismatched.mp4")
}
@After
fun tearDown() {
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
}
/**
* Cancelling a *running* join actually stops the native session.
*
* The `FFmpegEngine` half of #224 landed first (PR #236); this is the same gap in
* [ConcatEngine]. Before these two, no test on any source set had ever asked a real native
* session to stop — every `cancel` in `app/src/androidTest` targets WorkManager entries that
* are queued or already finished.
*
* ## Two things carried over from the conversion side, both measured there
*
* **The assertion is the session's return code.** A cancelled session ends with the cancel
* code, a completed one does not. The alternative — checking the output file — is even less
* available here than it was for conversions: [ConcatEngine] does not delete its output on
* cancellation at all. Its `invokeOnCancellation` is `FFmpegKit.cancel(...)` and nothing else,
* where [org.libremediaconverter.ffmpeg.FFmpegEngine]'s also deletes the partial. Whether that
* asymmetry is deliberate is a separate question from this test, which is why this asserts the
* thing that is true of both.
*
* **The cancel is triggered on [SessionState.RUNNING], not on progress.** `ConcatWorker`
* publishes no progress at all, so there is no callback to hang it on even in principle — but
* the conversion side established the deeper reason: the committed clips are 2 s at 320x240 and
* the encode outruns a callback-triggered cancel.
*
* **And the attempt is retried**, for the reason the conversion side measured the hard way: on
* a loaded runner the thread that observed `RUNNING` can be descheduled long enough for a short
* encode to finish before it calls `cancel`, which failed two CI legs there. An attempt whose
* session finished first has tested nothing, so it is a miss rather than a failure; only
* exhausting [CANCEL_ATTEMPTS] fails, and with `FFmpegKit.cancel` removed every attempt misses,
* so the mutation still bites.
*
* The inputs are deliberately the **mismatched** pair, so [ConcatStrategy.REENCODE] is chosen.
* A stream copy of two short clips is close to instantaneous and would leave nothing to
* interrupt; re-encoding is the case where a user would actually reach for Cancel.
*
* *Mutation:* drop `FFmpegKit.cancel(session.getSessionId())` from `ConcatEngine`'s
* `invokeOnCancellation` — the session runs to completion and this fails.
*/
@Test
fun cancellingARunningJoinCancelsTheNativeSession(): Unit = runBlocking {
val outcomes = mutableListOf<String>()
repeat(CANCEL_ATTEMPTS) { attempt ->
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
val out = output("cancelled_join_$attempt.mp4")
val job = launch(Dispatchers.IO) {
engine.join(
listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)),
out,
ConcatWorker.DEFAULT_FORMAT,
)
}
val ours = withTimeout(TIMEOUT_MS) {
var found: FFmpegSession? = null
while (found == null) {
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
if (found == null) delay(POLL_MS)
}
found
}
job.cancelAndJoin()
withTimeout(TIMEOUT_MS) {
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
}
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
}
fail(
"never interrupted a running join in $CANCEL_ATTEMPTS attempts, so either every " +
"encode finished first or cancellation does not reach it: $outcomes",
)
}
private fun copyAsset(name: String): File {
val out = File(context.cacheDir, name)
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> out.outputStream().use { asset.copyTo(it) } }
return out
}
private fun output(name: String) = File(context.cacheDir, name).also {
it.delete()
staged += it
}
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount)
.map { extractor.getTrackFormat(it) }
.filter { it.containsKey(MediaFormat.KEY_DURATION) }
.maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L
} finally {
extractor.release()
}
}
// --- the fast path ------------------------------------------------------
@Test
fun matchingClipsAreJoinedByStreamCopy(): Unit = runBlocking {
val out = output("joined_matching.mp4")
val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out)
assertEquals(
"identical inputs should not need re-encoding",
ConcatStrategy.STREAM_COPY,
result.strategy,
)
assertTrue("no output produced", out.exists() && out.length() > 0)
// Both 2 s inputs must be present, not just the first.
assertTrue(
"joined duration ${durationMs(out)}ms is too short to hold both clips",
durationMs(out) >= 3_500,
)
}
// --- the correctness path ----------------------------------------------
@Test
fun mismatchedClipsAreReEncodedRatherThanStreamCopied(): Unit = runBlocking {
val out = output("joined_mismatched.mp4")
val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out)
// This is the case a naive implementation gets wrong: the demuxer would accept
// these and produce a corrupt second half.
assertEquals(
"differing resolution must force a re-encode",
ConcatStrategy.REENCODE,
result.strategy,
)
assertTrue("no output produced", out.exists() && out.length() > 0)
assertTrue(
"joined duration ${durationMs(out)}ms is too short to hold both clips",
durationMs(out) >= 3_500,
)
}
@Test
fun reEncodedOutputIsPlayableAndCarriesBothTracks(): Unit = runBlocking {
val out = output("joined_playable.mp4")
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out)
val extractor = MediaExtractor()
try {
extractor.setDataSource(out.absolutePath)
val mimes = (0 until extractor.trackCount).map {
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
}
assertTrue("no video track in $mimes", mimes.any { it.startsWith("video/") })
assertTrue("no audio track in $mimes", mimes.any { it.startsWith("audio/") })
} finally {
extractor.release()
}
}
// --- guards -------------------------------------------------------------
@Test
fun joiningRefusesFewerThanTwoInputs() {
val out = output("joined_single.mp4")
val failure = runCatching {
runBlocking { engine.join(listOf(Uri.fromFile(clipA)), out) }
}.exceptionOrNull()
assertTrue(
"expected an IllegalArgumentException, got $failure",
failure is IllegalArgumentException,
)
}
/**
* A failed join tells the user the return code and what FFmpeg said.
*
* **This is the device half of #203/#217**, whose PR closed by noting the join legs had not
* been run. Running them would not have answered it: nothing on either source set drove a real
* join *failure*, so the unified message was asserted only against values a JVM test hands to
* `sessionOutcome` directly.
*
* What is device-only here is that the three reads behind that message work against a real
* native session at all — `getReturnCode`, `getFailStackTrace` and `getAllLogsAsString`. If
* the log tail came back null or empty on a device, the user would get `Joining failed (1): `
* with nothing after the colon and every JVM test would still pass.
*
* **What this deliberately does not pin is the preference between the two detail sources.** On
* an ordinary non-zero return code FFmpegKit reports no fail stack trace, so the stack-trace-
* first rule and the log-tail-first rule produce the same text and no assertion here can tell
* them apart. That ordering is [SessionOutcomeTest][org.libremediaconverter.ffmpeg.SessionOutcomeTest]'s
* job, where both sources can be non-blank at once. Asserting it here would be a test whose
* KDoc claims more than it checks — the `probeForConcat` mistake wave 3 caught.
*
* The failure is forced with an input that does not exist, which the concat demuxer rejects
* the same way on every FFmpeg build, rather than with malformed media whose handling varies.
*/
@Test
fun aFailedJoinReportsTheReturnCodeAndWhatFFmpegSaid(): Unit = runBlocking {
val missing = File(context.cacheDir, "no_such_clip.mp4").also { it.delete() }
val out = output("joined_failure.mp4")
val failure = runCatching {
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(missing)), out)
}.exceptionOrNull()
assertTrue(
"a join over a missing input must fail, got $failure",
failure is FFmpegEngine.FFmpegException,
)
val message = failure?.message.orEmpty()
assertTrue(
"the message must name the operation and carry the return code, was: '$message'",
message.startsWith("Joining failed ("),
)
// The half a JVM test cannot reach: a real session actually produced detail to show.
val detail = message.substringAfter("): ", "")
assertTrue(
"the message stopped at the return code and told the user nothing, was: '$message'",
detail.isNotBlank(),
)
}
@Test
fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking {
val out = output("joined_cleanup.mp4")
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out)
// Asked of StagingNames rather than spelled out: the list file used to be the constant
// concat_list.txt, and a literal here would have gone on passing vacuously once the name
// moved -- it would be asserting that a file nothing creates does not exist.
assertTrue(
"the concat list file was left behind",
!File(out.parentFile, StagingNames.concatListFor(out.name)).exists(),
)
}
// --- the probe the planner depends on ----------------------------------
@Test
fun probeReadsThePropertiesTheStrategyDependsOn() {
val a = MediaProbe.probeForConcat(context, Uri.fromFile(clipA))
val mismatched = MediaProbe.probeForConcat(context, Uri.fromFile(clipMismatched))
assertEquals("h264", a.videoCodec)
assertEquals(320, a.width)
assertEquals(240, a.height)
assertEquals(640, mismatched.width)
assertEquals(480, mismatched.height)
assertTrue(
"the probe must actually distinguish these clips, or the planner cannot",
a.width != mismatched.width || a.height != mismatched.height,
)
}
private companion object {
/** Generous: it bounds a hang, and both waits here normally settle in well under a second. */
const val TIMEOUT_MS = 30_000L
const val POLL_MS = 50L
/** See the conversion side: a miss is the loaded-runner case, not a defect. */
const val CANCEL_ATTEMPTS = 5
}
}