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

Added option to save video on death #270

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ dependencies {
api("com.formdev", "flatlaf-extras", "3.0")
api("org.jgrapht", "jgrapht-core", "1.3.0")

//contains JNI libraries
//api("org.lz4:lz4-java:1.8.0")
api("org.lz4:lz4-pure-java:1.8.0")
api("org.jcodec:jcodec:0.2.5")

compileOnly("org.jetbrains", "annotations", "23.0.0")

testImplementation("org.junit.jupiter:junit-jupiter:5.9.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ public static class Other {
public @Option boolean DISABLE_MASTER_PASSWORD = false;
public @Option @Number(min = 10, max = 300) int ZONE_RESOLUTION = 30;
public @Option @Visibility(Level.DEVELOPER) @Number(min = 10, max = 250) int MIN_TICK = 15;
public @Option @Visibility(Level.ADVANCED) boolean RECORD_DEATH = false;
public @Option @Visibility(Level.ADVANCED) boolean DEV_STUFF = false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public void tick() {
}

if (!destroyed) {
main.getGui().onDeath();
shouldInstantRepair = true;
destroyed = true;
deaths++;
Expand Down
35 changes: 33 additions & 2 deletions src/main/java/com/github/manolo8/darkbot/gui/MainGui.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
import com.github.manolo8.darkbot.core.api.GameAPI;
import com.github.manolo8.darkbot.gui.components.ExitConfirmation;
import com.github.manolo8.darkbot.gui.titlebar.MainTitleBar;
import com.github.manolo8.darkbot.gui.utils.DeathRecorder;
import com.github.manolo8.darkbot.gui.utils.UIUtils;
import com.github.manolo8.darkbot.gui.utils.window.WindowUtils;
import eu.darkbot.api.config.ConfigSetting;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;
import java.awt.*;
import javax.swing.JFrame;
import javax.swing.ToolTipManager;
import java.awt.BorderLayout;
import java.awt.HeadlessException;
import java.awt.Image;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.WindowEvent;
Expand All @@ -33,6 +37,9 @@ public class MainGui extends JFrame {
public static final int DEFAULT_WIDTH = 640, DEFAULT_HEIGHT = 480;
private int lastTick;

private final Consumer<Boolean> deathRecorderListener;
private DeathRecorder deathRecorder;

public MainGui(Main main) throws HeadlessException {
super("DarkBot");
getRootPane().putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false);
Expand Down Expand Up @@ -79,6 +86,16 @@ public void componentMoved(ComponentEvent e) {
}
main.configManager.saveConfig();
}));

ConfigSetting<Boolean> recordDeath = main.configHandler.requireConfig("bot_settings.other.record_death");
recordDeath.addListener(deathRecorderListener = (value -> {
if (value && deathRecorder == null) deathRecorder = new DeathRecorder(this);
else if (!value && deathRecorder != null) deathRecorder = null;
}));

if (recordDeath.getValue()) {
this.deathRecorder = new DeathRecorder(this);
}
}

private void setComponentPosition() {
Expand Down Expand Up @@ -130,6 +147,20 @@ public void tick() {
if ((lastTick++ % main.config.BOT_SETTINGS.MAP_DISPLAY.REFRESH_DELAY) == 0) {
mapDrawer.repaint();
}

// prevent race condition
DeathRecorder recorder = deathRecorder;
if (recorder != null) {
recorder.onTick();
}
}

public void onDeath() {
// prevent race condition
DeathRecorder recorder = deathRecorder;
if (recorder != null) {
recorder.onDeath();
}
}

@Override
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/github/manolo8/darkbot/gui/MapDrawer.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ public void setup(Main main) {
}

protected void onPaint() {
drawBackgroundImage();
if (!isPaintingForPrint()) {
drawBackgroundImage();
}

for (Drawable drawable : drawableHandler.getDrawables()) {
drawable.onDraw(mapGraphics);
Expand Down
163 changes: 163 additions & 0 deletions src/main/java/com/github/manolo8/darkbot/gui/utils/DeathRecorder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.github.manolo8.darkbot.gui.utils;

import com.github.manolo8.darkbot.utils.LogUtils;
import eu.darkbot.util.Timer;
import net.jpountz.lz4.LZ4Compressor;
import net.jpountz.lz4.LZ4Exception;
import net.jpountz.lz4.LZ4Factory;
import net.jpountz.lz4.LZ4FastDecompressor;
import org.jcodec.api.SequenceEncoder;
import org.jcodec.common.model.ColorSpace;
import org.jcodec.common.model.Picture;

import javax.swing.JFrame;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

public class DeathRecorder {
public static final int FPS = 4;

private static final int WIDTH = 640;
private static final int HEIGHT = 480;
private static final int PICTURE_LENGTH = WIDTH * HEIGHT * 3;

private static final int MAX_FRAMES = 100;
private static final int MAX_COMPRESSION_LENGTH = PICTURE_LENGTH / 8; //115_200

private final byte[] bitmapBuffer = new byte[PICTURE_LENGTH];
private final byte[] compressionBuffer = new byte[MAX_COMPRESSION_LENGTH];

private final List<CompressedFrame> compressedFrames = new ArrayList<>(MAX_FRAMES);
private final BufferedImage imageCache = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);

private final Timer frameTimer = Timer.get(1000 / FPS);

private final JFrame mainGui;
private final LZ4Compressor compressor;
private final LZ4FastDecompressor decompressor;

private int currentFrame, validFrames;
private boolean saving;

public DeathRecorder(JFrame mainGui) {
this.mainGui = mainGui;

LZ4Factory factory = LZ4Factory.fastestInstance();
this.compressor = factory.fastCompressor();
this.decompressor = factory.fastDecompressor();
}

public void onTick() {
if (frameTimer.tryActivate()) {
saveFrame();
}
}

public void onDeath() {
synchronized (this) {
if (saving) return;
saving = true;
}

new Thread(() -> {
try {
saveVideo();
} catch (IOException e) {
e.printStackTrace();
}

validFrames = currentFrame = 0;
saving = false;
}).start();
}

private synchronized void saveFrame() {
if (saving) return;
Graphics2D g2 = (Graphics2D) imageCache.getGraphics();

// cut native border from FlatLaf - only on Windows?
double frameWidth = mainGui.getWidth() - 16;
double frameHeight = mainGui.getHeight() - 8;
Comment on lines +84 to +85
Copy link
Member

Choose a reason for hiding this comment

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

if you're manually trying to get rid of the border, does that mean you just want the map being rendered? can't you dump the graphics being written for that component instead of the whole frame?

I think it'd probably be useful, especially given we fully render that ourselves to a graphics 2d, and that we probably can straight up copy it to a buffer with some method in there

Copy link
Member Author

Choose a reason for hiding this comment

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

I want to record whole bot GUI
Rendering image this way, shows a weird white border around gui, probably it is casused by flatlaf's native border on Windows*
So 16 pixels less are there only to remove white border


g2.scale(WIDTH / frameWidth, HEIGHT / frameHeight);
g2.translate(-8, 0);
mainGui.print(g2);
//g2.dispose();

for (int offset = 0, h = 0; h < HEIGHT; h++) {
for (int w = 0; w < WIDTH; w++) {
int v = imageCache.getRGB(w, h);
bitmapBuffer[offset++] = (byte) (((v >>> 16) & 0xff) - 128);
bitmapBuffer[offset++] = (byte) (((v >>> 8) & 0xff) - 128);
bitmapBuffer[offset++] = (byte) (((v) & 0xff) - 128);
}
}
Comment on lines +92 to +99
Copy link
Member

Choose a reason for hiding this comment

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

are we totally certain there's no other way to extract a graphics2d's rendered output to a byte array if you don't do it yourself?

From what i could find, you can create a BufferedImage, then call component.paint(image.getGraphics()). It will paint that component to the image and you'll have your desired data in image -> raster -> data buffer. Additionally you could be doing this directly at render time and then just render the buffered image to the component for very little recording overhead, ie, in MapDrawer have something like:

@Override
public void paint(Graphics g) {
    BufferedImage img = new BufferedImage(width(), height(), BufferedImage.TYPE_INT_RGB);
    Graphics2D imgGraphics = img.getGraphics();
    
    // All the logic to draw, do it based on img. 
    // Optionally only do this whole render-to-image if you want the frame saved, 
    // otherwise rendering to g directly.
    doPaintStuff(imgGraphics);
    
    // Normally use the image as if it was the proper render
    g.drawImage(img, 0, 0, width, height);

    img.getRaster().getDataBuffer(); // here's your pixel data to save
}

If course you'll have to benchmark this, but i'm pretty sure this is going to be faster than manually iterating and calling a method to get rgb manually for each pixel in the image, especially if using any higher resolution.


CompressedFrame compressedImage;
if (compressedFrames.size() <= currentFrame) {
compressedFrames.add(compressedImage = new CompressedFrame());
Copy link
Member

Choose a reason for hiding this comment

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

instead of add & remove, consider simply using a circular buffer (or ring buffer), much more convenient.

Essentially you'll be writing to a different index each time and once you get to the end it starts re-writing the beginning, once you want to finally save it as video you can just iterate from head + 1 up till you loop arround to head (head being the current "index" you're writing to.

Copy link
Member Author

Choose a reason for hiding this comment

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

it is actually kinda a circular buffer, no remove method is called

} else {
compressedImage = compressedFrames.get(currentFrame);
}
compressedImage.compress();

if (++currentFrame >= MAX_FRAMES)
currentFrame = 0;

if (validFrames < MAX_FRAMES)
validFrames++;
}

private void saveVideo() throws IOException {
if (validFrames > 0) {
long time = System.currentTimeMillis();

File outputFile = new File("logs/" + LocalDateTime.now().format(LogUtils.FILENAME_DATE) + ".mov");
SequenceEncoder sequenceEncoder = SequenceEncoder.createSequenceEncoder(outputFile, FPS);

Picture picture = Picture.create(WIDTH, HEIGHT, ColorSpace.RGB);
for (int i = 0; i < validFrames; i++) {
int frame = (currentFrame + i) % validFrames;

CompressedFrame compressedFrame = compressedFrames.get(frame);
compressedFrame.decompressToPicture(picture);

sequenceEncoder.encodeNativeFrame(picture);
}
sequenceEncoder.finish();
System.out.println("Saved video in: " + (System.currentTimeMillis() - time) + "ms | " + validFrames);
}
}

private class CompressedFrame {
private byte[] compressed;
private int size;

private void compress() {
try {
size = compressor.compress(bitmapBuffer, compressionBuffer);
} catch (LZ4Exception e) {
size = 0;
return;
}

if (compressed == null || compressed.length < size) {
compressed = new byte[(int) Math.min(MAX_COMPRESSION_LENGTH, size * 1.1)];
}

System.arraycopy(compressionBuffer, 0, compressed, 0, size);
}

private void decompressToPicture(Picture picture) {
if (size == 0) return; // keep old data?

byte[] data = picture.getPlaneData(0);
decompressor.decompress(compressed, data, PICTURE_LENGTH);
}
}
}
4 changes: 3 additions & 1 deletion src/main/resources/lang/strings_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ config.general.safety.revive_location.list.spot=Spot
config.general.safety.wait_before_revive=Wait before revive (sec)
config.general.safety.wait_before_revive.desc=Seconds to wait before reviving
config.general.safety.wait_after_revive=Wait after revive (sec)
config.general.safety.wait_after_revive.des=Seconds to wait after reviving, lets ship repair
config.general.safety.wait_after_revive.desc=Seconds to wait after reviving, lets ship repair
config.general.safety.instant_repair=Use instant repairs when above
config.general.safety.instant_repair.desc=Use instant repairs to fully heal the ship after revive if there's at least these amount of instant repairs left
config.general.running=Running
Expand Down Expand Up @@ -306,6 +306,8 @@ config.bot_settings.other.zone_resolution.desc=Amount of map subdivisions when s
config.bot_settings.other.min_tick=Minimum tick time
config.bot_settings.other.dev_stuff=Developer stuff shown
config.bot_settings.other.dev_stuff.desc=Enabling this WILL make your bot use more cpu.
config.bot_settings.other.record_death=Record bot gui before death
config.bot_settings.other.record_death.desc=Record last ~25 seconds of bot gui before death, saved in logs folder

# Misc
misc.editor.checkbox_list.selected={0} selected
Expand Down