Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ios): support transcoding #970

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading