Skip to content

Commit

Permalink
feat(ios): support transcoding (#970)
Browse files Browse the repository at this point in the history
  • Loading branch information
Malinskiy authored Sep 17, 2024
1 parent 2cfd50b commit 6edf70e
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ data class VideoConfiguration(
@JsonProperty("codec") val codec: Codec = Codec.H264,
@JsonProperty("display") val display: Display = Display.INTERNAL,
@JsonProperty("mask") val mask: Mask = Mask.BLACK,
@JsonProperty("transcoding") val transcoding: TranscodingConfiguration = TranscodingConfiguration()
)

enum class Codec(val value: String) {
Expand All @@ -34,3 +35,15 @@ enum class Codec(val value: String) {
@JsonProperty("hevc") HEVC("hevc"),
}

data class TranscodingConfiguration(
@JsonProperty("enabled") val enabled: Boolean = false,
@JsonProperty("ffmpegPath") val binary: String = "/opt/homebrew/bin/ffmpeg",
@JsonProperty("size") val size: Size = Size.hd720,
)


enum class Size(val value: Int) {
@JsonProperty("hd480") hd480(852),
@JsonProperty("hd720") hd720(1280),
@JsonProperty("hd1080") hd1080(1920),
}
13 changes: 13 additions & 0 deletions docs/runner/apple/configure/ios.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,19 @@ screenRecordConfiguration:
mask: black
```

#### Transcoding
Marathon can optionally use ffmpeg to transcode the captured video screen recordings and downscale them.
Supported sizes are [hd480, hd720, hd1080], default is hd720 i.e. largest dimension of 1280px.

```yaml
screenRecordConfiguration:
videoConfiguration:
transcoding:
enabled: true
ffmpegPath: /opt/homebrew/bin/ffmpeg
size: hd720
```

The `display` field can be either `internal` or `external`.
The `mask` field can be either `black` or `ignored`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ class RemoteFileManager(private val device: AppleDevice) {

fun xctestrunFileName(): String = "marathon.xctestrun"

private fun xctestFileName(): String = "marathon.xctest"
private fun libXctestParserFileName(): String = "libxctest-parser.dylib"
private fun xctestFileName(): String = "marathon.xctest"
private fun libXctestParserFileName(): String = "libxctest-parser.dylib"

fun appUnderTestFileName(): String = "appUnderTest.app"
fun testRunnerFileName(): String = "xctestRunner.app"
fun appUnderTestFileName(): String = "appUnderTest.app"
fun testRunnerFileName(): String = "xctestRunner.app"

private fun xcresultFileName(batch: TestBatch): String =
"${device.udid}.${batch.id}.xcresult"
Expand All @@ -74,14 +74,15 @@ class RemoteFileManager(private val device: AppleDevice) {
private suspend fun safeExecuteCommand(command: List<String>) {
try {
device.executeWorkerCommand(command)
} catch (_: Exception) {}
} catch (_: Exception) {
}
}

private suspend fun executeCommand(command: List<String>, errorMessage: String): String? {
return try {
val result = device.executeWorkerCommand(command) ?: return null
val stderr = result.combinedStderr.trim()
if(stderr.isNotBlank()) {
if (stderr.isNotBlank()) {
logger.error { "cmd=${command.joinToString(" ")}, stderr=$stderr" }
}
result.combinedStdout.trim()
Expand All @@ -92,7 +93,11 @@ class RemoteFileManager(private val device: AppleDevice) {
}

fun remoteVideoForTest(test: Test, testBatchId: String): String {
return remoteFileForTest(videoFileName(test, testBatchId))
return remoteFileForTest(videoFileName(test, testBatchId, temporary = false))
}

fun remoteTempVideoForTest(test: Test, testBatchId: String): String {
return remoteFileForTest(videoFileName(test, testBatchId, temporary = true))
}

fun remoteVideoPidfile() = remoteFileForTest(videoPidFileName(device.udid))
Expand All @@ -113,14 +118,15 @@ class RemoteFileManager(private val device: AppleDevice) {
return "$udid.${type.value}"
}

private fun videoFileName(test: Test, testBatchId: String): String {
val testSuffix = "-$testBatchId.mp4"
private fun videoFileName(test: Test, testBatchId: String, temporary: Boolean = false): String {
val tempSuffix = if (temporary) "-temp" else ""
val testSuffix = "-$testBatchId$tempSuffix.mp4"
val testName = "${test.toClassName('-')}-${test.method}".escape()
return "$testName$testSuffix"
}

fun parentOf(remoteXctestrunFile: String): String {
return remoteXctestrunFile.substringBeforeLast(FILE_SEPARATOR)
fun parentOf(path: String): String {
return path.substringBeforeLast(FILE_SEPARATOR)
}

private fun videoPidFileName(udid: String) = "${udid}.pid"
Expand All @@ -134,7 +140,7 @@ class RemoteFileManager(private val device: AppleDevice) {
}

suspend fun copy(src: String, dst: String, override: Boolean = true) {
if(override) {
if (override) {
safeExecuteCommand(
listOf("rm", "-R", dst)
)
Expand All @@ -143,8 +149,15 @@ class RemoteFileManager(private val device: AppleDevice) {
listOf("cp", "-R", src, dst), "failed to copy remote directory $src to $dst"
)
}

suspend fun move(src: String, dst: String) {
executeCommand(
listOf("mv", "-f", src, dst), "failed to move remote file $src to $dst"
)
}

suspend fun symlink(src: String, dst: String, override: Boolean = true) {
if(override) {
if (override) {
safeExecuteCommand(
listOf("rm", "-R", dst)
)
Expand All @@ -154,6 +167,13 @@ class RemoteFileManager(private val device: AppleDevice) {
)
}

suspend fun test(path: String): Boolean {
val commandResult = device.executeWorkerCommand(
listOf("test", "-f", path)
)

return commandResult?.successful == true
}

private fun String.bashEscape() = "'" + replace("'", "'\\''") + "'"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ class AppleSimulatorDevice(
override val storagePath = "${AppleDevice.SHARED_PATH}/$udid"
private lateinit var xcodeVersion: XcodeVersion
private lateinit var testBundle: AppleTestBundle
private var supportsTranscoding: Boolean = false

/**
* Called only once per device's lifetime
Expand Down Expand Up @@ -188,6 +189,14 @@ class AppleSimulatorDevice(
} ?: "Unknown"

deviceFeatures = detectFeatures()

supportsTranscoding = executeWorkerCommand(listOf(vendorConfiguration.screenRecordConfiguration.videoConfiguration.transcoding.binary, "-version"))?.let {
if (it.successful) {
true
} else {
false
}
} ?: false
}
}

Expand Down Expand Up @@ -294,7 +303,7 @@ class AppleSimulatorDevice(

else -> throw DeviceLostException(e)
}
} catch(e: CancellationException) {
} catch (e: CancellationException) {
job?.cancel(e)
throw e
}
Expand Down Expand Up @@ -736,6 +745,8 @@ class AppleSimulatorDevice(
testBatchId,
this,
screenRecordingPolicy,
vendorConfiguration.screenRecordConfiguration.videoConfiguration,
supportsTranscoding,
this,
)
.also { attachmentProviders.add(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import com.malinskiy.marathon.apple.RemoteFileManager
import com.malinskiy.marathon.apple.listener.AppleTestRunListener
import com.malinskiy.marathon.apple.logparser.parser.DeviceFailureReason
import com.malinskiy.marathon.config.ScreenRecordingPolicy
import com.malinskiy.marathon.config.vendor.apple.ios.Codec
import com.malinskiy.marathon.config.vendor.apple.ios.Size
import com.malinskiy.marathon.config.vendor.apple.ios.VideoConfiguration
import com.malinskiy.marathon.device.DevicePoolId
import com.malinskiy.marathon.device.toDeviceInfo
import com.malinskiy.marathon.exceptions.TransferException
Expand Down Expand Up @@ -33,6 +36,8 @@ class ScreenRecordingListener(
private val testBatchId: String,
private val device: AppleSimulatorDevice,
private val screenRecordingPolicy: ScreenRecordingPolicy,
private val videoConfiguration: VideoConfiguration,
private val supportsTranscoding: Boolean,
coroutineScope: CoroutineScope,
private val attachmentProvider: AttachmentProviderDelegate = AttachmentProviderDelegate(),
) : AppleTestRunListener, AttachmentProvider by attachmentProvider, CoroutineScope by coroutineScope {
Expand Down Expand Up @@ -100,12 +105,19 @@ class ScreenRecordingListener(

private suspend fun pullVideo(test: Test, success: Boolean) {
try {
val videoForTest = remoteFileManager.remoteVideoForTest(test, testBatchId)
if (!remoteFileManager.test(videoForTest)) return

if (screenRecordingPolicy == ScreenRecordingPolicy.ON_ANY ||
screenRecordingPolicy == ScreenRecordingPolicy.ON_FAILURE && !success
) {
if (videoConfiguration.transcoding.enabled && supportsTranscoding) {
val remoteTempFilePath = remoteFileManager.remoteTempVideoForTest(test, testBatchId)
transcode(videoForTest, remoteTempFilePath, videoConfiguration.transcoding.size)
}
pullTestVideo(test)
}
removeRemoteVideo(remoteFileManager.remoteVideoForTest(test, testBatchId))
removeRemoteVideo(videoForTest)
} catch (e: TransferException) {
logger.warn { "Can't pull video" }
}
Expand All @@ -116,6 +128,12 @@ class ScreenRecordingListener(
if (device.verifyHealthy()) {
stop()
lastRemoteFile?.let {
if (!remoteFileManager.test(it)) return

if (videoConfiguration.transcoding.enabled && supportsTranscoding) {
transcode(it, "$it.tmp", videoConfiguration.transcoding.size)
}

pullLastBatchVideo(it)
removeRemoteVideo(it)
}
Expand Down Expand Up @@ -147,13 +165,35 @@ class ScreenRecordingListener(
supervisorJob?.cancelAndJoin()
}

private suspend fun transcode(src: String, tempFile: String, size: Size) {
remoteFileManager.move(src, tempFile)

val millis = measureTimeMillis {
val optional = when (videoConfiguration.codec) {
Codec.H264 -> "-movflags +faststart"
Codec.HEVC -> ""
}
val result = device.executeWorkerCommand(
listOf(
"sh",
"-c",
"${videoConfiguration.transcoding.binary} -i $tempFile -vf \"scale=${size.value}:${size.value}:force_original_aspect_ratio=decrease\" -preset ultrafast $optional $src"
)
)
if (result?.successful == false) {
logger.error { "Transcoding failed for $src: ${result.combinedStdout}, ${result.combinedStderr}" }
}
}
logger.debug { "Transcoding finished in ${millis}ms $src" }
}

private suspend fun pullTestVideo(test: Test) {
val localVideoFile = fileManager.createFile(FileType.VIDEO, pool, device.toDeviceInfo(), test, testBatchId)
val remoteFilePath = remoteFileManager.remoteVideoForTest(test, testBatchId)
val millis = measureTimeMillis {
device.pullFile(remoteFilePath, localVideoFile)
}
logger.debug { "Pulling finished in ${millis}ms $remoteFilePath " }
logger.debug { "Pulling finished in ${millis}ms $remoteFilePath" }
attachmentProvider.onAttachment(test, Attachment(localVideoFile, AttachmentType.VIDEO, Attachment.Name.SCREEN))
}

Expand All @@ -162,7 +202,7 @@ class ScreenRecordingListener(
val millis = measureTimeMillis {
device.pullFile(remoteFilePath, localVideoFile)
}
logger.debug { "Pulling finished in ${millis}ms $remoteFilePath " }
logger.debug { "Pulling finished in ${millis}ms $remoteFilePath" }
}

private suspend fun removeRemoteVideo(remoteFilePath: String) {
Expand Down

0 comments on commit 6edf70e

Please sign in to comment.