Skip to content

Commit 5888220

Browse files
dwursteisenclaude
andcommitted
Add record CLI command for headless GIF/PNG capture
- Add `tiny-cli record` command that runs a game headless and captures frames as GIF or PNG - Support --duration, --frames, --output, and --include-boot options - Add headless and maxFrames fields to GameOptions - Implement endGameLoop() in GlfwPlatform to signal window close - Add clearRecordingCache() to Platform for boot frame exclusion - Add synchronous recordSync/screenshotSync methods to GlfwPlatform - Auto-stop game loop in GameEngine when maxFrames is reached Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2523c54 commit 5888220

File tree

7 files changed

+187
-2
lines changed

7 files changed

+187
-2
lines changed

tiny-cli/src/main/kotlin/com/github/minigdx/tiny/cli/command/DocsCommand.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class DocsCommand : CliktCommand(name = "docs") {
5252
SfxCommand(),
5353
UpdateCommand(),
5454
ResourcesCommand(),
55+
RecordCommand(),
5556
)
5657

5758
// Generate documentation for each command

tiny-cli/src/main/kotlin/com/github/minigdx/tiny/cli/command/MainCommand.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class MainCommand : CliktCommand() {
1616
PaletteCommand(),
1717
SfxCommand(),
1818
UpdateCommand(),
19+
RecordCommand(),
1920
ResourcesCommand(),
2021
DocsCommand(),
2122
)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.github.minigdx.tiny.cli.command
2+
3+
import com.github.ajalt.clikt.core.Abort
4+
import com.github.ajalt.clikt.core.CliktCommand
5+
import com.github.ajalt.clikt.core.Context
6+
import com.github.ajalt.clikt.parameters.arguments.argument
7+
import com.github.ajalt.clikt.parameters.arguments.default
8+
import com.github.ajalt.clikt.parameters.options.default
9+
import com.github.ajalt.clikt.parameters.options.flag
10+
import com.github.ajalt.clikt.parameters.options.option
11+
import com.github.ajalt.clikt.parameters.types.file
12+
import com.github.ajalt.clikt.parameters.types.int
13+
import com.github.ajalt.clikt.parameters.types.long
14+
import com.github.minigdx.tiny.cli.config.GameParameters
15+
import com.github.minigdx.tiny.engine.GameEngine
16+
import com.github.minigdx.tiny.engine.GameEngineListener
17+
import com.github.minigdx.tiny.file.CommonVirtualFileSystem
18+
import com.github.minigdx.tiny.log.LogLevel
19+
import com.github.minigdx.tiny.log.StdOutLogger
20+
import com.github.minigdx.tiny.platform.glfw.GlfwPlatform
21+
import com.github.minigdx.tiny.resources.GameScript
22+
import java.io.File
23+
24+
class RecordCommand : CliktCommand(name = "record") {
25+
val gameDirectory by argument(help = "The directory containing your game to record.")
26+
.file(mustExist = true, canBeDir = true, canBeFile = false)
27+
.default(File("."))
28+
29+
val duration by option("--duration", "-d", help = "Duration in seconds (default: 5)")
30+
.int()
31+
.default(5)
32+
33+
val frames by option("--frames", "-f", help = "Number of frames to capture (overrides --duration)")
34+
.long()
35+
36+
val output by option("--output", "-o", help = "Output file path (extension determines format: .png for screenshot, .gif for animation)")
37+
38+
val headless by option("--headless", help = "Run without displaying the game window")
39+
.flag()
40+
41+
val includeBoot by option("--include-boot", help = "Include the boot animation in the recording")
42+
.flag()
43+
44+
override fun help(context: Context) = "Record your game as a GIF or PNG screenshot."
45+
46+
override fun run() {
47+
val configFile = gameDirectory.resolve("_tiny.json")
48+
if (!configFile.exists()) {
49+
echo("No _tiny.json found in ${gameDirectory.absolutePath}! Can't record the game without it.")
50+
throw Abort()
51+
}
52+
53+
val gameParameters = GameParameters.read(configFile)
54+
val maxFrames = frames ?: (duration * 60L)
55+
val outputFile = File(output ?: gameDirectory.resolve("recording.gif").path)
56+
val isScreenshot = outputFile.extension.lowercase() == "png"
57+
58+
val logger = StdOutLogger("tiny-cli", level = LogLevel.INFO)
59+
val homeDirectory = findHomeDirectory(gameParameters)
60+
val vfs = CommonVirtualFileSystem()
61+
62+
val baseOptions = gameParameters.toGameOptions()
63+
val recordSeconds = (maxFrames / 60f) + 1f
64+
val gameOption = baseOptions.copy(
65+
headless = headless,
66+
maxFrames = maxFrames,
67+
record = recordSeconds,
68+
)
69+
70+
val platform = GlfwPlatform(
71+
gameOption,
72+
logger,
73+
vfs,
74+
gameDirectory,
75+
homeDirectory,
76+
)
77+
78+
val gameEngine = GameEngine(
79+
gameOptions = gameOption,
80+
platform = platform,
81+
vfs = vfs,
82+
logger = logger,
83+
listener = object : GameEngineListener {
84+
override fun switchScript(
85+
before: GameScript?,
86+
after: GameScript?,
87+
) {
88+
if (!includeBoot) {
89+
platform.clearRecordingCache()
90+
}
91+
}
92+
93+
override fun reload(gameScript: GameScript?) = Unit
94+
},
95+
)
96+
97+
echo("Recording ${if (isScreenshot) "screenshot" else "GIF"} ($maxFrames frames)...")
98+
99+
gameEngine.main()
100+
101+
if (isScreenshot) {
102+
platform.screenshotSync(outputFile)
103+
} else {
104+
platform.recordSync(outputFile)
105+
}
106+
107+
echo("Saved to ${outputFile.absolutePath}")
108+
109+
// Force exit as the sound manager may keep background threads alive.
110+
@Suppress("ExitProcess")
111+
kotlin.system.exitProcess(0)
112+
}
113+
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/engine/GameEngine.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ class GameEngine(
115115
accumulator -= REFRESH_LIMIT
116116
currentFrame++
117117

118+
if (gameOptions.maxFrames > 0 && currentFrame >= gameOptions.maxFrames) {
119+
platform.endGameLoop()
120+
}
121+
118122
interceptUserShortcup()
119123
currentMetrics?.run { storeFrameMetrics(this) }
120124

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/engine/GameOptions.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ data class GameOptions(
2222
val bootScript: String? = null,
2323
val icon: String? = null,
2424
val fonts: List<FontDescriptor> = emptyList(),
25+
val headless: Boolean = false,
26+
// 0 = unlimited
27+
val maxFrames: Long = 0L,
2528
) : MouseProject {
2629
init {
2730
require(width > 0) { "The width needs to be a positive number." }

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/platform/Platform.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ interface Platform {
4747
*/
4848
fun screenshot() = Unit
4949

50+
/**
51+
* Clear the recording frame cache.
52+
*/
53+
fun clearRecordingCache() = Unit
54+
5055
/**
5156
* Write an image from a frame using index as colors
5257
*/

tiny-engine/src/jvmMain/kotlin/com/github/minigdx/tiny/platform/glfw/GlfwPlatform.kt

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ class GlfwPlatform(
160160
GLFW.glfwSwapInterval(1)
161161

162162
// Make the window visible
163-
GLFW.glfwShowWindow(window)
163+
if (!gameOptions.headless) {
164+
GLFW.glfwShowWindow(window)
165+
}
164166

165167
// Get the size of the device window
166168
val tmpWidth = MemoryUtil.memAllocInt(1)
@@ -218,7 +220,13 @@ class GlfwPlatform(
218220
GLFW.glfwTerminate()
219221
}
220222

221-
override fun endGameLoop() = Unit
223+
override fun endGameLoop() {
224+
GLFW.glfwSetWindowShouldClose(window, true)
225+
}
226+
227+
override fun clearRecordingCache() {
228+
gifFrameCache.clear()
229+
}
222230

223231
override fun newFrameRendered(virtualFrameBuffer: VirtualFrameBuffer) {
224232
virtualFrameBuffer.readFrameBuffer().copyInto(lastDraw)
@@ -283,6 +291,56 @@ class GlfwPlatform(
283291
}
284292
}
285293

294+
fun recordSync(outputFile: File) {
295+
logger.info("GLFW") { "Starting to generate GIF in '${outputFile.absolutePath}' (Wait for it...)" }
296+
val buffer = mutableListOf<ByteArray>().apply {
297+
addAll(gifFrameCache)
298+
}
299+
300+
val now = System.currentTimeMillis()
301+
val options = ImageOptions().apply {
302+
this.setDelay(20, TimeUnit.MILLISECONDS)
303+
}
304+
outputFile.outputStream().buffered().use { out ->
305+
val encoder = FastGifEncoder(
306+
out,
307+
gameOptions.width,
308+
gameOptions.height,
309+
0,
310+
gameOptions.colors(),
311+
)
312+
313+
buffer.forEach { frame ->
314+
encoder.addIndexedImage(frame, gameOptions.width, options)
315+
}
316+
encoder.finishEncoding()
317+
}
318+
logger.info("GLFW") { "Screen recorded in '${outputFile.absolutePath}' in ${System.currentTimeMillis() - now} ms" }
319+
}
320+
321+
fun screenshotSync(outputFile: File) {
322+
val buffer = lastDraw.pixels.copyOf()
323+
val width = gameOptions.width
324+
val height = gameOptions.height
325+
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
326+
327+
for (y in 0 until height) {
328+
for (x in 0 until width) {
329+
val colorData = gameOptions.colors().getRGBA(buffer[x + y * width].toInt())
330+
331+
val r = colorData[0].toInt() and 0xff
332+
val g = colorData[1].toInt() and 0xff
333+
val b = colorData[2].toInt() and 0xff
334+
val a = colorData[3].toInt() and 0xff
335+
val color = (a shl 24) or (r shl 16) or (g shl 8) or b
336+
image.setRGB(x, y, color)
337+
}
338+
}
339+
340+
ImageIO.write(image, "png", outputFile)
341+
logger.info("GLFW") { "Screenshot saved in '${outputFile.absolutePath}'" }
342+
}
343+
286344
override fun writeImage(buffer: ByteArray) {
287345
val origin = newFile("screenshoot", "png")
288346
val width = gameOptions.width

0 commit comments

Comments
 (0)