From a7ec6524b7b472298880dddb5b5e7e3e510b761c Mon Sep 17 00:00:00 2001 From: CrushedPixel Date: Thu, 6 Dec 2018 01:29:31 +0100 Subject: [PATCH] Add FOV slider for spherical render methods Before uploading to YouTube, convert all spherical non-mp4 files to mp4 to be able to inject metadata Properly re-throw errors during the rendering pipeline --- .../extras/youtube/YoutubeUploader.java | 109 +++++++++--------- .../com/replaymod/render/RenderSettings.java | 87 +++++++++++++- .../com/replaymod/render/VideoWriter.java | 60 +--------- .../replaymod/render/gui/GuiExportFailed.java | 4 +- .../render/gui/GuiRenderSettings.java | 104 +++++++++++++---- .../render/metadata/MetadataInjector.java | 59 ++++++++-- .../EquirectangularToRGBProcessor.java | 49 +++++--- .../render/processor/ODSToRGBProcessor.java | 8 +- .../replaymod/render/rendering/Pipelines.java | 18 ++- .../render/rendering/VideoRenderer.java | 16 ++- src/main/resources/assets/replaymod/lang | 2 +- 11 files changed, 340 insertions(+), 176 deletions(-) diff --git a/src/main/java/com/replaymod/extras/youtube/YoutubeUploader.java b/src/main/java/com/replaymod/extras/youtube/YoutubeUploader.java index 79a53302..7abe58ce 100644 --- a/src/main/java/com/replaymod/extras/youtube/YoutubeUploader.java +++ b/src/main/java/com/replaymod/extras/youtube/YoutubeUploader.java @@ -21,6 +21,7 @@ import com.google.api.services.youtube.model.VideoSnippet; import com.google.api.services.youtube.model.VideoStatus; import com.google.common.base.Supplier; import com.google.common.base.Suppliers; +import com.google.common.io.Files; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import com.replaymod.render.RenderSettings; @@ -41,8 +42,7 @@ import static com.replaymod.extras.ReplayModExtras.LOGGER; public class YoutubeUploader { private static final String CLIENT_ID = "743126594724-mfe7pj1k7e47uu5pk4503c8st9vj9ibu.apps.googleusercontent.com"; private static final String CLIENT_SECRET = "gMwcy3mRYCRamCIjJIYP7rqc"; - private static final String FFMPEG_ODS = - "-i %s -vf scale=iw:iw*9/16,setdar=16:9 -c:v libx264 -preset slow -crf 16 %s"; + private static final String FFMPEG_MP4 = "-i %s -c:v libx264 -preset slow -crf 16 %s"; private static final JsonFactory JSON_FACTORY = new GsonFactory(); private final NetHttpTransport httpTransport; private final DataStoreFactory dataStoreFactory; @@ -131,68 +131,73 @@ public class YoutubeUploader { } private File preUpload() throws InterruptedException, IOException { - if (settings.getRenderMethod() == RenderSettings.RenderMethod.ODS) { - File tmpFile = new File(videoFile.getParentFile(), System.currentTimeMillis() + ".mp4"); - tmpFile.deleteOnExit(); + File outputFile = videoFile; - String args = String.format(FFMPEG_ODS, videoFile.getName(), tmpFile.getName()); + if (settings.getRenderMethod().isSpherical()) { + // inject spherical metadata for YouTube unless already done - CommandLine commandLine = new CommandLine(settings.getExportCommand()); - commandLine.addArguments(args); - LOGGER.info("Re-encoding for ODS with {} {}", settings.getExportCommand(), args); - Process process = new ProcessBuilder(commandLine.toStrings()).directory(videoFile.getParentFile()).start(); + boolean isMp4 = Files.getFileExtension(outputFile.getName()).equalsIgnoreCase("mp4"); - final AtomicBoolean active = new AtomicBoolean(true); - final InputStream in = process.getErrorStream(); - new Thread(() -> { - try { - StringBuilder sb = new StringBuilder(); - while (active.get()) { - char c = (char) in.read(); - if (c == '\r') { - String str = sb.toString(); - LOGGER.debug("[FFmpeg] {}", str); - if (str.startsWith("frame=")) { - str = str.substring(6).trim(); - str = str.substring(0, str.indexOf(' ')); - double frame = Integer.parseInt(str); - progress = Suppliers.ofInstance(frame / videoFrames); + if (!isMp4) { + // convert non-mp4 videos into mp4 format to be able to inject metadata + outputFile = new File(outputFile.getParentFile(), System.currentTimeMillis() + ".mp4"); + outputFile.deleteOnExit(); + + String args = String.format(FFMPEG_MP4, videoFile.getName(), outputFile.getName()); + + CommandLine commandLine = new CommandLine(settings.getExportCommandOrDefault()); + commandLine.addArguments(args); + LOGGER.info("Re-encoding for metadata injection with {} {}", settings.getExportCommand(), args); + Process process = new ProcessBuilder(commandLine.toStrings()).directory(outputFile.getParentFile()).start(); + + final AtomicBoolean active = new AtomicBoolean(true); + final InputStream in = process.getErrorStream(); + new Thread(() -> { + try { + StringBuilder sb = new StringBuilder(); + while (active.get()) { + char c = (char) in.read(); + if (c == '\r') { + String str = sb.toString(); + LOGGER.debug("[FFmpeg] {}", str); + if (str.startsWith("frame=")) { + str = str.substring(6).trim(); + str = str.substring(0, str.indexOf(' ')); + double frame = Integer.parseInt(str); + progress = Suppliers.ofInstance(frame / videoFrames); + } + sb = new StringBuilder(); + } else { + sb.append(c); } - sb = new StringBuilder(); - } else { - sb.append(c); } + } catch (IOException e) { + e.printStackTrace(); } - } catch (IOException e) { - e.printStackTrace(); + }).start(); + + int result; + try { + result = process.waitFor(); + } catch (InterruptedException e) { + process.destroy(); + throw e; + } finally { + active.set(false); + } + if (result != 0) { + throw new IOException("FFmpeg returned: " + result); } - }).start(); - - int result; - try { - result = process.waitFor(); - } catch (InterruptedException e) { - process.destroy(); - throw e; - } finally { - active.set(false); - } - if (result != 0) { - throw new IOException("FFmpeg returned: " + result); } - MetadataInjector.injectODSMetadata(tmpFile); - - return tmpFile; - - } else if (settings.getRenderMethod() == RenderSettings.RenderMethod.EQUIRECTANGULAR) { - // if metadata hasn't been injected before, inject it before the upload - if (!settings.isInject360Metadata()) { - MetadataInjector.inject360Metadata(videoFile); + if (!settings.isInjectSphericalMetadata() || !isMp4) { + MetadataInjector.injectMetadata(settings.getRenderMethod(), outputFile, + settings.getTargetVideoWidth(), settings.getTargetVideoHeight(), + settings.getSphericalFovX(), settings.getSphericalFovY()); } } - return videoFile; + return outputFile; } private Video doUpload(YouTube youTube, File processedFile) throws IOException { diff --git a/src/main/java/com/replaymod/render/RenderSettings.java b/src/main/java/com/replaymod/render/RenderSettings.java index 37977dda..2d158800 100644 --- a/src/main/java/com/replaymod/render/RenderSettings.java +++ b/src/main/java/com/replaymod/render/RenderSettings.java @@ -3,10 +3,23 @@ package com.replaymod.render; import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; +import net.minecraft.client.Minecraft; import net.minecraft.client.resources.I18n; +import net.minecraft.util.Util; import org.lwjgl.util.ReadableColor; import java.io.File; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Optional; + +import static com.replaymod.render.ReplayModRender.LOGGER; + +//#if MC>=10800 +import net.minecraftforge.fml.common.versioning.ComparableVersion; +//#else +//$$ import cpw.mods.fml.common.versioning.ComparableVersion; +//#endif @Data public class RenderSettings { @@ -21,6 +34,14 @@ public class RenderSettings { public String getDescription() { return I18n.format("replaymod.gui.rendersettings.renderer." + name().toLowerCase() + ".description"); } + + public boolean isSpherical() { + return this == EQUIRECTANGULAR || this == ODS; + } + + public boolean hasFixedAspectRatio() { + return this == EQUIRECTANGULAR || this == ODS || this == CUBIC; + } } public enum EncodingPreset { @@ -92,7 +113,9 @@ public class RenderSettings { private final boolean stabilizePitch; private final boolean stabilizeRoll; private final ReadableColor chromaKeyingColor; - private final boolean inject360Metadata; + private final int sphericalFovX; + private final int sphericalFovY; + private final boolean injectSphericalMetadata; private final AntiAliasing antiAliasing; private final String exportCommand; @@ -129,12 +152,66 @@ public class RenderSettings { } public String getVideoFilters() { - if (antiAliasing == AntiAliasing.NONE) { - return ""; - } else { + StringBuilder filters = new StringBuilder(); + + if (antiAliasing != AntiAliasing.NONE) { double factor = 1.0 / antiAliasing.getFactor(); - return String.format("-vf scale=iw*%1$s:ih*%1$s ", factor); + filters.append(String.format("-filter:v scale=iw*%1$s:ih*%1$s ", factor)); } + + return filters.toString(); + } + + public String getExportCommandOrDefault() { + return exportCommand.isEmpty() ? findFFmpeg() : exportCommand; + } + + private static 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"; } } diff --git a/src/main/java/com/replaymod/render/VideoWriter.java b/src/main/java/com/replaymod/render/VideoWriter.java index d3502a3c..792814f4 100755 --- a/src/main/java/com/replaymod/render/VideoWriter.java +++ b/src/main/java/com/replaymod/render/VideoWriter.java @@ -8,7 +8,6 @@ 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 org.apache.commons.exec.CommandLine; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; @@ -22,17 +21,8 @@ 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; -//#if MC>=10800 -import net.minecraftforge.fml.common.versioning.ComparableVersion; -//#else -//$$ import cpw.mods.fml.common.versioning.ComparableVersion; -//#endif - import static com.replaymod.render.ReplayModRender.LOGGER; import static org.apache.commons.lang3.Validate.isTrue; @@ -64,7 +54,7 @@ public class VideoWriter implements FrameConsumer { .replace("%BITRATE%", String.valueOf(settings.getBitRate())) .replace("%FILTERS%", settings.getVideoFilters()); - String executable = settings.getExportCommand().isEmpty() ? findFFmpeg() : settings.getExportCommand(); + String executable = settings.getExportCommandOrDefault(); LOGGER.info("Starting {} with args: {}", executable, commandArgs); String[] cmdline; try { @@ -86,54 +76,6 @@ public class VideoWriter implements FrameConsumer { 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); diff --git a/src/main/java/com/replaymod/render/gui/GuiExportFailed.java b/src/main/java/com/replaymod/render/gui/GuiExportFailed.java index b86e7118..930851ca 100644 --- a/src/main/java/com/replaymod/render/gui/GuiExportFailed.java +++ b/src/main/java/com/replaymod/render/gui/GuiExportFailed.java @@ -93,7 +93,9 @@ public class GuiExportFailed extends GuiScreen { oldSettings.isStabilizePitch(), oldSettings.isStabilizeRoll(), oldSettings.getChromaKeyingColor(), - oldSettings.isInject360Metadata(), + oldSettings.getSphericalFovX(), + oldSettings.getSphericalFovY(), + oldSettings.isInjectSphericalMetadata(), oldSettings.getAntiAliasing(), oldSettings.getExportCommand(), oldSettings.getEncodingPreset().getValue(), diff --git a/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java b/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java index a07f801e..a0a5ffc6 100644 --- a/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java +++ b/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java @@ -1,5 +1,6 @@ package com.replaymod.render.gui; +import com.google.common.base.Preconditions; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.gson.Gson; @@ -149,6 +150,20 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { .setI18nLabel("replaymod.gui.rendersettings.chromakey"); public final GuiColorPicker chromaKeyingColor = new GuiColorPicker().setSize(30, 15); + public final int minSphericalFov = 120; + public final int maxSphericalFov = 360; + public final int sphericalFovSteps = 5; + public final GuiSlider sphericalFovSlider = new GuiSlider() + .onValueChanged(new Runnable() { + @Override + public void run() { + sphericalFovSlider.setText(I18n.format("replaymod.gui.rendersettings.sphericalFov") + + ": " + (minSphericalFov + sphericalFovSlider.getValue() * sphericalFovSteps) + "°"); + + updateInputs(); + } + }).setSize(200, 20).setSteps((maxSphericalFov - minSphericalFov) / sphericalFovSteps); + public final GuiCheckbox inject360Metadata = new GuiCheckbox() .setI18nLabel("replaymod.gui.rendersettings.360metadata"); @@ -161,8 +176,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { .addElements(new GridLayout.Data(0, 0.5), new GuiLabel().setI18nText("replaymod.gui.rendersettings.stabilizecamera"), stabilizePanel, chromaKeyingCheckbox, chromaKeyingColor, - inject360Metadata, - new GuiLabel(), // to show the anti-aliasing options in a new line + inject360Metadata, sphericalFovSlider, new GuiLabel().setI18nText("replaymod.gui.rendersettings.antialiasing"), antiAliasingDropdown)); public final GuiTextField exportCommand = new GuiTextField().setI18nHint("replaymod.gui.rendersettings.command") @@ -289,6 +303,11 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { } protected void updateInputs() { + RenderSettings.RenderMethod renderMethod = renderMethodDropdown.getSelectedValue(); + + // Enable/Disable video with input + videoWidth.setEnabled(!renderMethod.hasFixedAspectRatio()); + // Validate video width and height String error = isResolutionValid(); if (error == null) { @@ -311,7 +330,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { } // Enable/Disable camera stabilization checkboxes - switch (renderMethodDropdown.getSelectedValue()) { + switch (renderMethod) { case CUBIC: case EQUIRECTANGULAR: case ODS: @@ -321,10 +340,12 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { stabilizePanel.forEach(IGuiCheckbox.class).setDisabled(); } + // Enable/Disable Spherical FOV slider + sphericalFovSlider.setEnabled(renderMethod.isSpherical()); + // Enable/Disable inject metadata checkbox if (encodingPresetDropdown.getSelectedValue().getFileExtension().equals("mp4") - && (renderMethodDropdown.getSelectedValue() == RenderSettings.RenderMethod.EQUIRECTANGULAR - || renderMethodDropdown.getSelectedValue() == RenderSettings.RenderMethod.ODS)) { + && renderMethod.isSpherical()) { inject360Metadata.setEnabled().setTooltip(null); } else { inject360Metadata.setDisabled().setTooltip(new GuiTooltip().setColor(Colors.RED) @@ -335,33 +356,72 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { protected String isResolutionValid() { RenderSettings.EncodingPreset preset = encodingPresetDropdown.getSelectedValue(); RenderSettings.RenderMethod method = renderMethodDropdown.getSelectedValue(); - int videoWidth = this.videoWidth.getInteger(); + int videoHeight = this.videoHeight.getInteger(); + int videoWidth; + if (method.hasFixedAspectRatio()) { + // cubic rendering requires an aspect ratio of 4:3, + // therefore the height must be divisible by 3 + if (method == RenderSettings.RenderMethod.CUBIC && videoHeight % 3 != 0) { + return "replaymod.gui.rendersettings.customresolution.warning.cubic.height"; + } + + int sphericalFov = minSphericalFov + sphericalFovSlider.getValue() * sphericalFovSteps; + videoWidth = videoWidthForHeight(method, videoHeight, sphericalFov, sphericalFov); + + this.videoWidth.setValue(videoWidth); // TODO: isResolutionValid should not have side-effects + } else { + videoWidth = this.videoWidth.getInteger(); + } // Make sure the export arguments haven't been changed manually if (exportArguments.getText().equals(preset.getValue())) { // Yuv420 requires both dimensions to be even if (preset.isYuv420() && (videoWidth % 2 != 0 || videoHeight % 2 != 0)) { + + if (method == RenderSettings.RenderMethod.CUBIC) { + // cubic yuv rendering has the special case that the height must be + // divisible by both 3 and 2 - tell the user about it with a special message + return "replaymod.gui.rendersettings.customresolution.warning.yuv420.cubic"; + } + return "replaymod.gui.rendersettings.customresolution.warning.yuv420"; } } - if (method == RenderSettings.RenderMethod.CUBIC - && (videoWidth * 3 / 4 != videoHeight || videoWidth * 3 % 4 != 0)) { - return "replaymod.gui.rendersettings.customresolution.warning.cubic"; - } - if (method == RenderSettings.RenderMethod.EQUIRECTANGULAR - && (videoWidth / 2 != videoHeight || videoWidth % 2 != 0)) { - return "replaymod.gui.rendersettings.customresolution.warning.equirectangular"; - } - if (method == RenderSettings.RenderMethod.ODS - && videoWidth != videoHeight) { - return "replaymod.gui.rendersettings.customresolution.warning.ods"; - } return null; } + protected int videoWidthForHeight(RenderSettings.RenderMethod method, int height, + int sphericalFovX, int sphericalFovY) { + if (method.isSpherical()) { + if (sphericalFovY < 180) { + // calculate the non-cropped height of the video + height = Math.round(height * 180 / (float) sphericalFovY); + } + + int width = height * 2; + + if (sphericalFovX < 360) { + // crop the resulting width + width = Math.round(width * (float) sphericalFovX / 360); + } + + if (method == RenderSettings.RenderMethod.ODS) { + width = Math.round(width / 2f); + } + + return width; + + } else if (method == RenderSettings.RenderMethod.CUBIC) { + Preconditions.checkArgument(height % 3 == 0); + return height / 3 * 4; + } + + throw new IllegalArgumentException(); + } + public void load(RenderSettings settings) { renderMethodDropdown.setSelected(settings.getRenderMethod()); encodingPresetDropdown.setSelected(settings.getEncodingPreset()); @@ -397,7 +457,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { chromaKeyingCheckbox.setChecked(true); chromaKeyingColor.setColor(settings.getChromaKeyingColor()); } - inject360Metadata.setChecked(settings.isInject360Metadata()); + sphericalFovSlider.setValue((settings.getSphericalFovX() - minSphericalFov) / sphericalFovSteps); + inject360Metadata.setChecked(settings.isInjectSphericalMetadata()); antiAliasingDropdown.setSelected(settings.getAntiAliasing()); exportCommand.setText(settings.getExportCommand()); exportArguments.setText(settings.getExportArguments()); @@ -406,6 +467,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { } public RenderSettings save(boolean serialize) { + int sphericalFov = minSphericalFov + sphericalFovSlider.getValue() * sphericalFovSteps; + return new RenderSettings( renderMethodDropdown.getSelectedValue(), encodingPresetDropdown.getSelectedValue(), @@ -419,6 +482,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { stabilizePitch.isChecked() && (serialize || stabilizePitch.isEnabled()), stabilizeRoll.isChecked() && (serialize || stabilizeRoll.isEnabled()), chromaKeyingCheckbox.isChecked() ? chromaKeyingColor.getColor() : null, + sphericalFov, sphericalFov, inject360Metadata.isChecked() && (serialize || inject360Metadata.isEnabled()), antiAliasingDropdown.getSelectedValue(), exportCommand.getText(), @@ -435,7 +499,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { private RenderSettings getDefaultRenderSettings() { return new RenderSettings(RenderSettings.RenderMethod.DEFAULT, RenderSettings.EncodingPreset.MP4_DEFAULT, 1920, 1080, 60, 10 << 20, null, - true, false, false, false, null, false, RenderSettings.AntiAliasing.NONE, "", RenderSettings.EncodingPreset.MP4_DEFAULT.getValue(), false); + true, false, false, false, null, 360, 180, false, RenderSettings.AntiAliasing.NONE, "", RenderSettings.EncodingPreset.MP4_DEFAULT.getValue(), false); } @Override diff --git a/src/main/java/com/replaymod/render/metadata/MetadataInjector.java b/src/main/java/com/replaymod/render/metadata/MetadataInjector.java index 1b8d76ba..16d9017e 100644 --- a/src/main/java/com/replaymod/render/metadata/MetadataInjector.java +++ b/src/main/java/com/replaymod/render/metadata/MetadataInjector.java @@ -4,8 +4,10 @@ import com.coremedia.iso.IsoFile; import com.coremedia.iso.boxes.*; import com.google.common.primitives.Bytes; import com.googlecode.mp4parser.BasicContainer; +import com.replaymod.render.RenderSettings; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; +import org.lwjgl.util.Dimension; import java.io.File; import java.io.FileOutputStream; @@ -30,12 +32,20 @@ public class MetadataInjector { "true " + ""+STITCHING_SOFTWARE+" " + "equirectangular "; + private static final String SPHERICAL_CROP_XML = + "%d " + + "%d " + + "%d " + + "%d " + + "%d " + + "%d "; private static final String STEREO_XML_CONTENTS = "top-bottom"; private static final String SPHERICAL_XML_FOOTER = ""; - private static final String XML_360_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS + SPHERICAL_XML_FOOTER; - private static final String XML_ODS_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS - + STEREO_XML_CONTENTS + SPHERICAL_XML_FOOTER; + private static final String XML_MONO_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS + + SPHERICAL_CROP_XML + SPHERICAL_XML_FOOTER; + private static final String XML_STEREO_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS + + SPHERICAL_CROP_XML + STEREO_XML_CONTENTS + SPHERICAL_XML_FOOTER; /** * These bytes are taken from the variable 'spherical_uuid_id' @@ -48,18 +58,43 @@ public class MetadataInjector { (byte)0x02, (byte)0x52, (byte)0x1f, (byte)0xdd }; - private static final byte[] BYTES_360_METADATA = Bytes.concat(UUID_BYTES, XML_360_METADATA.getBytes()); - private static final byte[] BYTES_ODS_METADATA = Bytes.concat(UUID_BYTES, XML_ODS_METADATA.getBytes()); + public static void injectMetadata(RenderSettings.RenderMethod renderMethod, File videoFile, + int videoWidth, int videoHeight, + int sphericalFovX, int sphericalFovY) { + String xmlString; + switch (renderMethod) { + case EQUIRECTANGULAR: + xmlString = XML_MONO_METADATA; + break; + case ODS: + xmlString = XML_STEREO_METADATA; + break; + default: + throw new IllegalArgumentException("Invalid render method"); + } - public static void inject360Metadata(File videoFile) { - writeMetadata(videoFile, BYTES_360_METADATA); + Dimension original = getOriginalDimensions(videoWidth, videoHeight, sphericalFovX, sphericalFovY); + writeMetadata(videoFile, String.format(xmlString, + original.getWidth(), original.getHeight(), videoWidth, videoHeight, + (original.getWidth() - videoWidth) / 2, (original.getHeight() - videoHeight) / 2)); } - public static void injectODSMetadata(File videoFile) { - writeMetadata(videoFile, BYTES_ODS_METADATA); + private static Dimension getOriginalDimensions(int videoWidth, int videoHeight, + int sphericalFovX, int sphericalFovY) { + if (sphericalFovX < 360) { + videoWidth = Math.round(videoWidth * 360 / (float) sphericalFovX); + } + + if (sphericalFovY < 180) { + videoHeight = Math.round(videoHeight * 180 / (float) sphericalFovY); + } + + return new Dimension(videoWidth, videoHeight); } - private static void writeMetadata(File videoFile, byte[] metadata) { + private static void writeMetadata(File videoFile, String metadata) { + byte[] bytes = Bytes.concat(UUID_BYTES, metadata.getBytes()); + File tempFile = null; FileOutputStream videoFileOutputStream = null; IsoFile tempIsoFile = null; @@ -80,7 +115,7 @@ public class MetadataInjector { //create a new UserBox, which actually contains the Metadata bytes UserBox metadataBox = new UserBox(new byte[0]); - metadataBox.setData(metadata); + metadataBox.setData(bytes); //add the Metadata UserBox to the Movie Track trackBox.addBox(metadataBox); @@ -97,7 +132,7 @@ public class MetadataInjector { videoFileOutputStream = new FileOutputStream(videoFile); tempIsoFile.getBox(videoFileOutputStream.getChannel()); } catch(Exception e) { - LOGGER.error("360 Degree Metadata couldn't be injected", e); + LOGGER.error("Spherical Metadata couldn't be injected", e); } finally { IOUtils.closeQuietly(tempIsoFile); IOUtils.closeQuietly(videoFileOutputStream); diff --git a/src/main/java/com/replaymod/render/processor/EquirectangularToRGBProcessor.java b/src/main/java/com/replaymod/render/processor/EquirectangularToRGBProcessor.java index 4d19bc11..b027b977 100644 --- a/src/main/java/com/replaymod/render/processor/EquirectangularToRGBProcessor.java +++ b/src/main/java/com/replaymod/render/processor/EquirectangularToRGBProcessor.java @@ -3,6 +3,7 @@ package com.replaymod.render.processor; import com.replaymod.render.frame.CubicOpenGlFrame; import com.replaymod.render.frame.RGBFrame; import com.replaymod.render.utils.ByteBufferPool; +import lombok.Getter; import org.apache.commons.lang3.Validate; import org.lwjgl.util.Dimension; @@ -18,6 +19,7 @@ public class EquirectangularToRGBProcessor extends AbstractFrameProcessor= 1) { @@ -74,9 +97,9 @@ public class EquirectangularToRGBProcessor extends AbstractFrameProcessor { private final EquirectangularToRGBProcessor processor; - public ODSToRGBProcessor(int frameSize) { - processor = new EquirectangularToRGBProcessor(frameSize); + public ODSToRGBProcessor(int outputWidth, int outputHeight, int sphericalFovX) { + processor = new EquirectangularToRGBProcessor(outputWidth, outputHeight / 2, sphericalFovX); } @Override @@ -34,4 +34,8 @@ public class ODSToRGBProcessor extends AbstractFrameProcessor newEquirectangularPipeline(RenderInfo renderInfo, FrameConsumer consumer) { RenderSettings settings = renderInfo.getRenderSettings(); + + EquirectangularToRGBProcessor processor = new EquirectangularToRGBProcessor(settings.getVideoWidth(), + settings.getVideoHeight(), settings.getSphericalFovX()); + FrameCapturer capturer; if (PixelBufferObject.SUPPORTED) { - capturer = new CubicPboOpenGlFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, settings.getVideoWidth() / 4); + capturer = new CubicPboOpenGlFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, processor.getFrameSize()); } else { - capturer = new CubicOpenGlFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, settings.getVideoWidth() / 4); + capturer = new CubicOpenGlFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, processor.getFrameSize()); } - return new Pipeline<>(capturer, new EquirectangularToRGBProcessor(settings.getVideoWidth() / 4), consumer); + return new Pipeline<>(capturer, processor, consumer); } public static Pipeline newODSPipeline(RenderInfo renderInfo, FrameConsumer consumer) { RenderSettings settings = renderInfo.getRenderSettings(); + + ODSToRGBProcessor processor = new ODSToRGBProcessor(settings.getVideoWidth(), + settings.getVideoHeight(), settings.getSphericalFovX()); + FrameCapturer capturer = - new ODSFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, settings.getVideoWidth() / 4); - return new Pipeline<>(capturer, new ODSToRGBProcessor(settings.getVideoWidth() / 4), consumer); + new ODSFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, processor.getFrameSize()); + return new Pipeline<>(capturer, processor, consumer); } } diff --git a/src/main/java/com/replaymod/render/rendering/VideoRenderer.java b/src/main/java/com/replaymod/render/rendering/VideoRenderer.java index 4ed6f223..9e16a538 100644 --- a/src/main/java/com/replaymod/render/rendering/VideoRenderer.java +++ b/src/main/java/com/replaymod/render/rendering/VideoRenderer.java @@ -22,6 +22,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.ScaledResolution; import net.minecraft.client.renderer.OpenGlHelper; import net.minecraft.client.shader.Framebuffer; +import net.minecraft.util.ReportedException; import net.minecraft.util.Timer; import org.lwjgl.input.Mouse; import org.lwjgl.opengl.Display; @@ -150,12 +151,15 @@ public class VideoRenderer implements RenderInfo { renderingPipeline.run(); - if (settings.isInject360Metadata()) { - if (settings.getRenderMethod() == RenderSettings.RenderMethod.ODS) { - MetadataInjector.injectODSMetadata(settings.getOutputFile()); - } else { - MetadataInjector.inject360Metadata(settings.getOutputFile()); - } + if (mc.hasCrashed) { + setFailure(mc.crashReporter.getCrashCause()); + throw new ReportedException(mc.crashReporter); + } + + if (settings.isInjectSphericalMetadata()) { + MetadataInjector.injectMetadata(settings.getRenderMethod(), settings.getOutputFile(), + settings.getTargetVideoWidth(), settings.getTargetVideoHeight(), + settings.getSphericalFovX(), settings.getSphericalFovY()); } finish(); diff --git a/src/main/resources/assets/replaymod/lang b/src/main/resources/assets/replaymod/lang index 692a6765..70c4d164 160000 --- a/src/main/resources/assets/replaymod/lang +++ b/src/main/resources/assets/replaymod/lang @@ -1 +1 @@ -Subproject commit 692a6765560bd96f02c9364798395914470c2c9e +Subproject commit 70c4d16435aab0c8d8028b3096b0aa2510ea9f31