Skip to content

Instantly share code, notes, and snippets.

@MrPowerGamerBR
Created September 7, 2024 22:22
Show Gist options
  • Save MrPowerGamerBR/1d0e4665443b969a56723b925bb781f1 to your computer and use it in GitHub Desktop.
Save MrPowerGamerBR/1d0e4665443b969a56723b925bb781f1 to your computer and use it in GitHub Desktop.
package com.mrpowergamerbr.desksnapper
object DeskSnapperLauncher {
@JvmStatic
fun main(args: Array<String>) {
// Get the screen devices
val screenDevices = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices
val m = DeskSnapperScreenshots(screenDevices.toList())
m.startRecording()
}
}
package com.mrpowergamerbr.desksnapper
import com.mrpowergamerbr.desksnapper.ActiveWindowInfo.PsapiExtended
import com.mrpowergamerbr.desksnapper.DeskSnapper.Companion
import com.sun.jna.Native
import com.sun.jna.platform.win32.Kernel32
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef
import com.sun.jna.ptr.IntByReference
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import java.awt.*
import java.awt.image.BufferedImage
import java.awt.image.DataBufferByte
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.seconds
import kotlin.time.measureTime
class DeskSnapperScreenshots(val graphicsDevices: List<GraphicsDevice>) {
companion object {
private val logger = KotlinLogging.logger {}
private val DELAY = 5.seconds
val SCREEN_WIDTH = 2560
val SCREEN_HEIGHT = 1440
// Save it in half of the screen resolution
// Yes, the resolution isn't that great, but tbh who cares, it is not like you are looking at the saved screenshots constantly
val TARGET_SCREEN_WIDTH = SCREEN_WIDTH / 2
val TARGET_SCREEN_HEIGHT = SCREEN_HEIGHT / 2
// We set a target file size to avoid things like games and videos storing way too much data
val TARGET_FILE_SIZE = 100 * 1024 // 100kb
}
private val sessionId = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").format(LocalDateTime.now(ZoneId.of("America/Sao_Paulo")))
val formatterDate = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val formatterTime = DateTimeFormatter.ofPattern("HH-mm-ss")
val recordingsFolder = File("E:\\DeskSnapper\\Recordings\\")
val sessionDataFolder = File("E:\\DeskSnapper\\Recordings\\SessionData")
private val snappers = graphicsDevices.mapIndexed { index, graphicsDevice ->
MonitorSnapperAlt(graphicsDevice, index, sessionId, recordingsFolder, sessionDataFolder)
}
fun startRecording() {
sessionDataFolder.mkdirs()
// We use screenshots instead of using ffmpeg because sometimes we need to quick lookup something that happened a few minutes ago, and with ffmpeg we can't do that
// We'll save the frames and then convert them to ffmpeg at the end of the day
// It may be tempting to switch from JPEG XL to a video format, but JPEG XL is SUPER good even when it is multiple frames like this one
// I tried libvpx-vp9 and libx264rgb but JPEG XL was still better of them in disk space (wow)
// They are probably better if we are watching a video or something like that, but for screenshots it isn't that good
// Technically video format IS BETTER than these screenshots, but ffmpeg is sooo finicky when you want to "playback" during rendering, that it kinda makes it a bit cumbersome to use
var currentFrames = 0
Runtime.getRuntime().addShutdownHook(
Thread {
snappers.forEach {
it.writeFrameData(true)
}
}
)
thread {
while (true) {
// "Garbage collector" thread (converts frames into videos)
logger.info { "Running garbage collector..." }
File("E:\\DeskSnapper\\Recordings\\SessionData\\")
.listFiles()
.forEach {
if (it.extension == "txt") {
val content = it.readLines(Charsets.UTF_8)
if (content.last().startsWith("# Finished at")) {
// Session has finished! Let's convert it into a video...
logger.info { "Converting session $it into a video..." }
val outputFile = File(recordingsFolder, it.nameWithoutExtension + ".mp4").absolutePath
val processBuilder = ProcessBuilder(
"D:\\Tools\\ffmpeg\\ffmpeg.exe",
"-f",
"concat",
"-safe",
"0",
"-i",
it.absolutePath,
"-c:v",
"libx264",
// We don't want to speed up the video for now...
// "-filter:v",
// "\"setpts=PTS/5\"",
// "-vsync",
// "vfr",
"-crf",
"25",
"-preset",
"veryslow",
"-pix_fmt",
"yuv420p",
"-y",
// Due to the way MP4 containers work (it goes back after writing all data!), we need to write directly to a file
outputFile
).redirectErrorStream(true)
.start()
thread {
while (true) {
val r = processBuilder.inputStream.read()
if (r == -1) // Keep reading until end of input
return@thread
print(r.toChar())
}
}
Runtime.getRuntime().addShutdownHook(
Thread {
processBuilder.destroy()
}
)
processBuilder.waitFor()
logger.info { "Finished rendering $it! Original frame files can now be discarded if needed" }
it.appendText("# Rendered at ${LocalDateTime.now()} to $outputFile")
}
}
}
Thread.sleep(30_000)
}
}
while (true) {
logger.info { "[FRAME $currentFrames] Rendering frame $currentFrames" }
val howMuchTimeItTook = measureTime {
val currentDateTime = LocalDateTime.now()
val activeProcess = getActiveProcessTitleAndName()
val jobs = snappers.map {
GlobalScope.async(Dispatchers.IO) {
it.snapScreenshot(
currentFrames,
"${currentDateTime.format(formatterDate)}_${currentDateTime.format(formatterTime)}",
activeProcess
)
}
}
runBlocking { jobs.awaitAll() }
}
val howMuchTimeToWait = (DELAY - howMuchTimeItTook).inWholeMilliseconds
logger.info { "[FRAME $currentFrames] Took ${howMuchTimeItTook} to take a screenshot! Waiting ${howMuchTimeToWait}ms..." }
if (howMuchTimeToWait > 0)
Thread.sleep(howMuchTimeToWait)
currentFrames++
}
}
fun getActiveProcessTitleAndName(): ActiveProcess {
// Get the handle of the current active window
val hwnd = User32.INSTANCE.GetForegroundWindow()
// Get the window title
val windowText = CharArray(512)
User32.INSTANCE.GetWindowText(hwnd, windowText, 512)
val wText = Native.toString(windowText)
// Get the process ID
val pid = IntByReference()
User32.INSTANCE.GetWindowThreadProcessId(hwnd, pid)
// Open the process to get the executable name
val process = Kernel32.INSTANCE.OpenProcess(
Kernel32.PROCESS_QUERY_INFORMATION or Kernel32.PROCESS_VM_READ,
false,
pid.value
)
// Get the executable name
val exeName = CharArray(512)
PsapiExtended.INSTANCE.GetModuleBaseNameW(process, null, exeName, 512)
val processName = Native.toString(exeName)
// Close the process handle
Kernel32.INSTANCE.CloseHandle(process)
return ActiveProcess(
wText,
processName
)
}
}
package com.mrpowergamerbr.desksnapper
import kotlinx.serialization.Serializable
@Serializable
data class FrameInformation(
val absoluteTimeInSeconds: Int,
val timestamp: Long,
val activeProcess: ActiveProcess?
)
package com.mrpowergamerbr.desksnapper
import com.mrpowergamerbr.desksnapper.DeskSnapperScreenshots.Companion.SCREEN_HEIGHT
import com.mrpowergamerbr.desksnapper.DeskSnapperScreenshots.Companion.SCREEN_WIDTH
import com.mrpowergamerbr.desksnapper.DeskSnapperScreenshots.Companion.TARGET_SCREEN_HEIGHT
import com.mrpowergamerbr.desksnapper.DeskSnapperScreenshots.Companion.TARGET_SCREEN_WIDTH
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.awt.GraphicsDevice
import java.awt.Image
import java.awt.Rectangle
import java.awt.Robot
import java.awt.image.BufferedImage
import java.awt.image.ConvolveOp
import java.awt.image.Kernel
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.LocalDateTime
import javax.imageio.ImageIO
import kotlin.time.measureTime
class MonitorSnapperAlt(
private val graphicsDevice: GraphicsDevice,
private val graphicsDeviceIndex: Int,
private val sessionId: String,
private val recordingsFolder: File,
private val sessionsDataFolder: File
) {
companion object {
private val logger = KotlinLogging.logger {}
}
private val robot = Robot()
private var lastScreenshot: BufferedImage? = null
private var lastScreenshotFileName: String? = null
private var lastScreenshotShotAtSeconds = 0
private var lastActiveProcess: ActiveProcess? = null
private var holdingLastScreenshotsForSeconds = 0
private var tickedSeconds = 0
private val sessionFile = File(sessionsDataFolder, "session-$sessionId.$graphicsDeviceIndex.txt")
fun snapScreenshot(
currentFrames: Int,
outputFileName: String,
activeProcess: ActiveProcess
) {
val howMuchTimeItTook = measureTime {
val screenSize = graphicsDevice.defaultConfiguration.bounds
val screenCapture = robot.createMultiResolutionScreenCapture(Rectangle(screenSize.x, screenSize.y, screenSize.width, screenSize.height))
val capture = toBufferedImage(
screenCapture.getResolutionVariant(SCREEN_WIDTH.toDouble(), SCREEN_HEIGHT.toDouble()).getScaledInstance(TARGET_SCREEN_WIDTH, TARGET_SCREEN_HEIGHT, BufferedImage.SCALE_SMOOTH),
BufferedImage.TYPE_3BYTE_BGR
)
val lastScreenshot = this.lastScreenshot
if (lastScreenshot != null) {
// Check if the message is similar or not
var similarPixels = 0
for (x in 0 until capture.width) {
for (y in 0 until capture.height) {
val imageRGB = capture.getRGB(x, y)
val lastScreenshotRGB = lastScreenshot.getRGB(x, y)
if (imageRGB == lastScreenshotRGB)
similarPixels++
}
}
val percentage = (similarPixels / (capture.width * capture.height).toDouble()) * 100
logger.info { "[FRAME $currentFrames; GRAPHICS $graphicsDeviceIndex] Pixels percentage: ${percentage}%" }
// Mostly to avoid a bunch of duplicate screenshots, most of the times nothing ever changes on the screen anyway
if (percentage >= 99.5) {
logger.info { "[FRAME $currentFrames; GRAPHICS $graphicsDeviceIndex] Ignoring screenshot because it is the same as the previous screenshot" }
holdingLastScreenshotsForSeconds += 5
tickedSeconds += 5
return
}
}
writeFrameData(false)
holdingLastScreenshotsForSeconds = 5
this.lastScreenshot = capture
this.lastActiveProcess = activeProcess
this.lastScreenshotShotAtSeconds = tickedSeconds
// We sharpen the capture a bit because the image gets a bit fuzzy when downscaling
val captureToBeSaved = sharpenImage(toBufferedImage(capture.getScaledInstance(DeskSnapperScreenshots.TARGET_SCREEN_WIDTH, DeskSnapperScreenshots.TARGET_SCREEN_HEIGHT, BufferedImage.SCALE_SMOOTH), BufferedImage.TYPE_3BYTE_BGR), 0f)
// Convert from bmp to webp with ImageMagick
val baos = ByteArrayOutputStream()
ImageIO.write(captureToBeSaved, "bmp", baos)
baos.close()
val imageAsBmp = baos.toByteArray()
// ImageIO.write(capture, "png", File(folderFile, currentDateTime.format(formatterTime) + ".png"))
val currentQuality = 100
val currentEffort = 7
var output: ByteArray? = null
val outputFolder = File("E:\\DeskSnapper\\Recordings\\$sessionId.$graphicsDeviceIndex")
outputFolder.mkdir()
val outputFile = File(outputFolder, "$outputFileName.jxl")
val processBuilder = ProcessBuilder(
"E:\\DeskSnapper\\ImageMagick\\magick.exe",
"-",
// We'll always write with full quality (lossless)
"-quality",
"100",
"-define",
// Increasing the effort makes it VERY smol, but it takes a whiiile to convert
// So we'll keep it at 7 for now, but anything higher than 8 is too slow (takes longer to optimize than it takes between the configured DELAY)
"jxl:effort=$currentEffort",
"jxl:-"
).redirectErrorStream(true)
.start()
processBuilder.outputStream.write(imageAsBmp)
processBuilder.outputStream.close()
output = processBuilder.inputStream.readAllBytes()
processBuilder.waitFor()
logger.info { "[FRAME $currentFrames; GRAPHICS $graphicsDeviceIndex] Output file size: ${output.size} bytes" }
if (output != null) {
logger.info { "[FRAME $currentFrames; GRAPHICS $graphicsDeviceIndex] Writing image to disk... File size: ${output.size} bytes; Quality: $currentQuality" }
outputFile.writeBytes(output)
lastScreenshotFileName = outputFile.absolutePath
}
}
logger.info { "[FRAME $currentFrames; GRAPHICS $graphicsDeviceIndex] Took ${howMuchTimeItTook} to write the screenshot to the disk! Current seconds: $tickedSeconds" }
tickedSeconds += 5
}
fun writeFrameData(isFinished: Boolean) {
if (lastScreenshotFileName != null) {
val frameInformation = FrameInformation(
lastScreenshotShotAtSeconds,
System.currentTimeMillis(),
lastActiveProcess
)
val textToAppend = buildString {
appendLine("# FrameInfo: ${Json.encodeToString(frameInformation)}")
appendLine("file '${lastScreenshotFileName}'")
appendLine("duration $holdingLastScreenshotsForSeconds")
if (isFinished) {
val now = LocalDateTime.now()
appendLine("# Finished at $now")
}
}
sessionFile.appendText(textToAppend)
}
}
/**
* Converts a given Image into a BufferedImage
*
* @param img The Image to be converted
* @return The converted BufferedImage
*/
fun toBufferedImage(img: Image, targetImageType: Int): BufferedImage {
if (img is BufferedImage && img.type == targetImageType) {
return img
}
// Create a buffered image with transparency
val bimage = BufferedImage(img.getWidth(null), img.getHeight(null), targetImageType)
// Draw the image on to the buffered image
val bGr = bimage.createGraphics()
bGr.drawImage(img, 0, 0, null)
bGr.dispose()
// Return the buffered image
return bimage
}
fun sharpenImage(image: BufferedImage, intensity: Float): BufferedImage {
// Define a sharpening kernel with adjustable intensity
val sharpenKernel = floatArrayOf(
0.0f, -intensity, 0.0f,
-intensity, 1.0f + 4 * intensity, -intensity,
0.0f, -intensity, 0.0f
)
// Create a Kernel object
val kernel = Kernel(3, 3, sharpenKernel)
// Create a ConvolveOp object
val convolveOp = ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null)
// Apply the convolution operation on the image
val outputImage = BufferedImage(image.width, image.height, image.type)
convolveOp.filter(image, outputImage)
return outputImage
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment