Skip to content

Commit

Permalink
Add option to render recording locally using "maestro record --local" (
Browse files Browse the repository at this point in the history
  • Loading branch information
Leland-Takamine authored Dec 10, 2024
1 parent cb5f53f commit fa9588d
Show file tree
Hide file tree
Showing 11 changed files with 496 additions and 81 deletions.
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,
) 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
}

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

0 comments on commit fa9588d

Please sign in to comment.