package com.replaymod.render; import com.replaymod.render.frame.RGBFrame; import com.replaymod.render.rendering.FrameConsumer; import com.replaymod.render.utils.ByteBufferPool; import com.replaymod.render.utils.StreamPipe; import net.minecraft.client.Minecraft; import net.minecraft.crash.CrashReport; import net.minecraft.crash.CrashReportCategory; import net.minecraft.util.Util; import net.minecraftforge.fml.common.versioning.ComparableVersion; import org.apache.commons.exec.CommandLine; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.TeeOutputStream; import org.lwjgl.util.ReadableDimension; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.channels.Channels; import java.nio.channels.WritableByteChannel; import java.util.Arrays; import java.util.Comparator; import java.util.Optional; import java.util.concurrent.TimeUnit; import static com.replaymod.render.ReplayModRender.LOGGER; import static org.apache.commons.lang3.Validate.isTrue; public class VideoWriter implements FrameConsumer { private final RenderSettings settings; private final Process process; private final OutputStream outputStream; private final WritableByteChannel channel; private final String commandArgs; private volatile boolean aborted; private ByteArrayOutputStream ffmpegLog = new ByteArrayOutputStream(4096); public VideoWriter(final RenderSettings settings) throws IOException { this.settings = settings; File outputFolder = settings.getOutputFile().getParentFile(); FileUtils.forceMkdir(outputFolder); String fileName = settings.getOutputFile().getName(); commandArgs = settings.getExportArguments() .replace("%WIDTH%", String.valueOf(settings.getVideoWidth())) .replace("%HEIGHT%", String.valueOf(settings.getVideoHeight())) .replace("%FPS%", String.valueOf(settings.getFramesPerSecond())) .replace("%FILENAME%", fileName) .replace("%BITRATE%", String.valueOf(settings.getBitRate())) .replace("%FILTERS%", settings.getVideoFilters()); String executable = settings.getExportCommand().isEmpty() ? findFFmpeg() : settings.getExportCommand(); System.out.println("Starting " + executable + " with args: " + commandArgs); String[] cmdline = new CommandLine(executable).addArguments(commandArgs).toStrings(); process = new ProcessBuilder(cmdline).directory(outputFolder).start(); File exportLogFile = new File(Minecraft.getMinecraft().mcDataDir, "export.log"); OutputStream exportLogOut = new TeeOutputStream(new FileOutputStream(exportLogFile), ffmpegLog); new StreamPipe(process.getInputStream(), exportLogOut).start(); new StreamPipe(process.getErrorStream(), exportLogOut).start(); outputStream = process.getOutputStream(); channel = Channels.newChannel(outputStream); } private String findFFmpeg() { switch (Util.getOSType()) { case WINDOWS: // Allow windows users to unpack the ffmpeg archive into a sub-folder of their .minecraft folder File inDotMinecraft = new File(Minecraft.getMinecraft().mcDataDir, "ffmpeg/bin/ffmpeg.exe"); if (inDotMinecraft.exists()) { LOGGER.debug("FFmpeg found in .minecraft/ffmpeg"); return inDotMinecraft.getAbsolutePath(); } break; case OSX: // The PATH doesn't seem to be set as expected on OSX, therefore we check some common locations ourselves for (String path : new String[]{"/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"}) { File file = new File(path); if (file.exists()) { LOGGER.debug("Found FFmpeg at {}", path); return path; } else { LOGGER.debug("FFmpeg not located at {}", path); } } // Homebrew doesn't seem to reliably symlink its installed binaries either File homebrewFolder = new File("/usr/local/Cellar/ffmpeg"); String[] homebrewVersions = homebrewFolder.list(); if (homebrewVersions != null) { Optional latestOpt = Arrays.stream(homebrewVersions) .map(ComparableVersion::new) // Convert file name to comparable version .sorted(Comparator.reverseOrder()) // Sort for latest version .map(ComparableVersion::toString) // Convert back to file name .map(v -> new File(new File(homebrewFolder, v), "bin/ffmpeg")) // Convert to binary files .filter(File::exists) // Filter invalid installations (missing executable) .findFirst(); // Take first one if (latestOpt.isPresent()) { File latest = latestOpt.get(); LOGGER.debug("Found {} versions of FFmpeg installed with homebrew, chose {}", homebrewVersions.length, latest); return latest.getAbsolutePath(); } } break; case LINUX: // Linux users are entrusted to have their PATH configured correctly (most package manager do this) case SOLARIS: // Never heard of anyone running this mod on Solaris having any problems case UNKNOWN: // Unknown OS, just try to use "ffmpeg" } LOGGER.debug("Using default FFmpeg executable"); return "ffmpeg"; } @Override public void close() throws IOException { IOUtils.closeQuietly(outputStream); long startTime = System.nanoTime(); long rem = TimeUnit.SECONDS.toNanos(30); do { try { process.exitValue(); break; } catch(IllegalThreadStateException ex) { if (rem > 0) { try { Thread.sleep(Math.min(TimeUnit.NANOSECONDS.toMillis(rem) + 1, 100)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } rem = TimeUnit.SECONDS.toNanos(30) - (System.nanoTime() - startTime); } while (rem > 0); process.destroy(); } @Override public void consume(RGBFrame frame) { try { checkSize(frame.getSize()); channel.write(frame.getByteBuffer()); ByteBufferPool.release(frame.getByteBuffer()); } catch (Throwable t) { if (aborted) { return; } CrashReport report = CrashReport.makeCrashReport(t, "Exporting frame"); CrashReportCategory exportDetails = report.makeCategory("Export details"); exportDetails.addCrashSection("Export command", settings.getExportCommand()); exportDetails.addCrashSection("Export args", commandArgs); Minecraft.getMinecraft().crashed(report); } } private void checkSize(ReadableDimension size) { checkSize(size.getWidth(), size.getHeight()); } private void checkSize(int width, int height) { isTrue(width == settings.getVideoWidth(), "Width has to be %d but was %d", settings.getVideoWidth(), width); isTrue(height == settings.getVideoHeight(), "Height has to be %d but was %d", settings.getVideoHeight(), height); } public void abort() { aborted = true; } public File getVideoFile() { String log = ffmpegLog.toString(); for (String line : log.split("\n")) { if (line.startsWith("Output #0")) { String fileName = line.substring(line.indexOf(", to '") + 6, line.lastIndexOf('\'')); return new File(settings.getOutputFile().getParentFile(), fileName); } } throw new IllegalStateException("No output file found."); } }