diff --git a/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/ios/VideoConfiguration.kt b/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/ios/VideoConfiguration.kt index 4dfc5c3cc..0b624eeb6 100644 --- a/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/ios/VideoConfiguration.kt +++ b/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/ios/VideoConfiguration.kt @@ -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) { @@ -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), +} diff --git a/docs/runner/apple/configure/ios.md b/docs/runner/apple/configure/ios.md index 99a8abbbf..04dc88d0d 100644 --- a/docs/runner/apple/configure/ios.md +++ b/docs/runner/apple/configure/ios.md @@ -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`. diff --git a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt index 3667ffa5e..074eecebb 100644 --- a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt +++ b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt @@ -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" @@ -74,14 +74,15 @@ class RemoteFileManager(private val device: AppleDevice) { private suspend fun safeExecuteCommand(command: List) { try { device.executeWorkerCommand(command) - } catch (_: Exception) {} + } catch (_: Exception) { + } } private suspend fun executeCommand(command: List, 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() @@ -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)) @@ -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" @@ -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) ) @@ -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) ) @@ -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("'", "'\\''") + "'" diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt index 1c9eeb0d9..8028b9ae4 100644 --- a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt @@ -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 @@ -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 } } @@ -294,7 +303,7 @@ class AppleSimulatorDevice( else -> throw DeviceLostException(e) } - } catch(e: CancellationException) { + } catch (e: CancellationException) { job?.cancel(e) throw e } @@ -736,6 +745,8 @@ class AppleSimulatorDevice( testBatchId, this, screenRecordingPolicy, + vendorConfiguration.screenRecordConfiguration.videoConfiguration, +supportsTranscoding, this, ) .also { attachmentProviders.add(it) } diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/video/ScreenRecordingListener.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/video/ScreenRecordingListener.kt index 69c3702c5..30c8416ab 100644 --- a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/video/ScreenRecordingListener.kt +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/video/ScreenRecordingListener.kt @@ -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 @@ -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 { @@ -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" } } @@ -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) } @@ -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)) } @@ -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) {