-
-
Save MrPowerGamerBR/1d0e4665443b969a56723b925bb781f1 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mrpowergamerbr.desksnapper | |
import kotlinx.serialization.Serializable | |
@Serializable | |
data class FrameInformation( | |
val absoluteTimeInSeconds: Int, | |
val timestamp: Long, | |
val activeProcess: ActiveProcess? | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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