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

Add option to render recording locally using "maestro record --local" #2173

Merged
merged 1 commit into from
Dec 10, 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
10 changes: 10 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ hiddenapibypass = "4.3"
jackson = "2.17.1"
jansi = "2.4.1"
jarchivelib = "1.2.0"
jcodec = "0.2.5"
junit = "5.10.2"
kotlin = "1.8.22"
kotlinResult = "1.1.18"
Expand All @@ -42,6 +43,7 @@ mockk = "1.12.0"
mozillaRhino = "1.7.14"
picocli = "4.6.3"
selenium = "4.13.0"
skiko = "0.8.18"
slf4j = "1.7.36"
squareOkhttp = "4.12.0"
squareOkio = "3.8.0"
Expand Down Expand Up @@ -87,6 +89,8 @@ jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" }
jansi = { module = "org.fusesource.jansi:jansi", version.ref = "jansi" }
jarchivelib = { module = "org.rauschig:jarchivelib", version.ref = "jarchivelib" }
jcodec = { module = "org.jcodec:jcodec", version.ref = "jcodec" }
jcodec-awt = { module = "org.jcodec:jcodec-javase", version.ref = "jcodec" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
Expand All @@ -110,6 +114,12 @@ mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "mozillaRhino" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" }
selenium = { module = "org.seleniumhq.selenium:selenium-java", version.ref = "selenium" }
skiko-macos-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-arm64", version.ref = "skiko" }
skiko-macos-x64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-x64", version.ref = "skiko" }
skiko-linux-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-linux-arm64", version.ref = "skiko" }
skiko-linux-x64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-linux-x64", version.ref = "skiko" }
skiko-windows-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-windows-arm64", version.ref = "skiko" }
skiko-windows-x64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-windows-x64", version.ref = "skiko" }
slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
square-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "squareOkhttp" }
square-okhttp-logs = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "squareOkhttp" }
Expand Down
8 changes: 8 additions & 0 deletions maestro-cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,21 @@ dependencies {
implementation(libs.jackson.dataformat.xml)
implementation(libs.jackson.datatype.jsr310)
implementation(libs.jansi)
implementation(libs.jcodec)
implementation(libs.jcodec.awt)
implementation(libs.square.okhttp)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.jarchivelib)
implementation(libs.commons.codec)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.html)
implementation(libs.skiko.macos.arm64)
implementation(libs.skiko.macos.x64)
implementation(libs.skiko.linux.arm64)
implementation(libs.skiko.linux.x64)
implementation(libs.skiko.windows.arm64)
implementation(libs.skiko.windows.x64)

testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
Expand Down
106 changes: 26 additions & 80 deletions maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,18 @@ import maestro.cli.App
import maestro.cli.CliError
import maestro.cli.DisableAnsiMixin
import maestro.cli.ShowHelpMixin
import maestro.cli.api.ApiClient
import maestro.cli.graphics.LocalVideoRenderer
import maestro.cli.graphics.RemoteVideoRenderer
import maestro.cli.graphics.SkiaFrameRenderer
import maestro.cli.report.TestDebugReporter
import maestro.cli.runner.TestRunner
import maestro.cli.runner.resultview.AnsiResultView
import maestro.cli.session.MaestroSessionManager
import maestro.cli.view.ProgressBar
import okio.sink
import org.fusesource.jansi.Ansi
import picocli.CommandLine
import picocli.CommandLine.Option
import java.io.File
import java.util.concurrent.Callable
import maestro.cli.device.Platform

@CommandLine.Command(
name = "record",
Expand All @@ -54,12 +53,18 @@ class RecordCommand : Callable<Int> {
@CommandLine.ParentCommand
private val parent: App? = null

@CommandLine.Parameters
@CommandLine.Parameters(index = "0", description = ["The Flow file to record."])
private lateinit var flowFile: File

@CommandLine.Parameters(description = ["Output file for the rendered video. Only valid for local rendering (--local)."], arity = "0..1", index = "1")
private var outputFile: File? = null

@Option(names = ["--config"], description = ["Optional .yaml configuration file for Flows. If not provided, Maestro will look for a config.yaml file in the root directory."])
private var configFile: File? = null

@Option(names = ["--local"], description = ["(Beta) Record using local rendering. This will become the default in a future Maestro release."])
private var local: Boolean = false

@Option(names = ["-e", "--env"])
private var env: Map<String, String> = emptyMap()

Expand All @@ -80,6 +85,13 @@ class RecordCommand : Callable<Int> {
)
}

if (!local && outputFile != null) {
throw CommandLine.ParameterException(
commandSpec.commandLine(),
"The outputFile parameter is only valid for local rendering (--local).",
)
}

if (configFile != null && configFile?.exists()?.not() == true) {
throw CliError("The config file ${configFile?.absolutePath} does not exist.")
}
Expand Down Expand Up @@ -111,87 +123,21 @@ class RecordCommand : Callable<Int> {
}
}

System.err.println()
System.err.println("@|bold Rendering your video. This usually takes a couple minutes...|@".render())
System.err.println()

val frames = resultView.getFrames()
val client = ApiClient("")

val uploadProgress = ProgressBar(50)
System.err.println("Uploading raw files for render...")
val id = client.render(screenRecording, frames) { totalBytes, bytesWritten ->
uploadProgress.set(bytesWritten.toFloat() / totalBytes)
}
System.err.println()

var renderProgress: ProgressBar? = null
var status: String? = null
var positionInQueue: Int? = null
while (true) {
val state = client.getRenderState(id)

// If new position or status, print header
if (state.status != status || state.positionInQueue != positionInQueue) {
status = state.status
positionInQueue = state.positionInQueue

if (renderProgress != null) {
renderProgress.set(1f)
System.err.println()
}

System.err.println()

System.err.println("Status : ${styledStatus(state.status)}")
if (state.positionInQueue != null) {
System.err.println("Position In Queue : ${state.positionInQueue}")
}
}

// Add ticks to progress bar
if (state.currentTaskProgress != null) {
if (renderProgress == null) renderProgress = ProgressBar(50)
renderProgress.set(state.currentTaskProgress)
}

// Print download url or error and return
if (state.downloadUrl != null || state.error != null) {
System.err.println()
if (state.downloadUrl != null) {
System.err.println("@|bold Signed Download URL:|@".render())
System.err.println()
print("@|cyan,bold ${state.downloadUrl}|@".render())
System.err.println()
System.err.println()
System.err.println("Open the link above to download your video. If you're sharing on Twitter be sure to tag us @|bold @mobile__dev|@!".render())
} else {
System.err.println("@|bold Render encountered during rendering:|@".render())
System.err.println(state.error)
}
break
}

Thread.sleep(2000)
}
val localOutputFile = outputFile ?: path.resolve("maestro-recording.mp4").toFile()
val videoRenderer = if (local) LocalVideoRenderer(
frameRenderer = SkiaFrameRenderer(),
outputFile = localOutputFile,
outputFPS = 25,
outputWidthPx = 1920,
outputHeightPx = 1080,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these the same specs for the video we used to record with remote renderer?

Copy link
Contributor Author

@Leland-Takamine Leland-Takamine Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote recorder was 30 fps but I think 25 if sufficient. Same resolution

) else RemoteVideoRenderer()
videoRenderer.render(screenRecording, frames)

TestDebugReporter.deleteOldFiles()

exitCode
}
}

private fun styledStatus(status: String): String {
val style = when (status) {
"PENDING" -> "yellow,bold"
"RENDERING" -> "blue,bold"
"SUCCESS" -> "green,bold"
else -> "bold"
}
return "@|$style $status|@".render()
}

private fun String.render(): String {
return Ansi.ansi().render(this).toString()
}
}
30 changes: 30 additions & 0 deletions maestro-cli/src/main/java/maestro/cli/graphics/AWTUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package maestro.cli.graphics

import org.jcodec.api.FrameGrab
import org.jcodec.api.awt.AWTSequenceEncoder
import org.jcodec.common.io.NIOUtils
import java.awt.Graphics2D
import java.io.File

fun Graphics2D.use(block: (g: Graphics2D) -> Unit) {
try {
block(this)
} finally {
dispose()
}
}

fun AWTSequenceEncoder.use(block: (encoder: AWTSequenceEncoder) -> Unit) {
try {
block(this)
} finally {
finish()
}
}

fun useFrameGrab(file: File, block: (grab: FrameGrab) -> Unit) {
NIOUtils.readableChannel(file).use { channelIn ->
val grab = FrameGrab.createFrameGrab(channelIn)
block(grab)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package maestro.cli.graphics

import maestro.cli.runner.resultview.AnsiResultView
import maestro.cli.view.ProgressBar
import maestro.cli.view.render
import okio.ByteString.Companion.decodeBase64
import org.jcodec.api.PictureWithMetadata
import org.jcodec.api.awt.AWTSequenceEncoder
import org.jcodec.common.io.NIOUtils
import org.jcodec.common.model.Rational
import org.jcodec.scale.AWTUtil
import java.awt.image.BufferedImage
import java.io.File

interface FrameRenderer {
fun render(
outputWidthPx: Int,
outputHeightPx: Int,
screen: BufferedImage,
text: String,
): BufferedImage
}

class LocalVideoRenderer(
private val frameRenderer: FrameRenderer,
private val outputFile: File,
private val outputFPS: Int,
private val outputWidthPx: Int,
private val outputHeightPx: Int,
) : VideoRenderer {

override fun render(
screenRecording: File,
textFrames: List<AnsiResultView.Frame>,
) {
System.err.println()
System.err.println("@|bold Rendering video - This may take some time...|@".render())
System.err.println()
System.err.println(outputFile.absolutePath)

val uploadProgress = ProgressBar(50)
NIOUtils.writableFileChannel(outputFile.absolutePath).use { out ->
AWTSequenceEncoder(out, Rational.R(outputFPS, 1)).use { encoder ->
useFrameGrab(screenRecording) { grab ->
val outputDurationSeconds = grab.videoTrack.meta.totalDuration
val outputFrameCount = (outputDurationSeconds * outputFPS).toInt()
var curFrame: PictureWithMetadata = grab.nativeFrameWithMetadata!!
var nextFrame: PictureWithMetadata? = grab.nativeFrameWithMetadata
(0..outputFrameCount).forEach { frameIndex ->
val currentTimestampSeconds = frameIndex.toDouble() / outputFPS

// !! Due to smart cast limitation: https://youtrack.jetbrains.com/issue/KT-7186
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
while (nextFrame != null && nextFrame!!.timestamp <= currentTimestampSeconds) {
curFrame = nextFrame!!
nextFrame = grab.nativeFrameWithMetadata
}
Comment on lines +52 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like this bug is fixed in Kotlin 2.0

(currently Maestro is at 1.8.22)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nice good to know!


val curImage = AWTUtil.toBufferedImage(curFrame.picture)
val curTextFrame = textFrames.lastOrNull { frame -> frame.timestamp.div(1000.0) <= currentTimestampSeconds } ?: textFrames.first()
val curText = curTextFrame.content.decodeBase64()!!.string(Charsets.UTF_8).stripAnsiCodes()
val outputImage = frameRenderer.render(outputWidthPx, outputHeightPx, curImage, curText)
encoder.encodeImage(outputImage)

uploadProgress.set(frameIndex / outputFrameCount.toFloat())
}
}
}
}
System.err.println()
System.err.println()
System.err.println("Rendering complete! If you're sharing on Twitter be sure to tag us \uD83D\uDE04 @|bold @mobile__dev|@".render())
}

private fun String.stripAnsiCodes(): String {
return replace("\\u001B\\[[;\\d]*[mH]".toRegex(), "")
}
}
Loading
Loading