Add com.googlecode.mp4parser:isoparser Library to build.gradle Create inject360Metadata boolean setting in RenderOptions and added respective checkbox to GuiRenderSettings Create MetadataInjector to inject 360 Degree Spherical Metadata into mp4 videos
399 lines
14 KiB
Java
399 lines
14 KiB
Java
package eu.crushedpixel.replaymod.video;
|
|
|
|
import eu.crushedpixel.replaymod.ReplayMod;
|
|
import eu.crushedpixel.replaymod.gui.GuiVideoRenderer;
|
|
import eu.crushedpixel.replaymod.holders.AdvancedPosition;
|
|
import eu.crushedpixel.replaymod.holders.Keyframe;
|
|
import eu.crushedpixel.replaymod.holders.SpectatorData;
|
|
import eu.crushedpixel.replaymod.holders.TimestampValue;
|
|
import eu.crushedpixel.replaymod.interpolation.KeyframeList;
|
|
import eu.crushedpixel.replaymod.renderer.ChunkLoadingRenderGlobal;
|
|
import eu.crushedpixel.replaymod.replay.ReplayHandler;
|
|
import eu.crushedpixel.replaymod.replay.ReplaySender;
|
|
import eu.crushedpixel.replaymod.settings.RenderOptions;
|
|
import eu.crushedpixel.replaymod.timer.EnchantmentTimer;
|
|
import eu.crushedpixel.replaymod.timer.ReplayTimer;
|
|
import eu.crushedpixel.replaymod.video.capturer.RenderInfo;
|
|
import eu.crushedpixel.replaymod.video.frame.RGBFrame;
|
|
import eu.crushedpixel.replaymod.video.metadata.MetadataInjector;
|
|
import eu.crushedpixel.replaymod.video.rendering.Pipeline;
|
|
import eu.crushedpixel.replaymod.video.rendering.Pipelines;
|
|
import net.minecraft.client.Minecraft;
|
|
import net.minecraft.client.gui.ScaledResolution;
|
|
import net.minecraft.util.Timer;
|
|
import org.lwjgl.input.Mouse;
|
|
import org.lwjgl.opengl.Display;
|
|
import org.lwjgl.util.Dimension;
|
|
import org.lwjgl.util.ReadableDimension;
|
|
|
|
import java.io.File;
|
|
import java.io.IOException;
|
|
import java.util.concurrent.FutureTask;
|
|
|
|
import static net.minecraft.client.renderer.GlStateManager.*;
|
|
import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT;
|
|
import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT;
|
|
|
|
public class VideoRenderer implements RenderInfo {
|
|
private final Minecraft mc = Minecraft.getMinecraft();
|
|
private final ReplaySender replaySender;
|
|
private final RenderOptions options;
|
|
private final VideoWriter videoWriter;
|
|
|
|
private int fps;
|
|
private boolean mouseWasGrabbed;
|
|
|
|
private ChunkLoadingRenderGlobal chunkLoadingRenderGlobal;
|
|
|
|
private int framesDone;
|
|
private int totalFrames;
|
|
|
|
private KeyframeList<AdvancedPosition> positionKeyframes;
|
|
private KeyframeList<TimestampValue> timeKeyframes;
|
|
|
|
private final GuiVideoRenderer gui;
|
|
private boolean paused;
|
|
private boolean cancelled;
|
|
|
|
private final Pipeline renderingPipeline;
|
|
|
|
public VideoRenderer(RenderOptions options) throws IOException {
|
|
this.gui = new GuiVideoRenderer(this);
|
|
this.replaySender = ReplayMod.replaySender;
|
|
this.options = options;
|
|
this.renderingPipeline = Pipelines.newPipeline(options.getMode(), this, videoWriter = new VideoWriter(options) {
|
|
@Override
|
|
public void consume(RGBFrame frame) {
|
|
gui.updatePreview(frame);
|
|
super.consume(frame);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render this video.
|
|
* @return {@code true} if rendering was successful, {@code false} if the user aborted rendering (or the window was closed)
|
|
*/
|
|
public boolean renderVideo() {
|
|
setup();
|
|
|
|
// Because this might take some time to prepare we'll render the GUI at least once to not confuse the user
|
|
drawGui();
|
|
|
|
Timer timer = mc.timer;
|
|
|
|
// Play up to one second before starting to render
|
|
// This is necessary in order to ensure that all entities have at least two position packets
|
|
// and their first position in the recording is correct.
|
|
// Note that it is impossible to also get the interpolation between their latest position
|
|
// and the one in the recording correct as there's no reliable way to tell when the server ticks
|
|
// or when we should be done with the interpolation of the entity
|
|
int videoStart = timeKeyframes.getInterpolatedValueForPathPosition(0, true).asInt();
|
|
|
|
if (videoStart > 1000) {
|
|
int replayTime = videoStart - 1000;
|
|
timer.elapsedPartialTicks = timer.renderPartialTicks = 0;
|
|
timer.timerSpeed = 1;
|
|
while (replayTime < videoStart) {
|
|
timer.elapsedTicks = 1;
|
|
replayTime += 50;
|
|
replaySender.sendPacketsTill(replayTime);
|
|
tick();
|
|
}
|
|
}
|
|
|
|
mc.renderGlobal.renderEntitiesStartupCounter = 0;
|
|
|
|
renderingPipeline.run();
|
|
|
|
if(options.isInject360Metadata()) {
|
|
File outputFile = options.getOutputFile();
|
|
MetadataInjector.inject360Metadata(new File(outputFile.getParentFile(), outputFile.getName() + ".mp4"));
|
|
}
|
|
|
|
finish();
|
|
return !cancelled;
|
|
}
|
|
|
|
@Override
|
|
public float updateForNextFrame() {
|
|
if (!options.isHighPerformance() || framesDone % fps == 0) {
|
|
drawGui();
|
|
}
|
|
|
|
updateTime(mc.timer, framesDone);
|
|
|
|
int elapsedTicks = mc.timer.elapsedTicks;
|
|
while (elapsedTicks-- > 0) {
|
|
tick();
|
|
}
|
|
|
|
updateCam();
|
|
framesDone++;
|
|
return mc.timer.renderPartialTicks;
|
|
}
|
|
|
|
@Override
|
|
public RenderOptions getRenderOptions() {
|
|
return options;
|
|
}
|
|
|
|
private void setup() {
|
|
if (Mouse.isGrabbed()) {
|
|
mouseWasGrabbed = true;
|
|
}
|
|
Mouse.setGrabbed(false);
|
|
ReplayTimer.get(mc).passive = true;
|
|
mc.timer.timerSpeed = 1;
|
|
|
|
fps = options.getFps();
|
|
|
|
positionKeyframes = ReplayHandler.getPositionKeyframes();
|
|
timeKeyframes = ReplayHandler.getTimeKeyframes();
|
|
|
|
int duration = Math.max(timeKeyframes.last().getRealTimestamp(), positionKeyframes.last().getRealTimestamp());
|
|
|
|
totalFrames = duration*fps/1000;
|
|
|
|
ScaledResolution scaled = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
|
|
gui.setWorldAndResolution(mc, scaled.getScaledWidth(), scaled.getScaledHeight());
|
|
|
|
if (options.isWaitForChunks()) {
|
|
chunkLoadingRenderGlobal = new ChunkLoadingRenderGlobal(mc.renderGlobal);
|
|
}
|
|
}
|
|
|
|
private void finish() {
|
|
if (mouseWasGrabbed) {
|
|
mc.mouseHelper.grabMouseCursor();
|
|
}
|
|
ReplayTimer.get(mc).passive = false;
|
|
mc.displayGuiScreen(null);
|
|
if (chunkLoadingRenderGlobal != null) {
|
|
chunkLoadingRenderGlobal.uninstall();
|
|
}
|
|
|
|
ReplayMod.soundHandler.playRenderSuccessSound();
|
|
|
|
mc.displayGuiScreen(null);
|
|
}
|
|
|
|
private void updateCam() {
|
|
KeyframeList<AdvancedPosition> positionKeyframes = ReplayHandler.getPositionKeyframes();
|
|
|
|
if (mc.thePlayer == null) {
|
|
return; // Not ready yet
|
|
}
|
|
int videoTime = framesDone * 1000 / fps;
|
|
int posCount = ReplayHandler.getPositionKeyframes().size();
|
|
|
|
AdvancedPosition pos = new AdvancedPosition();
|
|
Keyframe<AdvancedPosition> lastPos = positionKeyframes.getPreviousKeyframe(videoTime, true);
|
|
Keyframe<AdvancedPosition> nextPos = null;
|
|
|
|
// Position interpolation
|
|
nextPos = positionKeyframes.getNextKeyframe(videoTime, true);
|
|
|
|
int lastPosStamp = lastPos.getRealTimestamp();
|
|
int nextPosStamp = (nextPos == null ? lastPos : nextPos).getRealTimestamp();
|
|
|
|
int diffLength = nextPosStamp - lastPosStamp;
|
|
float diffPct = (float) (videoTime - lastPosStamp) / diffLength;
|
|
if(Float.isInfinite(diffPct) || Float.isNaN(diffPct)) diffPct = 0;
|
|
|
|
float totalPct = (positionKeyframes.indexOf(lastPos) + diffPct) / (posCount-1);
|
|
pos = positionKeyframes.getInterpolatedValueForPathPosition(Math.max(0, Math.min(1, totalPct)), ReplayMod.replaySettings.isLinearMovement());
|
|
|
|
boolean spectateCamera = true;
|
|
|
|
//check whether between two First Person Spectator Keyframes
|
|
if(lastPos != null && nextPos != null) {
|
|
if(lastPos.getValue() instanceof SpectatorData && nextPos.getValue() instanceof SpectatorData) {
|
|
SpectatorData previousSpectatorData = (SpectatorData)lastPos.getValue();
|
|
SpectatorData nextSpectatorData = (SpectatorData)nextPos.getValue();
|
|
if(previousSpectatorData.getSpectatedEntityID() == nextSpectatorData.getSpectatedEntityID()) {
|
|
if(previousSpectatorData.getSpectatingMethod() == SpectatorData.SpectatingMethod.FIRST_PERSON
|
|
&& nextSpectatorData.getSpectatingMethod() == SpectatorData.SpectatingMethod.FIRST_PERSON) {
|
|
spectateCamera = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if(spectateCamera) {
|
|
// Make sure we're spectating the camera entity
|
|
// We do not use .spectateCamera() as that method sets the position of the camera to the previous
|
|
// entity, overriding our calculations
|
|
ReplayHandler.spectateEntity(ReplayHandler.getCameraEntity());
|
|
|
|
if(pos != null) {
|
|
ReplayHandler.setCameraTilt((float)pos.getRoll());
|
|
ReplayHandler.getCameraEntity().movePath(pos);
|
|
mc.entityRenderer.fovModifierHand = mc.entityRenderer.fovModifierHandPrev = 1;
|
|
}
|
|
} else {
|
|
ReplayHandler.spectateEntity(mc.theWorld.getEntityByID(((SpectatorData)lastPos.getValue()).getSpectatedEntityID()));
|
|
}
|
|
}
|
|
|
|
private void updateTime(Timer timer, int framesDone) {
|
|
KeyframeList<TimestampValue> timeKeyframes = ReplayHandler.getTimeKeyframes();
|
|
|
|
int videoTime = framesDone * 1000 / fps;
|
|
int timeCount = timeKeyframes.size();
|
|
|
|
// WARNING: The rest of this method contains some magic for which Marius is responsible
|
|
// Time interpolation
|
|
Keyframe<TimestampValue> lastTime = timeKeyframes.getPreviousKeyframe(videoTime, true);
|
|
Keyframe<TimestampValue> nextTime = timeKeyframes.getNextKeyframe(videoTime, true);
|
|
|
|
int lastTimeStamp = 0;
|
|
int nextTimeStamp = 0;
|
|
|
|
double curSpeed = 0;
|
|
|
|
if(nextTime != null || lastTime != null) {
|
|
if(nextTime != null && lastTime != null && nextTime.getRealTimestamp() == lastTime.getRealTimestamp()) {
|
|
curSpeed = 0;
|
|
} else {
|
|
if(nextTime != null) {
|
|
nextTimeStamp = nextTime.getRealTimestamp();
|
|
} else {
|
|
nextTimeStamp = lastTime.getRealTimestamp();
|
|
}
|
|
|
|
if(lastTime != null) {
|
|
lastTimeStamp = lastTime.getRealTimestamp();
|
|
} else {
|
|
lastTimeStamp = nextTime.getRealTimestamp();
|
|
}
|
|
|
|
if(!(nextTime == null || lastTime == null)) {
|
|
curSpeed = ((double)(((int)nextTime.getValue().value-(int)lastTime.getValue().value)))/((double)((nextTimeStamp-lastTimeStamp)));
|
|
}
|
|
|
|
if(lastTimeStamp == nextTimeStamp) {
|
|
curSpeed = 0f;
|
|
}
|
|
}
|
|
}
|
|
|
|
int currentTimeDiff = nextTimeStamp - lastTimeStamp;
|
|
int currentTime = videoTime - lastTimeStamp;
|
|
|
|
float currentTimeStepPerc = (float)currentTime/(float)currentTimeDiff; //The percentage of the travelled path between the current timestamps
|
|
if(Float.isInfinite(currentTimeStepPerc) || Float.isNaN(currentTimeStepPerc)) currentTimeStepPerc = 0;
|
|
|
|
float timePos = (timeKeyframes.indexOf(lastTime) + currentTimeStepPerc) / (timeCount-1f);
|
|
|
|
Integer replayTime = timeKeyframes.getInterpolatedValueForPathPosition(Math.max(0, Math.min(1, timePos)), true).asInt();
|
|
|
|
replaySender.sendPacketsTill(replayTime);
|
|
|
|
if (curSpeed >= 0) {
|
|
replaySender.setReplaySpeed(curSpeed);
|
|
}
|
|
|
|
// Update Timer
|
|
EnchantmentTimer.increaseRecordingTime(1000 / fps);
|
|
|
|
timer.elapsedPartialTicks += timer.timerSpeed * 20 / fps;
|
|
timer.elapsedTicks = (int) timer.elapsedPartialTicks;
|
|
timer.elapsedPartialTicks -= timer.elapsedTicks;
|
|
timer.renderPartialTicks = timer.elapsedPartialTicks;
|
|
}
|
|
|
|
private void tick() {
|
|
synchronized (mc.scheduledTasks) {
|
|
while (!mc.scheduledTasks.isEmpty()) {
|
|
((FutureTask) mc.scheduledTasks.poll()).run();
|
|
}
|
|
}
|
|
|
|
mc.currentScreen = gui;
|
|
try {
|
|
mc.runTick();
|
|
} catch (IOException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
public void drawGui() {
|
|
do {
|
|
pushMatrix();
|
|
clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
|
enableTexture2D();
|
|
mc.getFramebuffer().bindFramebuffer(true);
|
|
mc.entityRenderer.setupOverlayRendering();
|
|
|
|
try {
|
|
gui.handleInput();
|
|
} catch (IOException e) {
|
|
// That's a strange exception from this kind of method O_o
|
|
// It isn't actually thrown here, so we'll deal with it the easy way
|
|
throw new RuntimeException(e);
|
|
}
|
|
|
|
ScaledResolution scaled = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
|
|
int mouseX = Mouse.getX() * scaled.getScaledWidth() / mc.displayWidth;
|
|
int mouseY = scaled.getScaledHeight() - Mouse.getY() * scaled.getScaledHeight() / mc.displayHeight - 1;
|
|
gui.drawScreen(mouseX, mouseY, 0);
|
|
|
|
mc.getFramebuffer().unbindFramebuffer();
|
|
popMatrix();
|
|
pushMatrix();
|
|
mc.getFramebuffer().framebufferRender(mc.displayWidth, mc.displayHeight);
|
|
popMatrix();
|
|
|
|
|
|
// if not in high performance mode, update the gui size if screen size changed
|
|
// otherwise just swap the progress gui to screen
|
|
if (options.isHighPerformance()) {
|
|
Display.update();
|
|
} else {
|
|
mc.updateDisplay();
|
|
}
|
|
if (Mouse.isGrabbed()) {
|
|
Mouse.setGrabbed(false);
|
|
}
|
|
if (paused) {
|
|
try {
|
|
Thread.sleep(50);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
return;
|
|
}
|
|
}
|
|
} while (paused);
|
|
}
|
|
|
|
public int getFramesDone() {
|
|
return framesDone;
|
|
}
|
|
|
|
@Override
|
|
public ReadableDimension getFrameSize() {
|
|
return new Dimension(options.getWidth(), options.getHeight());
|
|
}
|
|
|
|
public int getTotalFrames() {
|
|
return totalFrames;
|
|
}
|
|
|
|
public int getVideoTime() { return framesDone * 1000 / fps; }
|
|
|
|
public void setPaused(boolean paused) {
|
|
this.paused = paused;
|
|
}
|
|
|
|
public boolean isPaused() {
|
|
return paused;
|
|
}
|
|
|
|
public void cancel() {
|
|
videoWriter.abort();
|
|
this.cancelled = true;
|
|
renderingPipeline.cancel();
|
|
}
|
|
}
|