Merge branch 'spherical-fov' into develop

This commit is contained in:
Jonas Herzig
2018-12-19 10:20:23 +01:00
11 changed files with 347 additions and 184 deletions

View File

@@ -21,6 +21,7 @@ import com.google.api.services.youtube.model.VideoSnippet;
import com.google.api.services.youtube.model.VideoStatus; import com.google.api.services.youtube.model.VideoStatus;
import com.google.common.base.Supplier; import com.google.common.base.Supplier;
import com.google.common.base.Suppliers; 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.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.SettableFuture;
import com.replaymod.render.RenderSettings; import com.replaymod.render.RenderSettings;
@@ -41,8 +42,7 @@ import static com.replaymod.extras.ReplayModExtras.LOGGER;
public class YoutubeUploader { public class YoutubeUploader {
private static final String CLIENT_ID = "743126594724-mfe7pj1k7e47uu5pk4503c8st9vj9ibu.apps.googleusercontent.com"; private static final String CLIENT_ID = "743126594724-mfe7pj1k7e47uu5pk4503c8st9vj9ibu.apps.googleusercontent.com";
private static final String CLIENT_SECRET = "gMwcy3mRYCRamCIjJIYP7rqc"; private static final String CLIENT_SECRET = "gMwcy3mRYCRamCIjJIYP7rqc";
private static final String FFMPEG_ODS = private static final String FFMPEG_MP4 = "-i %s -c:v libx264 -preset slow -crf 16 %s";
"-i %s -vf scale=iw:iw*9/16,setdar=16:9 -c:v libx264 -preset slow -crf 16 %s";
private static final JsonFactory JSON_FACTORY = new GsonFactory(); private static final JsonFactory JSON_FACTORY = new GsonFactory();
private final NetHttpTransport httpTransport; private final NetHttpTransport httpTransport;
private final DataStoreFactory dataStoreFactory; private final DataStoreFactory dataStoreFactory;
@@ -131,68 +131,73 @@ public class YoutubeUploader {
} }
private File preUpload() throws InterruptedException, IOException { private File preUpload() throws InterruptedException, IOException {
if (settings.getRenderMethod() == RenderSettings.RenderMethod.ODS) { File outputFile = videoFile;
File tmpFile = new File(videoFile.getParentFile(), System.currentTimeMillis() + ".mp4");
tmpFile.deleteOnExit();
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()); boolean isMp4 = Files.getFileExtension(outputFile.getName()).equalsIgnoreCase("mp4");
commandLine.addArguments(args);
LOGGER.info("Re-encoding for ODS with {} {}", settings.getExportCommand(), args);
Process process = new ProcessBuilder(commandLine.toStrings()).directory(videoFile.getParentFile()).start();
final AtomicBoolean active = new AtomicBoolean(true); if (!isMp4) {
final InputStream in = process.getErrorStream(); // convert non-mp4 videos into mp4 format to be able to inject metadata
new Thread(() -> { outputFile = new File(outputFile.getParentFile(), System.currentTimeMillis() + ".mp4");
try { outputFile.deleteOnExit();
StringBuilder sb = new StringBuilder();
while (active.get()) { String args = String.format(FFMPEG_MP4, videoFile.getName(), outputFile.getName());
char c = (char) in.read();
if (c == '\r') { CommandLine commandLine = new CommandLine(settings.getExportCommandOrDefault());
String str = sb.toString(); commandLine.addArguments(args);
LOGGER.debug("[FFmpeg] {}", str); LOGGER.info("Re-encoding for metadata injection with {} {}", commandLine.getExecutable(), args);
if (str.startsWith("frame=")) { Process process = new ProcessBuilder(commandLine.toStrings()).directory(outputFile.getParentFile()).start();
str = str.substring(6).trim();
str = str.substring(0, str.indexOf(' ')); final AtomicBoolean active = new AtomicBoolean(true);
double frame = Integer.parseInt(str); final InputStream in = process.getErrorStream();
progress = Suppliers.ofInstance(frame / videoFrames); 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) { }).start();
e.printStackTrace();
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); if (!settings.isInjectSphericalMetadata() || !isMp4) {
MetadataInjector.injectMetadata(settings.getRenderMethod(), outputFile,
return tmpFile; settings.getTargetVideoWidth(), settings.getTargetVideoHeight(),
settings.getSphericalFovX(), settings.getSphericalFovY());
} 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);
} }
} }
return videoFile; return outputFile;
} }
private Video doUpload(YouTube youTube, File processedFile) throws IOException { private Video doUpload(YouTube youTube, File processedFile) throws IOException {

View File

@@ -3,10 +3,23 @@ package com.replaymod.render;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.Getter; import lombok.Getter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.resources.I18n; import net.minecraft.client.resources.I18n;
import net.minecraft.util.Util;
import org.lwjgl.util.ReadableColor; import org.lwjgl.util.ReadableColor;
import java.io.File; 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 @Data
public class RenderSettings { public class RenderSettings {
@@ -21,6 +34,14 @@ public class RenderSettings {
public String getDescription() { public String getDescription() {
return I18n.format("replaymod.gui.rendersettings.renderer." + name().toLowerCase() + ".description"); 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 { public enum EncodingPreset {
@@ -92,7 +113,9 @@ public class RenderSettings {
private final boolean stabilizePitch; private final boolean stabilizePitch;
private final boolean stabilizeRoll; private final boolean stabilizeRoll;
private final ReadableColor chromaKeyingColor; 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 AntiAliasing antiAliasing;
private final String exportCommand; private final String exportCommand;
@@ -129,12 +152,66 @@ public class RenderSettings {
} }
public String getVideoFilters() { public String getVideoFilters() {
if (antiAliasing == AntiAliasing.NONE) { StringBuilder filters = new StringBuilder();
return "";
} else { if (antiAliasing != AntiAliasing.NONE) {
double factor = 1.0 / antiAliasing.getFactor(); 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<File> 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";
} }
} }

View File

@@ -8,7 +8,6 @@ import com.replaymod.render.utils.StreamPipe;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.crash.CrashReport; import net.minecraft.crash.CrashReport;
import net.minecraft.crash.CrashReportCategory; import net.minecraft.crash.CrashReportCategory;
import net.minecraft.util.Util;
import org.apache.commons.exec.CommandLine; import org.apache.commons.exec.CommandLine;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
@@ -22,17 +21,8 @@ import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.channels.Channels; import java.nio.channels.Channels;
import java.nio.channels.WritableByteChannel; import java.nio.channels.WritableByteChannel;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Optional;
import java.util.concurrent.TimeUnit; 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 com.replaymod.render.ReplayModRender.LOGGER;
import static org.apache.commons.lang3.Validate.isTrue; import static org.apache.commons.lang3.Validate.isTrue;
@@ -64,7 +54,7 @@ public class VideoWriter implements FrameConsumer<RGBFrame> {
.replace("%BITRATE%", String.valueOf(settings.getBitRate())) .replace("%BITRATE%", String.valueOf(settings.getBitRate()))
.replace("%FILTERS%", settings.getVideoFilters()); .replace("%FILTERS%", settings.getVideoFilters());
String executable = settings.getExportCommand().isEmpty() ? findFFmpeg() : settings.getExportCommand(); String executable = settings.getExportCommandOrDefault();
LOGGER.info("Starting {} with args: {}", executable, commandArgs); LOGGER.info("Starting {} with args: {}", executable, commandArgs);
String[] cmdline; String[] cmdline;
try { try {
@@ -86,54 +76,6 @@ public class VideoWriter implements FrameConsumer<RGBFrame> {
channel = Channels.newChannel(outputStream); 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<File> 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 @Override
public void close() throws IOException { public void close() throws IOException {
IOUtils.closeQuietly(outputStream); IOUtils.closeQuietly(outputStream);

View File

@@ -93,7 +93,9 @@ public class GuiExportFailed extends GuiScreen {
oldSettings.isStabilizePitch(), oldSettings.isStabilizePitch(),
oldSettings.isStabilizeRoll(), oldSettings.isStabilizeRoll(),
oldSettings.getChromaKeyingColor(), oldSettings.getChromaKeyingColor(),
oldSettings.isInject360Metadata(), oldSettings.getSphericalFovX(),
oldSettings.getSphericalFovY(),
oldSettings.isInjectSphericalMetadata(),
oldSettings.getAntiAliasing(), oldSettings.getAntiAliasing(),
oldSettings.getExportCommand(), oldSettings.getExportCommand(),
oldSettings.getEncodingPreset().getValue(), oldSettings.getEncodingPreset().getValue(),

View File

@@ -1,5 +1,6 @@
package com.replaymod.render.gui; package com.replaymod.render.gui;
import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.gson.Gson; import com.google.gson.Gson;
@@ -149,8 +150,22 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
.setI18nLabel("replaymod.gui.rendersettings.chromakey"); .setI18nLabel("replaymod.gui.rendersettings.chromakey");
public final GuiColorPicker chromaKeyingColor = new GuiColorPicker().setSize(30, 15); public final GuiColorPicker chromaKeyingColor = new GuiColorPicker().setSize(30, 15);
public final GuiCheckbox inject360Metadata = new GuiCheckbox() public static final int MIN_SPHERICAL_FOV = 120;
.setI18nLabel("replaymod.gui.rendersettings.360metadata"); public static final int MAX_SPHERICAL_FOV = 360;
public static final int SPHERICAL_FOV_STEP_SIZE = 5;
public final GuiSlider sphericalFovSlider = new GuiSlider()
.onValueChanged(new Runnable() {
@Override
public void run() {
sphericalFovSlider.setText(I18n.format("replaymod.gui.rendersettings.sphericalFov")
+ ": " + (MIN_SPHERICAL_FOV + sphericalFovSlider.getValue() * SPHERICAL_FOV_STEP_SIZE) + "°");
updateInputs();
}
}).setSize(200, 20).setSteps((MAX_SPHERICAL_FOV - MIN_SPHERICAL_FOV) / SPHERICAL_FOV_STEP_SIZE);
public final GuiCheckbox injectSphericalMetadata = new GuiCheckbox()
.setI18nLabel("replaymod.gui.rendersettings.sphericalmetadata");
public final GuiDropdownMenu<RenderSettings.AntiAliasing> antiAliasingDropdown = new GuiDropdownMenu<RenderSettings.AntiAliasing>() public final GuiDropdownMenu<RenderSettings.AntiAliasing> antiAliasingDropdown = new GuiDropdownMenu<RenderSettings.AntiAliasing>()
.setSize(200, 20).setValues(RenderSettings.AntiAliasing.values()).setSelected(RenderSettings.AntiAliasing.NONE); .setSize(200, 20).setValues(RenderSettings.AntiAliasing.values()).setSelected(RenderSettings.AntiAliasing.NONE);
@@ -161,8 +176,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
.addElements(new GridLayout.Data(0, 0.5), .addElements(new GridLayout.Data(0, 0.5),
new GuiLabel().setI18nText("replaymod.gui.rendersettings.stabilizecamera"), stabilizePanel, new GuiLabel().setI18nText("replaymod.gui.rendersettings.stabilizecamera"), stabilizePanel,
chromaKeyingCheckbox, chromaKeyingColor, chromaKeyingCheckbox, chromaKeyingColor,
inject360Metadata, injectSphericalMetadata, sphericalFovSlider,
new GuiLabel(), // to show the anti-aliasing options in a new line
new GuiLabel().setI18nText("replaymod.gui.rendersettings.antialiasing"), antiAliasingDropdown)); new GuiLabel().setI18nText("replaymod.gui.rendersettings.antialiasing"), antiAliasingDropdown));
public final GuiTextField exportCommand = new GuiTextField().setI18nHint("replaymod.gui.rendersettings.command") public final GuiTextField exportCommand = new GuiTextField().setI18nHint("replaymod.gui.rendersettings.command")
@@ -289,8 +303,13 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
} }
protected void updateInputs() { protected void updateInputs() {
RenderSettings.RenderMethod renderMethod = renderMethodDropdown.getSelectedValue();
// Enable/Disable video with input
videoWidth.setEnabled(!renderMethod.hasFixedAspectRatio());
// Validate video width and height // Validate video width and height
String error = isResolutionValid(); String error = updateResolution();
if (error == null) { if (error == null) {
renderButton.setEnabled().setTooltip(null); renderButton.setEnabled().setTooltip(null);
videoWidth.setTextColor(Colors.WHITE); videoWidth.setTextColor(Colors.WHITE);
@@ -311,7 +330,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
} }
// Enable/Disable camera stabilization checkboxes // Enable/Disable camera stabilization checkboxes
switch (renderMethodDropdown.getSelectedValue()) { switch (renderMethod) {
case CUBIC: case CUBIC:
case EQUIRECTANGULAR: case EQUIRECTANGULAR:
case ODS: case ODS:
@@ -321,47 +340,88 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
stabilizePanel.forEach(IGuiCheckbox.class).setDisabled(); stabilizePanel.forEach(IGuiCheckbox.class).setDisabled();
} }
// Enable/Disable Spherical FOV slider
sphericalFovSlider.setEnabled(renderMethod.isSpherical());
// Enable/Disable inject metadata checkbox // Enable/Disable inject metadata checkbox
if (encodingPresetDropdown.getSelectedValue().getFileExtension().equals("mp4") if (encodingPresetDropdown.getSelectedValue().getFileExtension().equals("mp4")
&& (renderMethodDropdown.getSelectedValue() == RenderSettings.RenderMethod.EQUIRECTANGULAR && renderMethod.isSpherical()) {
|| renderMethodDropdown.getSelectedValue() == RenderSettings.RenderMethod.ODS)) { injectSphericalMetadata.setEnabled().setTooltip(null);
inject360Metadata.setEnabled().setTooltip(null);
} else { } else {
inject360Metadata.setDisabled().setTooltip(new GuiTooltip().setColor(Colors.RED) injectSphericalMetadata.setDisabled().setTooltip(new GuiTooltip().setColor(Colors.RED)
.setI18nText("replaymod.gui.rendersettings.360metadata.error")); .setI18nText("replaymod.gui.rendersettings.sphericalmetadata.error"));
} }
} }
protected String isResolutionValid() { protected String updateResolution() {
RenderSettings.EncodingPreset preset = encodingPresetDropdown.getSelectedValue(); RenderSettings.EncodingPreset preset = encodingPresetDropdown.getSelectedValue();
RenderSettings.RenderMethod method = renderMethodDropdown.getSelectedValue(); RenderSettings.RenderMethod method = renderMethodDropdown.getSelectedValue();
int videoWidth = this.videoWidth.getInteger();
int videoHeight = this.videoHeight.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 = MIN_SPHERICAL_FOV + sphericalFovSlider.getValue() * SPHERICAL_FOV_STEP_SIZE;
videoWidth = videoWidthForHeight(method, videoHeight, sphericalFov, sphericalFov);
this.videoWidth.setValue(videoWidth);
} else {
videoWidth = this.videoWidth.getInteger();
}
// Make sure the export arguments haven't been changed manually // Make sure the export arguments haven't been changed manually
if (exportArguments.getText().equals(preset.getValue())) { if (exportArguments.getText().equals(preset.getValue())) {
// Yuv420 requires both dimensions to be even // Yuv420 requires both dimensions to be even
if (preset.isYuv420() if (preset.isYuv420()
&& (videoWidth % 2 != 0 || videoHeight % 2 != 0)) { && (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"; 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; 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) { public void load(RenderSettings settings) {
renderMethodDropdown.setSelected(settings.getRenderMethod()); renderMethodDropdown.setSelected(settings.getRenderMethod());
encodingPresetDropdown.setSelected(settings.getEncodingPreset()); encodingPresetDropdown.setSelected(settings.getEncodingPreset());
@@ -397,7 +457,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
chromaKeyingCheckbox.setChecked(true); chromaKeyingCheckbox.setChecked(true);
chromaKeyingColor.setColor(settings.getChromaKeyingColor()); chromaKeyingColor.setColor(settings.getChromaKeyingColor());
} }
inject360Metadata.setChecked(settings.isInject360Metadata()); sphericalFovSlider.setValue((settings.getSphericalFovX() - MIN_SPHERICAL_FOV) / SPHERICAL_FOV_STEP_SIZE);
injectSphericalMetadata.setChecked(settings.isInjectSphericalMetadata());
antiAliasingDropdown.setSelected(settings.getAntiAliasing()); antiAliasingDropdown.setSelected(settings.getAntiAliasing());
exportCommand.setText(settings.getExportCommand()); exportCommand.setText(settings.getExportCommand());
exportArguments.setText(settings.getExportArguments()); exportArguments.setText(settings.getExportArguments());
@@ -406,6 +467,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
} }
public RenderSettings save(boolean serialize) { public RenderSettings save(boolean serialize) {
int sphericalFov = MIN_SPHERICAL_FOV + sphericalFovSlider.getValue() * SPHERICAL_FOV_STEP_SIZE;
return new RenderSettings( return new RenderSettings(
renderMethodDropdown.getSelectedValue(), renderMethodDropdown.getSelectedValue(),
encodingPresetDropdown.getSelectedValue(), encodingPresetDropdown.getSelectedValue(),
@@ -419,7 +482,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
stabilizePitch.isChecked() && (serialize || stabilizePitch.isEnabled()), stabilizePitch.isChecked() && (serialize || stabilizePitch.isEnabled()),
stabilizeRoll.isChecked() && (serialize || stabilizeRoll.isEnabled()), stabilizeRoll.isChecked() && (serialize || stabilizeRoll.isEnabled()),
chromaKeyingCheckbox.isChecked() ? chromaKeyingColor.getColor() : null, chromaKeyingCheckbox.isChecked() ? chromaKeyingColor.getColor() : null,
inject360Metadata.isChecked() && (serialize || inject360Metadata.isEnabled()), sphericalFov, Math.min(180, sphericalFov),
injectSphericalMetadata.isChecked() && (serialize || injectSphericalMetadata.isEnabled()),
antiAliasingDropdown.getSelectedValue(), antiAliasingDropdown.getSelectedValue(),
exportCommand.getText(), exportCommand.getText(),
exportArguments.getText(), exportArguments.getText(),
@@ -435,7 +499,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable {
private RenderSettings getDefaultRenderSettings() { private RenderSettings getDefaultRenderSettings() {
return new RenderSettings(RenderSettings.RenderMethod.DEFAULT, RenderSettings.EncodingPreset.MP4_DEFAULT, 1920, 1080, 60, 10 << 20, null, 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 @Override

View File

@@ -4,8 +4,10 @@ import com.coremedia.iso.IsoFile;
import com.coremedia.iso.boxes.*; import com.coremedia.iso.boxes.*;
import com.google.common.primitives.Bytes; import com.google.common.primitives.Bytes;
import com.googlecode.mp4parser.BasicContainer; import com.googlecode.mp4parser.BasicContainer;
import com.replaymod.render.RenderSettings;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.lwjgl.util.Dimension;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@@ -30,12 +32,20 @@ public class MetadataInjector {
"<GSpherical:Stitched>true</GSpherical:Stitched> " + "<GSpherical:Stitched>true</GSpherical:Stitched> " +
"<GSpherical:StitchingSoftware>"+STITCHING_SOFTWARE+"</GSpherical:StitchingSoftware> " + "<GSpherical:StitchingSoftware>"+STITCHING_SOFTWARE+"</GSpherical:StitchingSoftware> " +
"<GSpherical:ProjectionType>equirectangular</GSpherical:ProjectionType> "; "<GSpherical:ProjectionType>equirectangular</GSpherical:ProjectionType> ";
private static final String SPHERICAL_CROP_XML =
"<GSpherical:FullPanoWidthPixels>%d</GSpherical:FullPanoWidthPixels> " +
"<GSpherical:FullPanoHeightPixels>%d</GSpherical:FullPanoHeightPixels> " +
"<GSpherical:CroppedAreaImageWidthPixels>%d</GSpherical:CroppedAreaImageWidthPixels> " +
"<GSpherical:CroppedAreaImageHeightPixels>%d</GSpherical:CroppedAreaImageHeightPixels> " +
"<GSpherical:CroppedAreaLeftPixels>%d</GSpherical:CroppedAreaLeftPixels> " +
"<GSpherical:CroppedAreaTopPixels>%d</GSpherical:CroppedAreaTopPixels> ";
private static final String STEREO_XML_CONTENTS = "<GSpherical:StereoMode>top-bottom</GSpherical:StereoMode>"; private static final String STEREO_XML_CONTENTS = "<GSpherical:StereoMode>top-bottom</GSpherical:StereoMode>";
private static final String SPHERICAL_XML_FOOTER = "</rdf:SphericalVideo>"; private static final String SPHERICAL_XML_FOOTER = "</rdf:SphericalVideo>";
private static final String XML_360_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS + SPHERICAL_XML_FOOTER; private static final String XML_MONO_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS
private static final String XML_ODS_METADATA = SPHERICAL_XML_HEADER + SPHERICAL_XML_CONTENTS + SPHERICAL_CROP_XML + SPHERICAL_XML_FOOTER;
+ STEREO_XML_CONTENTS + 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' * 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 (byte)0x02, (byte)0x52, (byte)0x1f, (byte)0xdd
}; };
private static final byte[] BYTES_360_METADATA = Bytes.concat(UUID_BYTES, XML_360_METADATA.getBytes()); public static void injectMetadata(RenderSettings.RenderMethod renderMethod, File videoFile,
private static final byte[] BYTES_ODS_METADATA = Bytes.concat(UUID_BYTES, XML_ODS_METADATA.getBytes()); 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) { Dimension original = getOriginalDimensions(videoWidth, videoHeight, sphericalFovX, sphericalFovY);
writeMetadata(videoFile, BYTES_360_METADATA); 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) { private static Dimension getOriginalDimensions(int videoWidth, int videoHeight,
writeMetadata(videoFile, BYTES_ODS_METADATA); 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; File tempFile = null;
FileOutputStream videoFileOutputStream = null; FileOutputStream videoFileOutputStream = null;
IsoFile tempIsoFile = null; IsoFile tempIsoFile = null;
@@ -80,7 +115,7 @@ public class MetadataInjector {
//create a new UserBox, which actually contains the Metadata bytes //create a new UserBox, which actually contains the Metadata bytes
UserBox metadataBox = new UserBox(new byte[0]); UserBox metadataBox = new UserBox(new byte[0]);
metadataBox.setData(metadata); metadataBox.setData(bytes);
//add the Metadata UserBox to the Movie Track //add the Metadata UserBox to the Movie Track
trackBox.addBox(metadataBox); trackBox.addBox(metadataBox);
@@ -97,7 +132,7 @@ public class MetadataInjector {
videoFileOutputStream = new FileOutputStream(videoFile); videoFileOutputStream = new FileOutputStream(videoFile);
tempIsoFile.getBox(videoFileOutputStream.getChannel()); tempIsoFile.getBox(videoFileOutputStream.getChannel());
} catch(Exception e) { } catch(Exception e) {
LOGGER.error("360 Degree Metadata couldn't be injected", e); LOGGER.error("Spherical Metadata couldn't be injected", e);
} finally { } finally {
IOUtils.closeQuietly(tempIsoFile); IOUtils.closeQuietly(tempIsoFile);
IOUtils.closeQuietly(videoFileOutputStream); IOUtils.closeQuietly(videoFileOutputStream);

View File

@@ -3,6 +3,7 @@ package com.replaymod.render.processor;
import com.replaymod.render.frame.CubicOpenGlFrame; import com.replaymod.render.frame.CubicOpenGlFrame;
import com.replaymod.render.frame.RGBFrame; import com.replaymod.render.frame.RGBFrame;
import com.replaymod.render.utils.ByteBufferPool; import com.replaymod.render.utils.ByteBufferPool;
import lombok.Getter;
import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.Validate;
import org.lwjgl.util.Dimension; import org.lwjgl.util.Dimension;
@@ -18,6 +19,7 @@ public class EquirectangularToRGBProcessor extends AbstractFrameProcessor<CubicO
private static final byte IMAGE_TOP = 4; private static final byte IMAGE_TOP = 4;
private static final byte IMAGE_BOTTOM = 5; private static final byte IMAGE_BOTTOM = 5;
@Getter
private final int frameSize; private final int frameSize;
private final int width; private final int width;
private final int height; private final int height;
@@ -26,18 +28,36 @@ public class EquirectangularToRGBProcessor extends AbstractFrameProcessor<CubicO
private final int[][] imageX; private final int[][] imageX;
private final int[][] imageY; private final int[][] imageY;
public EquirectangularToRGBProcessor(int outputWidth, int outputHeight, int sphericalFovX) {
// calculate the dimensions of the original equirectangular projection
// (before cropping according to FOV)
width = outputWidth;
height = outputHeight;
public EquirectangularToRGBProcessor(int frameSize) { int fullWidth;
this.frameSize = frameSize; if (sphericalFovX < 360) {
fullWidth = Math.round(width * 360 / (float) sphericalFovX);
} else {
fullWidth = width;
}
int fullHeight = fullWidth / 2;
frameSize = fullWidth / 4;
width = frameSize * 4;
height = frameSize * 2;
image = new byte[height][width]; image = new byte[height][width];
imageX = new int[height][width]; imageX = new int[height][width];
imageY = new int[height][width]; imageY = new int[height][width];
for (int i = 0; i < width; i++) {
double yaw = PI * 2 * i / width; int xOffset = (fullWidth - width) / 2;
int piQuarter = 8 * i / width - 4; int yOffset = (fullHeight - height) / 2;
for (int x = 0; x < width; x++) {
// get x position relative to the full projection
int i = xOffset + x;
double yaw = PI * 2 * i / fullWidth;
int piQuarter = 8 * i / fullWidth - 4;
byte target; byte target;
if (piQuarter < -3) { if (piQuarter < -3) {
target = IMAGE_BACK; target = IMAGE_BACK;
@@ -50,13 +70,16 @@ public class EquirectangularToRGBProcessor extends AbstractFrameProcessor<CubicO
} else { } else {
target = IMAGE_BACK; target = IMAGE_BACK;
} }
double fYaw = (yaw + PI/4) % (PI / 2) - PI/4; double fYaw = (yaw + PI / 4) % (PI / 2) - PI / 4;
double d = 1 / Math.cos(fYaw); double d = 1 / Math.cos(fYaw);
double gcXN = (Math.tan(fYaw) + 1) / 2; double gcXN = (Math.tan(fYaw) + 1) / 2;
for (int j = 0; j < height; j++) { for (int y = 0; y < height; y++) {
// get y position relative to the full projection
int j = yOffset + y;
double cXN = gcXN; double cXN = gcXN;
byte pt = target; byte pt = target;
double pitch = PI * j / height - PI / 2; double pitch = PI * j / fullHeight - PI / 2;
double cYN = (Math.tan(pitch) * d + 1) / 2; double cYN = (Math.tan(pitch) * d + 1) / 2;
if (cYN >= 1) { if (cYN >= 1) {
@@ -74,9 +97,9 @@ public class EquirectangularToRGBProcessor extends AbstractFrameProcessor<CubicO
int imgX = (int) Math.min(frameSize - 1, (cXN * frameSize)); int imgX = (int) Math.min(frameSize - 1, (cXN * frameSize));
int imgY = (int) Math.min(frameSize - 1, (cYN * frameSize)); int imgY = (int) Math.min(frameSize - 1, (cYN * frameSize));
image[j][i] = pt; image[y][x] = pt;
imageX[j][i] = imgX; imageX[y][x] = imgX;
imageY[j][i] = frameSize - imgY - 1; // The OpenGl buffer contains data flipped vertically imageY[y][x] = frameSize - imgY - 1; // The OpenGl buffer contains data flipped vertically
} }
} }
} }

View File

@@ -12,8 +12,8 @@ import java.nio.ByteBuffer;
public class ODSToRGBProcessor extends AbstractFrameProcessor<ODSOpenGlFrame, RGBFrame> { public class ODSToRGBProcessor extends AbstractFrameProcessor<ODSOpenGlFrame, RGBFrame> {
private final EquirectangularToRGBProcessor processor; private final EquirectangularToRGBProcessor processor;
public ODSToRGBProcessor(int frameSize) { public ODSToRGBProcessor(int outputWidth, int outputHeight, int sphericalFovX) {
processor = new EquirectangularToRGBProcessor(frameSize); processor = new EquirectangularToRGBProcessor(outputWidth, outputHeight / 2, sphericalFovX);
} }
@Override @Override
@@ -34,4 +34,8 @@ public class ODSToRGBProcessor extends AbstractFrameProcessor<ODSOpenGlFrame, RG
public void close() throws IOException { public void close() throws IOException {
processor.close(); processor.close();
} }
public int getFrameSize() {
return processor.getFrameSize();
}
} }

View File

@@ -61,19 +61,27 @@ public class Pipelines {
public static Pipeline<CubicOpenGlFrame, RGBFrame> newEquirectangularPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) { public static Pipeline<CubicOpenGlFrame, RGBFrame> newEquirectangularPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
RenderSettings settings = renderInfo.getRenderSettings(); RenderSettings settings = renderInfo.getRenderSettings();
EquirectangularToRGBProcessor processor = new EquirectangularToRGBProcessor(settings.getVideoWidth(),
settings.getVideoHeight(), settings.getSphericalFovX());
FrameCapturer<CubicOpenGlFrame> capturer; FrameCapturer<CubicOpenGlFrame> capturer;
if (PixelBufferObject.SUPPORTED) { 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 { } 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<ODSOpenGlFrame, RGBFrame> newODSPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) { public static Pipeline<ODSOpenGlFrame, RGBFrame> newODSPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
RenderSettings settings = renderInfo.getRenderSettings(); RenderSettings settings = renderInfo.getRenderSettings();
ODSToRGBProcessor processor = new ODSToRGBProcessor(settings.getVideoWidth(),
settings.getVideoHeight(), settings.getSphericalFovX());
FrameCapturer<ODSOpenGlFrame> capturer = FrameCapturer<ODSOpenGlFrame> capturer =
new ODSFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, settings.getVideoWidth() / 4); new ODSFrameCapturer(new EntityRendererHandler(settings, renderInfo), renderInfo, processor.getFrameSize());
return new Pipeline<>(capturer, new ODSToRGBProcessor(settings.getVideoWidth() / 4), consumer); return new Pipeline<>(capturer, processor, consumer);
} }
} }

View File

@@ -22,6 +22,7 @@ import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.ScaledResolution; import net.minecraft.client.gui.ScaledResolution;
import net.minecraft.client.renderer.OpenGlHelper; import net.minecraft.client.renderer.OpenGlHelper;
import net.minecraft.client.shader.Framebuffer; import net.minecraft.client.shader.Framebuffer;
import net.minecraft.util.ReportedException;
import net.minecraft.util.Timer; import net.minecraft.util.Timer;
import org.lwjgl.input.Mouse; import org.lwjgl.input.Mouse;
import org.lwjgl.opengl.Display; import org.lwjgl.opengl.Display;
@@ -150,12 +151,14 @@ public class VideoRenderer implements RenderInfo {
renderingPipeline.run(); renderingPipeline.run();
if (settings.isInject360Metadata()) { if (mc.hasCrashed) {
if (settings.getRenderMethod() == RenderSettings.RenderMethod.ODS) { throw new ReportedException(mc.crashReporter);
MetadataInjector.injectODSMetadata(settings.getOutputFile()); }
} else {
MetadataInjector.inject360Metadata(settings.getOutputFile()); if (settings.isInjectSphericalMetadata()) {
} MetadataInjector.injectMetadata(settings.getRenderMethod(), settings.getOutputFile(),
settings.getTargetVideoWidth(), settings.getTargetVideoHeight(),
settings.getSphericalFovX(), settings.getSphericalFovY());
} }
finish(); finish();