diff --git a/libs/monte-cc.jar b/libs/monte-cc.jar deleted file mode 100755 index c26f1c80..00000000 Binary files a/libs/monte-cc.jar and /dev/null differ diff --git a/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java b/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java index f0bef10a..09a5dc80 100755 --- a/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java +++ b/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java @@ -192,16 +192,18 @@ public class ReplayMod { final String path = System.getProperty("replaymod.render.path"); String type = System.getProperty("replaymod.render.type"); - String quality = System.getProperty("replaymod.render.quality"); + String bitrate = System.getProperty("replaymod.render.bitrate"); String fps = System.getProperty("replaymod.render.fps"); String waitForChunks = System.getProperty("replaymod.render.waitforchunks"); String linearMovement = System.getProperty("replaymod.render.linearmovement"); String skyColor = System.getProperty("replaymod.render.skycolor"); String width = System.getProperty("replaymod.render.width"); String height = System.getProperty("replaymod.render.height"); + String exportCommand = System.getProperty("replaymod.render.exportcommand"); + String exportCommandArgs = System.getProperty("replaymod.render.exportcommandargs"); final RenderOptions options = new RenderOptions(); - if (quality != null) { - options.setQuality(Float.parseFloat(quality)); + if (bitrate != null) { + options.setBitrate(bitrate); } if (fps != null) { options.setFps(Integer.parseInt(fps)); @@ -226,6 +228,13 @@ public class ReplayMod { options.setHeight(Integer.parseInt(height)); } + if (exportCommand != null) { + options.setExportCommand(exportCommand); + } + if (exportCommandArgs != null) { + options.setExportCommandArgs(exportCommandArgs); + } + FrameRenderer renderer; if (type != null) { String[] parts = type.split(":"); diff --git a/src/main/java/eu/crushedpixel/replaymod/gui/GuiRenderSettings.java b/src/main/java/eu/crushedpixel/replaymod/gui/GuiRenderSettings.java index bfcff6b9..0f7aa985 100644 --- a/src/main/java/eu/crushedpixel/replaymod/gui/GuiRenderSettings.java +++ b/src/main/java/eu/crushedpixel/replaymod/gui/GuiRenderSettings.java @@ -390,7 +390,7 @@ public class GuiRenderSettings extends GuiScreen { options.setFps(framerateSlider.getFPS()); ReplayMod.replaySettings.setVideoFramerate(framerateSlider.getFPS()); - options.setQuality(qualitySlider.getQuality()); + //TODO options.setQuality(qualitySlider.getQuality()); ReplayMod.replaySettings.setVideoQuality(qualitySlider.getQuality()); if(enableGreenscreen.isChecked()) { diff --git a/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java b/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java index 720c154f..d307f766 100644 --- a/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java +++ b/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java @@ -7,7 +7,7 @@ import static org.apache.commons.lang3.Validate.*; public final class RenderOptions { private FrameRenderer renderer; - private float quality = 0.5f; + private String bitrate = "10M"; private int fps = 30; // Advanced @@ -18,6 +18,10 @@ public final class RenderOptions { private int height = Minecraft.getMinecraft().displayHeight; // Highly advanced + private String exportCommand = "ffmpeg"; + private String exportCommandArgs = "-f rawvideo -pix_fmt argb -s %WIDTH%x%HEIGHT% -r %FPS% -i - " + + "-an " + + "-c:v libvpx -b:v %BITRATE% %FILENAME%.webm"; private int writerQueueSize = Integer.parseInt(System.getProperty("replaymod.render.writerQueueSize", "1")); public FrameRenderer getRenderer() { @@ -28,13 +32,12 @@ public final class RenderOptions { this.renderer = notNull(renderer); } - public float getQuality() { - return quality; + public String getBitrate() { + return bitrate; } - public void setQuality(float quality) { - inclusiveBetween(0f, 1f, quality); - this.quality = quality; + public void setBitrate(String bitrate) { + this.bitrate = bitrate; } public int getFps() { @@ -90,6 +93,22 @@ public final class RenderOptions { this.height = height; } + public String getExportCommand() { + return exportCommand; + } + + public void setExportCommand(String exportCommand) { + this.exportCommand = exportCommand; + } + + public String getExportCommandArgs() { + return exportCommandArgs; + } + + public void setExportCommandArgs(String exportCommandArgs) { + this.exportCommandArgs = exportCommandArgs; + } + public int getWriterQueueSize() { return writerQueueSize; } @@ -101,13 +120,15 @@ public final class RenderOptions { public RenderOptions copy() { RenderOptions copy = new RenderOptions(); copy.renderer = this.renderer; - copy.quality = this.quality; + copy.bitrate = this.bitrate; copy.fps = this.fps; copy.waitForChunks = this.waitForChunks; copy.isLinearMovement = this.isLinearMovement; copy.skyColor = this.skyColor; copy.width = this.width; copy.height = this.height; + copy.exportCommand = this.exportCommand; + copy.exportCommandArgs = this.exportCommandArgs; copy.writerQueueSize = this.writerQueueSize; return copy; } diff --git a/src/main/java/eu/crushedpixel/replaymod/utils/StreamPipe.java b/src/main/java/eu/crushedpixel/replaymod/utils/StreamPipe.java new file mode 100644 index 00000000..ab1f0910 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/utils/StreamPipe.java @@ -0,0 +1,28 @@ +package eu.crushedpixel.replaymod.utils; + +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class StreamPipe extends Thread { + private final InputStream in; + private final OutputStream out; + + public StreamPipe(InputStream in, OutputStream out) { + super("StreamPipe from " + in + " to " + out); + this.in = in; + this.out = out; + } + + @Override + public void run() { + try { + IOUtils.copy(in, out); + } catch (IOException ignored) { + // We don't care + // Note: Once we use this for something important, we should probably care! + } + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/utils/StringUtils.java b/src/main/java/eu/crushedpixel/replaymod/utils/StringUtils.java index 01bb7841..8c2718c8 100644 --- a/src/main/java/eu/crushedpixel/replaymod/utils/StringUtils.java +++ b/src/main/java/eu/crushedpixel/replaymod/utils/StringUtils.java @@ -3,7 +3,9 @@ package eu.crushedpixel.replaymod.utils; import net.minecraft.client.Minecraft; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.StringTokenizer; public class StringUtils { @@ -34,4 +36,81 @@ public class StringUtils { return rows.toArray(new String[rows.size()]); } + + /** + * Slightly modified from apache commons exec. Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Crack a command line. + * + * @param toProcess + * the command line to process + * @return the command line broken into strings. An empty or null toProcess + * parameter results in a zero sized array + */ + public static List translateCommandline(final String toProcess) { + if (toProcess == null || toProcess.length() == 0) { + // no command? no string + return Collections.emptyList(); + } + + // parse with a simple finite state machine + + final int normal = 0; + final int inQuote = 1; + final int inDoubleQuote = 2; + int state = normal; + final StringTokenizer tok = new StringTokenizer(toProcess, "\"\' ", true); + final ArrayList list = new ArrayList(); + StringBuilder current = new StringBuilder(); + boolean lastTokenHasBeenQuoted = false; + + while (tok.hasMoreTokens()) { + final String nextTok = tok.nextToken(); + switch (state) { + case inQuote: + if ("\'".equals(nextTok)) { + lastTokenHasBeenQuoted = true; + state = normal; + } else { + current.append(nextTok); + } + break; + case inDoubleQuote: + if ("\"".equals(nextTok)) { + lastTokenHasBeenQuoted = true; + state = normal; + } else { + current.append(nextTok); + } + break; + default: + if ("\'".equals(nextTok)) { + state = inQuote; + } else if ("\"".equals(nextTok)) { + state = inDoubleQuote; + } else if (" ".equals(nextTok)) { + if (lastTokenHasBeenQuoted || current.length() != 0) { + list.add(current.toString()); + current = new StringBuilder(); + } + } else { + current.append(nextTok); + } + lastTokenHasBeenQuoted = false; + break; + } + } + + if (lastTokenHasBeenQuoted || current.length() != 0) { + list.add(current.toString()); + } + + if (state == inQuote || state == inDoubleQuote) { + throw new IllegalArgumentException("Unbalanced quotes in " + + toProcess); + } + + return list; + } } diff --git a/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java index 7a5f63a0..29a14fba 100644 --- a/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java +++ b/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java @@ -55,7 +55,7 @@ public class VideoRenderer { public VideoRenderer(RenderOptions options) throws IOException { this.frameRenderer = options.getRenderer(); - this.videoWriter = new VideoWriter(options); + this.videoWriter = new VideoWriter(this, options); this.gui = new GuiVideoRenderer(this); this.replaySender = ReplayMod.replaySender; this.options = options; diff --git a/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java b/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java index 712f2322..6cf3b46c 100755 --- a/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java +++ b/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java @@ -3,33 +3,41 @@ package eu.crushedpixel.replaymod.video; import com.google.common.base.Preconditions; import eu.crushedpixel.replaymod.settings.RenderOptions; import eu.crushedpixel.replaymod.utils.ReplayFileIO; -import org.apache.commons.io.FileUtils; -import org.monte.media.*; -import org.monte.media.FormatKeys.MediaType; -import org.monte.media.math.Rational; +import eu.crushedpixel.replaymod.utils.StreamPipe; +import eu.crushedpixel.replaymod.utils.StringUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.crash.CrashReport; +import net.minecraft.crash.CrashReportCategory; +import org.apache.commons.io.IOUtils; import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferByte; +import java.awt.image.DataBufferInt; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.LinkedList; -import java.util.Queue; +import java.util.*; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import static org.apache.commons.lang3.Validate.isTrue; + public class VideoWriter { private static final String DATE_FORMAT = "yyyy_MM_dd_HH_mm_ss"; private static final SimpleDateFormat FILE_FORMAT = new SimpleDateFormat(DATE_FORMAT); - private static final String VIDEO_EXTENSION = ".avi"; - - private final File file; - private final MovieWriter out; - private final Buffer buf; - private final int track; + private final RenderOptions options; + private final Process process; + private final OutputStream outputStream; + private final WritableByteChannel channel; private volatile boolean active = true; private volatile boolean cancelled = false; @@ -43,68 +51,81 @@ public class VideoWriter { private final Condition noLongerEmptyCondition = lock.newCondition(); private final Condition noLongerFullCondition = lock.newCondition(); - public VideoWriter(RenderOptions options) throws IOException { + public VideoWriter(final VideoRenderer renderer, final RenderOptions options) throws IOException { + this.options = options.copy(); this.queueLimit = options.getWriterQueueSize(); this.toWrite = new LinkedList(); File folder = ReplayFileIO.getRenderFolder(); String fileName = FILE_FORMAT.format(Calendar.getInstance().getTime()); - file = new File(folder, fileName + VIDEO_EXTENSION); - FileUtils.touch(file); - out = Registry.getInstance().getWriter(file); - Format format = new Format(FormatKeys.MediaTypeKey, MediaType.VIDEO, - FormatKeys.EncodingKey, VideoFormatKeys.ENCODING_AVI_MJPG, - FormatKeys.FrameRateKey, new Rational(options.getFps(), 1), - VideoFormatKeys.WidthKey, options.getWidth(), - VideoFormatKeys.HeightKey, options.getHeight(), - VideoFormatKeys.DepthKey, 24, - VideoFormatKeys.QualityKey, options.getQuality()); + final String args = options.getExportCommandArgs() + .replace("%WIDTH%", String.valueOf(options.getWidth())) + .replace("%HEIGHT%", String.valueOf(options.getHeight())) + .replace("%FPS%", String.valueOf(options.getFps())) + .replace("%FILENAME%", fileName) + .replace("%BITRATE%", options.getBitrate()); - track = out.addTrack(format); - - buf = new Buffer(); - buf.format = new Format(VideoFormatKeys.DataClassKey, BufferedImage.class); - buf.sampleDuration = out.getFormat(track).get(VideoFormatKeys.FrameRateKey).inverse(); + List command = new ArrayList(); + command.add(options.getExportCommand()); + command.addAll(StringUtils.translateCommandline(args)); + System.out.println("Starting " + options.getExportCommand() + " with args: " + args); + process = new ProcessBuilder(command).directory(folder).start(); + OutputStream exportLogOut = new FileOutputStream("export.log"); + new StreamPipe(process.getInputStream(), exportLogOut).start(); + new StreamPipe(process.getErrorStream(), exportLogOut).start(); + outputStream = process.getOutputStream(); + channel = Channels.newChannel(outputStream); writerThread = new Thread(new Runnable() { @Override public void run() { - while(!cancelled && (active || !toWrite.isEmpty())) { - try { - lock.lockInterruptibly(); - BufferedImage img; - try { - img = toWrite.poll(); - if (img == null) { - noLongerEmptyCondition.await(); - img = toWrite.poll(); - } - noLongerFullCondition.signal(); - if (toWrite.isEmpty()) { - emptyCondition.signalAll(); - } - } finally { - lock.unlock(); - } - buf.data = img; - try { - out.write(track, buf); - } catch (IOException e) { - e.printStackTrace(); - } - } catch (InterruptedException ignored) { - } - } try { - toWrite.clear(); - out.close(); - if (cancelled) { - FileUtils.forceDelete(file); + while (!cancelled && (active || !toWrite.isEmpty())) { + try { + lock.lockInterruptibly(); + BufferedImage img; + try { + img = toWrite.poll(); + if (img == null) { + noLongerEmptyCondition.await(); + img = toWrite.poll(); + } + noLongerFullCondition.signal(); + if (toWrite.isEmpty()) { + emptyCondition.signalAll(); + } + } finally { + lock.unlock(); + } + + DataBuffer imgBuffer = img.getRaster().getDataBuffer(); + if (imgBuffer instanceof DataBufferByte) { + outputStream.write(((DataBufferByte) imgBuffer).getData()); + } else if (imgBuffer instanceof DataBufferInt) { + ByteBuffer byteBuffer = ByteBuffer.allocate(img.getWidth() * img.getHeight() * 4); + byteBuffer.asIntBuffer().put(((DataBufferInt) imgBuffer).getData()); + channel.write(byteBuffer); + } else { + throw new RuntimeException("DataBuffer type not supported: " + imgBuffer.getClass()); + } + } catch (InterruptedException ignored) { + } } + toWrite.clear(); + IOUtils.closeQuietly(outputStream); } catch (IOException e) { - e.printStackTrace(); + if (active) { + CrashReport report = new CrashReport("Exporting frame", e); + CrashReportCategory exportDetails = report.makeCategory("Export details"); + exportDetails.addCrashSection("Export command", options.getExportCommand()); + exportDetails.addCrashSection("Export args", args); + Minecraft.getMinecraft().crashed(report); + renderer.cancel(); + } + } finally { + process.destroy(); } } }, "replaymod-video-writer"); @@ -124,6 +145,8 @@ public class VideoWriter { */ public boolean writeImage(BufferedImage image, boolean waitIfFull) { Preconditions.checkState(active, "This VideoWriter has already been closed."); + isTrue(image.getWidth() == options.getWidth(), "Width has to be " + options.getWidth() + " but was " + image.getWidth()); + isTrue(image.getHeight() == options.getHeight(), "Height has to be " + options.getHeight() + " but was " + image.getHeight()); lock.lock(); try {