From 3bf35a18e97b70735805758ac8e19a194867ee1c Mon Sep 17 00:00:00 2001 From: covers1624 Date: Sun, 12 May 2024 17:24:14 +0930 Subject: [PATCH] Add support for rendering items to webp, improve performance. --- .../lib/internal/ItemFileRenderer.java | 309 +++++++++++++----- .../client/RenderItemToFileCommand.java | 51 ++- 2 files changed, 260 insertions(+), 100 deletions(-) diff --git a/src/main/java/codechicken/lib/internal/ItemFileRenderer.java b/src/main/java/codechicken/lib/internal/ItemFileRenderer.java index cd861795..8aef2588 100644 --- a/src/main/java/codechicken/lib/internal/ItemFileRenderer.java +++ b/src/main/java/codechicken/lib/internal/ItemFileRenderer.java @@ -1,5 +1,6 @@ package codechicken.lib.internal; +import com.google.common.base.Suppliers; import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.platform.Lighting; import com.mojang.blaze3d.platform.NativeImage; @@ -7,28 +8,29 @@ import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.VertexSorting; import net.covers1624.quack.image.AnimatedGifEncoder; -import net.covers1624.quack.io.IOUtils; -import net.covers1624.quack.util.SneakyUtils; +import net.covers1624.quack.platform.OperatingSystem; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.world.item.ItemStack; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; import org.joml.Matrix4f; import org.lwjgl.opengl.GL11; import javax.imageio.ImageIO; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.OutputStream; +import java.io.*; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.function.Supplier; /** * Mostly internal, intended for developer use. @@ -43,144 +45,281 @@ public class ItemFileRenderer { private static final Logger LOGGER = LogManager.getLogger(); private static final LinkedList tasks = new LinkedList<>(); - private static final List gifTasks = new LinkedList<>(); + private static final Path OUTPUT = Paths.get("exports"); + private static @Nullable RenderTarget target; - public static void addRenderTask(ItemStack stack, Path file, int resolution) { - tasks.add(new RenderTask(stack, file, resolution)); + public static void renderStatic(ItemStack stack, String file, int resolution) { + tasks.add(new StaticRenderTask(stack, resolution, OUTPUT.resolve(file))); } - public static void addGifRenderTask(ItemStack stack, Path file, int resolution, int fps, int duration) { - gifTasks.add(new GifRenderTask(stack, file, resolution, fps, duration)); + public static void addGifRenderTask(ItemStack stack, String file, int resolution, int fps, int duration) { + tasks.add(new GifRenderTask(stack, resolution, OUTPUT.resolve(file), fps, duration)); } - public static void tick() { - renderStackToFile(); - renderGifs(); - } + public static boolean addWebpRenderTask(ItemStack stack, String file, int resolution, int fps, int duration) { + if (!WebpRenderTask.isFfmpegAvailable()) return false; - private static void renderStackToFile() { - if (tasks.isEmpty()) return; + tasks.add(new WebpRenderTask(stack, resolution, OUTPUT.resolve(file), fps, duration)); + return true; + } - RenderTask task = tasks.pop(); - takeItemScreenshot(task.stack, task.resolution, image -> { - try { - image.writeToFile(IOUtils.makeParents(task.file)); - } catch (IOException ex) { - LOGGER.error("Failed to write image to file.", ex); - } - }); + public static void tick() { + RenderTask task = tasks.peek(); + if (task != null && guardRender(task)) { + tasks.pop(); + } } - private static void renderGifs() { - gifTasks.removeIf(GifRenderTask::render); + private static boolean guardRender(RenderTask task) { + try { + return task.render(); + } catch (Throwable ex) { + LOGGER.error("Failed to render.", ex); + return true; + } } - private static void takeItemScreenshot(ItemStack stack, int res, Consumer cons) { - Minecraft mc = Minecraft.getInstance(); - RenderTarget mainTarget = mc.getMainRenderTarget(); - PoseStack pStack = RenderSystem.getModelViewStack(); - GuiGraphics guiGraphics = new GuiGraphics(mc, mc.renderBuffers().bufferSource()); + private static abstract class RenderTask { + + private static final boolean DEBUG = Boolean.getBoolean("ccl.ItemFileRenderer.debug"); + + protected final ItemStack stack; - if (mainTarget.width < res || mainTarget.height < res) { - // TODO explode instead? - LOGGER.warn("Window is not at least 512x512 make it bigger! Your image is probably cropped a bit."); + protected final int resolution; + protected final Path path; + + private RenderTask(ItemStack stack, int resolution, Path path) { + this.stack = stack; + this.resolution = resolution; + this.path = path; } - Matrix4f ortho = new Matrix4f().setOrtho(0, mainTarget.width * 16F / res, mainTarget.height * 16F / res, 0, -3000, 3000); - RenderSystem.setProjectionMatrix(ortho, VertexSorting.ORTHOGRAPHIC_Z); + /** + * Perform the render operation. + * + * @return If the render operation is complete. + */ + protected abstract boolean render() throws IOException; - pStack.pushPose(); - pStack.setIdentity(); - RenderSystem.applyModelViewMatrix(); + protected NativeImage takeItemScreenshot() { + long start = System.nanoTime(); + Minecraft mc = Minecraft.getInstance(); + PoseStack pStack = RenderSystem.getModelViewStack(); + GuiGraphics guiGraphics = new GuiGraphics(mc, mc.renderBuffers().bufferSource()); - mainTarget.bindWrite(true); - GL11.glClearColor(0, 0, 0, 0); - GL11.glClearDepth(1.0); - GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + if (target == null || target.width < resolution || target.height < resolution) { + if (target == null) { + target = new RenderTarget(true) { }; + } + target.resize(resolution, resolution, Minecraft.ON_OSX); + } - Lighting.setupFor3DItems(); - RenderSystem.enableCull(); + Matrix4f ortho = new Matrix4f().setOrtho(0, resolution * 16F / resolution, resolution * 16F / resolution, 0, -3000, 3000); + RenderSystem.setProjectionMatrix(ortho, VertexSorting.ORTHOGRAPHIC_Z); - guiGraphics.renderItem(stack, 0, 0); + pStack.pushPose(); + pStack.setIdentity(); + RenderSystem.applyModelViewMatrix(); + target.bindWrite(true); - try (NativeImage fullScreenshot = new NativeImage(mainTarget.width, mainTarget.height, false); - NativeImage subImage = new NativeImage(res, res, false)) { - RenderSystem.bindTexture(mainTarget.getColorTextureId()); - fullScreenshot.downloadTexture(0, false); - fullScreenshot.flipY(); - fullScreenshot.resizeSubRectTo(0, 0, res, res, subImage); - cons.accept(subImage); - } + GL11.glClearColor(0, 0, 0, 0); + GL11.glClearDepth(1.0); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + + Lighting.setupFor3DItems(); + RenderSystem.enableCull(); + long preRender = System.nanoTime(); + if (DEBUG) LOGGER.info("Setup: {}ns", preRender - start); + + guiGraphics.renderItem(stack, 0, 0); + + long postRender = System.nanoTime(); + if (DEBUG) LOGGER.info("Render: {}ns", postRender - preRender); + + NativeImage image = new NativeImage(resolution, resolution, false); + target.bindRead(); + image.downloadTexture(0, false); + image.flipY(); + if (DEBUG) LOGGER.info("Screenshot: {}ns", System.nanoTime() - postRender); + + pStack.popPose(); + RenderSystem.applyModelViewMatrix(); - pStack.popPose(); - RenderSystem.applyModelViewMatrix(); - mainTarget.unbindWrite(); + target.unbindWrite(); + return image; + } } - private record RenderTask(ItemStack stack, Path file, int resolution) { + private static class StaticRenderTask extends RenderTask { + + private StaticRenderTask(ItemStack stack, int resolution, Path path) { + super(stack, resolution, path); + } + + @Override + protected boolean render() throws IOException { + try (NativeImage image = takeItemScreenshot()) { + image.flipY(); + image.writeToFile(path); + } + return true; + } } - private static final class GifRenderTask { + private static abstract class AnimatedRenderTask extends RenderTask { - public final ItemStack stack; - public final Path file; - private final int resolution; - public final int fps; - public final long targetDuration; + protected final int fps; + protected final long targetDuration; - public final List frames; - public final long frameDelay; + protected final List frames; + protected final long frameDelay; - public long startTime = -1; - public long lastFrame; + protected long startTime = -1; + protected long lastFrame; - private GifRenderTask(ItemStack stack, Path file, int resolution, int fps, int targetDuration) { - this.stack = stack; - this.file = file; - this.resolution = resolution; + private @Nullable CompletableFuture finalizeTask; + + private AnimatedRenderTask(ItemStack stack, int resolution, Path path, int fps, int duration) { + super(stack, resolution, path); this.fps = fps; - this.targetDuration = TimeUnit.SECONDS.toMillis(targetDuration); + this.targetDuration = TimeUnit.SECONDS.toMillis(duration); - frames = new ArrayList<>(targetDuration * fps); + frames = new ArrayList<>(duration * fps); frameDelay = (long) ((1F / fps) * 1000F); } - public boolean render() { + @Override + protected boolean render() { long currTime = System.currentTimeMillis(); if (startTime == -1) { startTime = currTime; } if (startTime + targetDuration <= currTime) { - CompletableFuture.runAsync(this::finishGif); - return true; + if (finalizeTask == null) { + finalizeTask = CompletableFuture.runAsync(() -> { + try { + serialize(); + } catch (Throwable ex) { + LOGGER.error("Failed to serialize item render task.", ex); + } + }); + } + return finalizeTask.isDone(); } // Wait more for next frame. if (lastFrame + frameDelay > currTime) return false; lastFrame = currTime; - takeItemScreenshot(stack, resolution, SneakyUtils.sneak(e -> frames.add(e.asByteArray()))); - LOGGER.info("Captured gif frame {} / {}", frames.size(), ((targetDuration / 1000) * fps)); + frames.add(takeItemScreenshot()); + LOGGER.info("Captured frame {} / {}", frames.size(), ((targetDuration / 1000) * fps)); return false; } - private void finishGif() { - LOGGER.info("Writing gif.."); - try (OutputStream os = Files.newOutputStream(file)) { + protected abstract void serialize() throws IOException; + } + + private static class GifRenderTask extends AnimatedRenderTask { + + private GifRenderTask(ItemStack stack, int resolution, Path path, int fps, int duration) { + super(stack, resolution, path, fps, duration); + } + + @Override + protected void serialize() throws IOException { + try (OutputStream os = Files.newOutputStream(path)) { AnimatedGifEncoder encoder = new AnimatedGifEncoder(); encoder.start(os); encoder.setDelay((int) frameDelay); encoder.setRepeat(0); // Always repeat. encoder.setQuality(1); // Best quality possible. for (int i = 0; i < frames.size(); i++) { - byte[] frame = frames.get(i); LOGGER.info("Encoding Frame {} / {}", i + 1, frames.size()); - encoder.addFrame(ImageIO.read(new ByteArrayInputStream(frame))); + NativeImage frame = frames.get(i); + try (frame) { + encoder.addFrame(ImageIO.read(new ByteArrayInputStream(frame.asByteArray()))); + } } encoder.finish(); LOGGER.info("Finished writing gif."); + } + } + } + + private static class WebpRenderTask extends AnimatedRenderTask { + + public static final Supplier<@Nullable String> FFMPEG_BINARY = Suppliers.memoize(() -> { + String ffmpeg = System.getProperty("ccl.ffmpeg", "ffmpeg" + (OperatingSystem.current().isWindows() ? ".exe" : "")); + LOGGER.info("Probing for ffmpeg with {}", ffmpeg); + try { + int ret = runFfmpeg(List.of(ffmpeg, "-version"), LOGGER::info); + if (ret == 0) { + LOGGER.info("ffmpeg available via {}", ffmpeg); + return ffmpeg; + } + LOGGER.error("Failed to find working ffmpeg. Got exit code: {}", ret); } catch (IOException ex) { - LOGGER.error("Failed to write gif.", ex); + LOGGER.error("Failed to find ffmpeg. Exception whilst running command.", ex); + } + return null; + }); + + public static boolean isFfmpegAvailable() { + return FFMPEG_BINARY.get() != null; + } + + private WebpRenderTask(ItemStack stack, int resolution, Path path, int fps, int duration) { + super(stack, resolution, path, fps, duration); + } + + @Override + protected void serialize() throws IOException { + Path tempDir = path.getParent().resolve(path.getFileName() + ".tmp"); + Files.createDirectory(tempDir); + LOGGER.info("Dumping frames to temp directory.."); + int i = 0; + List tempFiles = new ArrayList<>(frames.size() + 1); + for (NativeImage frame : frames) { + try (frame) { + Path file = tempDir.resolve(i++ + ".png"); + tempFiles.add(file); + frame.writeToFile(file); + } + } + tempFiles.add(tempDir); + + int ret = runFfmpeg( + List.of( + FFMPEG_BINARY.get(), + "-y", + "-framerate", String.valueOf(fps), + "-i", tempDir.toAbsolutePath() + "/%d.png", + "-loop", "0", + "-lossless", "1", + "-compression_level", "0", + path.toAbsolutePath().toString() + ), + LOGGER::info + ); + if (ret != 0) { + LOGGER.error("ffmpeg exited with exit code {}", ret); + } else { + LOGGER.info("ffmpeg success!"); + } + + for (Path tempFile : tempFiles) { + Files.deleteIfExists(tempFile); + } + } + + private static int runFfmpeg(List args, Consumer out) throws IOException { + ProcessBuilder pb = new ProcessBuilder(args); + pb.redirectErrorStream(true); + Process proc = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) { + reader.lines().forEach(out); } + proc.onExit().join(); + return proc.exitValue(); } } } diff --git a/src/main/java/codechicken/lib/internal/command/client/RenderItemToFileCommand.java b/src/main/java/codechicken/lib/internal/command/client/RenderItemToFileCommand.java index b08921fd..9a9a747b 100644 --- a/src/main/java/codechicken/lib/internal/command/client/RenderItemToFileCommand.java +++ b/src/main/java/codechicken/lib/internal/command/client/RenderItemToFileCommand.java @@ -13,9 +13,6 @@ import net.minecraft.world.InteractionHand; import net.minecraft.world.item.ItemStack; -import java.nio.file.Path; -import java.nio.file.Paths; - import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; @@ -26,7 +23,7 @@ public class RenderItemToFileCommand { public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("ccl") - .then(literal("render_held_to_file") + .then(literal("render_held") .then(argument("resolution", IntegerArgumentType.integer(16)) .then(argument("name", StringArgumentType.greedyString()) .executes(e -> renderToFile(e, getResolution(e))) @@ -36,16 +33,16 @@ public static void register(CommandDispatcher dispatcher) { .executes(e -> renderToFile(e, ItemFileRenderer.DEFAULT_RES)) ) ) - .then(literal("render_held_to_gif") + .then(literal("render_held_anim") .then(argument("fps", IntegerArgumentType.integer(5, 75)) .then(argument("duration", IntegerArgumentType.integer()) .then(argument("resolution", IntegerArgumentType.integer(16)) .then(argument("name", StringArgumentType.greedyString()) - .executes(e -> renderToGif(e, getResolution(e))) + .executes(e -> renderAnim(e, getResolution(e))) ) ) .then(argument("name", StringArgumentType.greedyString()) - .executes(e -> renderToGif(e, ItemFileRenderer.DEFAULT_RES)) + .executes(e -> renderAnim(e, ItemFileRenderer.DEFAULT_RES)) ) ) ) @@ -54,33 +51,57 @@ public static void register(CommandDispatcher dispatcher) { } private static int renderToFile(CommandContext ctx, int resolution) { - Path path = getPath(ctx, "png"); + String path = getPath(ctx); ItemStack held = getHeldItem(); ctx.getSource().sendSuccess(() -> Component.literal("Queued item render to file: " + path), false); - ItemFileRenderer.addRenderTask(held, path, resolution); + ItemFileRenderer.renderStatic(held, path, resolution); return 0; } - private static int renderToGif(CommandContext ctx, int resolution) { + private static int renderAnim(CommandContext ctx, int resolution) { CommandSourceStack src = ctx.getSource(); int fps = IntegerArgumentType.getInteger(ctx, "fps"); int duration = IntegerArgumentType.getInteger(ctx, "duration"); - Path path = getPath(ctx, "gif"); + String path = getPath(ctx); ItemStack held = getHeldItem(); - src.sendSuccess(() -> Component.literal("Queued item render to gif: " + path), false); - ItemFileRenderer.addGifRenderTask(held, path, resolution, fps, duration); + int lastSlash = path.lastIndexOf('/'); + int lastDot = path.lastIndexOf('.', lastSlash != -1 ? lastSlash : path.length() - 1); + String ext = lastDot != -1 ? path.substring(lastDot + 1) : ""; + + String finalPath; + if (ext.isEmpty()) { + ext = "gif"; + finalPath = path + ".gif"; + } else { + finalPath = path; + } + + switch (ext) { + case "gif" -> { + ItemFileRenderer.addGifRenderTask(held, finalPath, resolution, fps, duration); + src.sendSuccess(() -> Component.literal("Queued item render to gif: " + finalPath), false); + } + case "webp" -> { + if (!ItemFileRenderer.addWebpRenderTask(held, path, resolution, fps, duration)) { + src.sendFailure(Component.literal("Failed to queue render for webp, ffmpeg is not accessible. Either make it accessible on PATH, or set the `ccl.ffmpeg` system property.")); + } else { + src.sendSuccess(() -> Component.literal("Queued item render to webp: " + path), false); + } + } + } + return 0; } - private static Path getPath(CommandContext ctx, String extension) { + private static String getPath(CommandContext ctx) { String str = StringArgumentType.getString(ctx, "name"); if (str.contains("..")) { throw new CommandRuntimeException(Component.literal("'..' is not allowed in name.")); } - return Paths.get("exports", str + "." + extension); + return str; } public static int getResolution(CommandContext ctx) {