Use ffmpeg for video exporting (default webm)

Remove monte media lib
This commit is contained in:
johni0702
2015-06-27 10:57:08 +02:00
parent 36c38ddfd3
commit f19fc0ab36
8 changed files with 232 additions and 72 deletions

Binary file not shown.

View File

@@ -192,16 +192,18 @@ public class ReplayMod {
final String path = System.getProperty("replaymod.render.path"); final String path = System.getProperty("replaymod.render.path");
String type = System.getProperty("replaymod.render.type"); 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 fps = System.getProperty("replaymod.render.fps");
String waitForChunks = System.getProperty("replaymod.render.waitforchunks"); String waitForChunks = System.getProperty("replaymod.render.waitforchunks");
String linearMovement = System.getProperty("replaymod.render.linearmovement"); String linearMovement = System.getProperty("replaymod.render.linearmovement");
String skyColor = System.getProperty("replaymod.render.skycolor"); String skyColor = System.getProperty("replaymod.render.skycolor");
String width = System.getProperty("replaymod.render.width"); String width = System.getProperty("replaymod.render.width");
String height = System.getProperty("replaymod.render.height"); 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(); final RenderOptions options = new RenderOptions();
if (quality != null) { if (bitrate != null) {
options.setQuality(Float.parseFloat(quality)); options.setBitrate(bitrate);
} }
if (fps != null) { if (fps != null) {
options.setFps(Integer.parseInt(fps)); options.setFps(Integer.parseInt(fps));
@@ -226,6 +228,13 @@ public class ReplayMod {
options.setHeight(Integer.parseInt(height)); options.setHeight(Integer.parseInt(height));
} }
if (exportCommand != null) {
options.setExportCommand(exportCommand);
}
if (exportCommandArgs != null) {
options.setExportCommandArgs(exportCommandArgs);
}
FrameRenderer renderer; FrameRenderer renderer;
if (type != null) { if (type != null) {
String[] parts = type.split(":"); String[] parts = type.split(":");

View File

@@ -390,7 +390,7 @@ public class GuiRenderSettings extends GuiScreen {
options.setFps(framerateSlider.getFPS()); options.setFps(framerateSlider.getFPS());
ReplayMod.replaySettings.setVideoFramerate(framerateSlider.getFPS()); ReplayMod.replaySettings.setVideoFramerate(framerateSlider.getFPS());
options.setQuality(qualitySlider.getQuality()); //TODO options.setQuality(qualitySlider.getQuality());
ReplayMod.replaySettings.setVideoQuality(qualitySlider.getQuality()); ReplayMod.replaySettings.setVideoQuality(qualitySlider.getQuality());
if(enableGreenscreen.isChecked()) { if(enableGreenscreen.isChecked()) {

View File

@@ -7,7 +7,7 @@ import static org.apache.commons.lang3.Validate.*;
public final class RenderOptions { public final class RenderOptions {
private FrameRenderer renderer; private FrameRenderer renderer;
private float quality = 0.5f; private String bitrate = "10M";
private int fps = 30; private int fps = 30;
// Advanced // Advanced
@@ -18,6 +18,10 @@ public final class RenderOptions {
private int height = Minecraft.getMinecraft().displayHeight; private int height = Minecraft.getMinecraft().displayHeight;
// Highly advanced // 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")); private int writerQueueSize = Integer.parseInt(System.getProperty("replaymod.render.writerQueueSize", "1"));
public FrameRenderer getRenderer() { public FrameRenderer getRenderer() {
@@ -28,13 +32,12 @@ public final class RenderOptions {
this.renderer = notNull(renderer); this.renderer = notNull(renderer);
} }
public float getQuality() { public String getBitrate() {
return quality; return bitrate;
} }
public void setQuality(float quality) { public void setBitrate(String bitrate) {
inclusiveBetween(0f, 1f, quality); this.bitrate = bitrate;
this.quality = quality;
} }
public int getFps() { public int getFps() {
@@ -90,6 +93,22 @@ public final class RenderOptions {
this.height = height; 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() { public int getWriterQueueSize() {
return writerQueueSize; return writerQueueSize;
} }
@@ -101,13 +120,15 @@ public final class RenderOptions {
public RenderOptions copy() { public RenderOptions copy() {
RenderOptions copy = new RenderOptions(); RenderOptions copy = new RenderOptions();
copy.renderer = this.renderer; copy.renderer = this.renderer;
copy.quality = this.quality; copy.bitrate = this.bitrate;
copy.fps = this.fps; copy.fps = this.fps;
copy.waitForChunks = this.waitForChunks; copy.waitForChunks = this.waitForChunks;
copy.isLinearMovement = this.isLinearMovement; copy.isLinearMovement = this.isLinearMovement;
copy.skyColor = this.skyColor; copy.skyColor = this.skyColor;
copy.width = this.width; copy.width = this.width;
copy.height = this.height; copy.height = this.height;
copy.exportCommand = this.exportCommand;
copy.exportCommandArgs = this.exportCommandArgs;
copy.writerQueueSize = this.writerQueueSize; copy.writerQueueSize = this.writerQueueSize;
return copy; return copy;
} }

View File

@@ -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!
}
}
}

View File

@@ -3,7 +3,9 @@ package eu.crushedpixel.replaymod.utils;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.StringTokenizer;
public class StringUtils { public class StringUtils {
@@ -34,4 +36,81 @@ public class StringUtils {
return rows.toArray(new String[rows.size()]); 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<String> 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<String> list = new ArrayList<String>();
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;
}
} }

View File

@@ -55,7 +55,7 @@ public class VideoRenderer {
public VideoRenderer(RenderOptions options) throws IOException { public VideoRenderer(RenderOptions options) throws IOException {
this.frameRenderer = options.getRenderer(); this.frameRenderer = options.getRenderer();
this.videoWriter = new VideoWriter(options); this.videoWriter = new VideoWriter(this, options);
this.gui = new GuiVideoRenderer(this); this.gui = new GuiVideoRenderer(this);
this.replaySender = ReplayMod.replaySender; this.replaySender = ReplayMod.replaySender;
this.options = options; this.options = options;

View File

@@ -3,33 +3,41 @@ package eu.crushedpixel.replaymod.video;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import eu.crushedpixel.replaymod.settings.RenderOptions; import eu.crushedpixel.replaymod.settings.RenderOptions;
import eu.crushedpixel.replaymod.utils.ReplayFileIO; import eu.crushedpixel.replaymod.utils.ReplayFileIO;
import org.apache.commons.io.FileUtils; import eu.crushedpixel.replaymod.utils.StreamPipe;
import org.monte.media.*; import eu.crushedpixel.replaymod.utils.StringUtils;
import org.monte.media.FormatKeys.MediaType; import net.minecraft.client.Minecraft;
import org.monte.media.math.Rational; 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.BufferedImage;
import java.awt.image.DataBuffer;
import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException; 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.text.SimpleDateFormat;
import java.util.Calendar; import java.util.*;
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import static org.apache.commons.lang3.Validate.isTrue;
public class VideoWriter { public class VideoWriter {
private static final String DATE_FORMAT = "yyyy_MM_dd_HH_mm_ss"; 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 SimpleDateFormat FILE_FORMAT = new SimpleDateFormat(DATE_FORMAT);
private static final String VIDEO_EXTENSION = ".avi"; private final RenderOptions options;
private final Process process;
private final File file; private final OutputStream outputStream;
private final MovieWriter out; private final WritableByteChannel channel;
private final Buffer buf;
private final int track;
private volatile boolean active = true; private volatile boolean active = true;
private volatile boolean cancelled = false; private volatile boolean cancelled = false;
@@ -43,68 +51,81 @@ public class VideoWriter {
private final Condition noLongerEmptyCondition = lock.newCondition(); private final Condition noLongerEmptyCondition = lock.newCondition();
private final Condition noLongerFullCondition = 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.queueLimit = options.getWriterQueueSize();
this.toWrite = new LinkedList<BufferedImage>(); this.toWrite = new LinkedList<BufferedImage>();
File folder = ReplayFileIO.getRenderFolder(); File folder = ReplayFileIO.getRenderFolder();
String fileName = FILE_FORMAT.format(Calendar.getInstance().getTime()); String fileName = FILE_FORMAT.format(Calendar.getInstance().getTime());
file = new File(folder, fileName + VIDEO_EXTENSION);
FileUtils.touch(file);
out = Registry.getInstance().getWriter(file); final String args = options.getExportCommandArgs()
Format format = new Format(FormatKeys.MediaTypeKey, MediaType.VIDEO, .replace("%WIDTH%", String.valueOf(options.getWidth()))
FormatKeys.EncodingKey, VideoFormatKeys.ENCODING_AVI_MJPG, .replace("%HEIGHT%", String.valueOf(options.getHeight()))
FormatKeys.FrameRateKey, new Rational(options.getFps(), 1), .replace("%FPS%", String.valueOf(options.getFps()))
VideoFormatKeys.WidthKey, options.getWidth(), .replace("%FILENAME%", fileName)
VideoFormatKeys.HeightKey, options.getHeight(), .replace("%BITRATE%", options.getBitrate());
VideoFormatKeys.DepthKey, 24,
VideoFormatKeys.QualityKey, options.getQuality());
track = out.addTrack(format); List<String> command = new ArrayList<String>();
command.add(options.getExportCommand());
buf = new Buffer(); command.addAll(StringUtils.translateCommandline(args));
buf.format = new Format(VideoFormatKeys.DataClassKey, BufferedImage.class); System.out.println("Starting " + options.getExportCommand() + " with args: " + args);
buf.sampleDuration = out.getFormat(track).get(VideoFormatKeys.FrameRateKey).inverse(); 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() { writerThread = new Thread(new Runnable() {
@Override @Override
public void run() { 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 { try {
toWrite.clear(); while (!cancelled && (active || !toWrite.isEmpty())) {
out.close(); try {
if (cancelled) { lock.lockInterruptibly();
FileUtils.forceDelete(file); 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) { } 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"); }, "replaymod-video-writer");
@@ -124,6 +145,8 @@ public class VideoWriter {
*/ */
public boolean writeImage(BufferedImage image, boolean waitIfFull) { public boolean writeImage(BufferedImage image, boolean waitIfFull) {
Preconditions.checkState(active, "This VideoWriter has already been closed."); 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(); lock.lock();
try { try {