diff --git a/app/src/main/java/com/annimon/hotarufx/ui/Renderer.java b/app/src/main/java/com/annimon/hotarufx/ui/RenderPreparer.java similarity index 75% rename from app/src/main/java/com/annimon/hotarufx/ui/Renderer.java rename to app/src/main/java/com/annimon/hotarufx/ui/RenderPreparer.java index 38d13c3..75668f8 100644 --- a/app/src/main/java/com/annimon/hotarufx/ui/Renderer.java +++ b/app/src/main/java/com/annimon/hotarufx/ui/RenderPreparer.java @@ -10,17 +10,19 @@ import com.annimon.hotarufx.parser.visitors.InterpreterVisitor; import com.annimon.hotarufx.visual.Composition; import java.util.List; import java.util.function.BiConsumer; +import java.util.function.Function; +import javafx.scene.Scene; import javafx.stage.Modality; import javafx.stage.Stage; import lombok.val; -public class Renderer { +public class RenderPreparer { - public static Renderer init() { - return new Renderer(); + public static RenderPreparer init() { + return new RenderPreparer(); } - private Renderer() { + private RenderPreparer() { } public WithInput input(String input) { @@ -58,24 +60,30 @@ public class Renderer { public Evaluated evaluateWithBundles(List> bundles) { BundleLoader.load(context, bundles); - return evaluate(); + evaluate(); + return new Evaluated(this); } - private Evaluated evaluate() { + public EvaluatedForRender evaluateForRender() { + BundleLoader.load(context, BundleLoader.runtimeBundles()); + evaluate(); + return new EvaluatedForRender(this); + } + + private void evaluate() { val parser = new HotaruParser(HotaruLexer.tokenize(source.input)); val program = parser.parse(); if (parser.getParseErrors().hasErrors()) { throw new RendererException(parser.getParseErrors().toString()); } program.accept(new InterpreterVisitor(), context); - return new Evaluated(this); } } public class Evaluated { - private final WithContext source; + protected final WithContext source; private Evaluated(WithContext source) { this.source = source; @@ -87,10 +95,14 @@ public class Renderer { stage.initOwner(primaryStage); stage.initModality(Modality.WINDOW_MODAL); val composition = source.context.composition(); - stage.setScene(composition.produceAnimationScene()); + stage.setScene(sceneProvider().apply(composition)); return new WithStage(composition, stage); } + protected Function sceneProvider() { + return Composition::producePreviewScene; + } + private void checkCompositionExists() { if (source.context.composition() == null) { throw new RendererException("There is no composition.\n" + @@ -99,6 +111,18 @@ public class Renderer { } } + public class EvaluatedForRender extends Evaluated { + + private EvaluatedForRender(WithContext source) { + super(source); + } + + @Override + protected Function sceneProvider() { + return Composition::produceRendererScene; + } + } + public class WithStage { private final Composition composition; diff --git a/app/src/main/java/com/annimon/hotarufx/ui/controller/EditorController.java b/app/src/main/java/com/annimon/hotarufx/ui/controller/EditorController.java index f023c60..6635ec7 100644 --- a/app/src/main/java/com/annimon/hotarufx/ui/controller/EditorController.java +++ b/app/src/main/java/com/annimon/hotarufx/ui/controller/EditorController.java @@ -8,7 +8,7 @@ import com.annimon.hotarufx.io.DocumentManager; import com.annimon.hotarufx.io.FileManager; import com.annimon.hotarufx.io.IOStream; import com.annimon.hotarufx.lib.Context; -import com.annimon.hotarufx.ui.Renderer; +import com.annimon.hotarufx.ui.RenderPreparer; import com.annimon.hotarufx.ui.SyntaxHighlighter; import com.annimon.hotarufx.ui.control.LibraryItem; import java.io.IOException; @@ -30,6 +30,7 @@ import javafx.scene.control.CheckMenuItem; import javafx.scene.control.TextArea; import javafx.scene.control.TitledPane; import javafx.scene.layout.Pane; +import javafx.stage.DirectoryChooser; import javafx.stage.Modality; import javafx.stage.Stage; import javafx.util.Duration; @@ -37,6 +38,7 @@ import lombok.val; import org.fxmisc.richtext.CodeArea; import org.fxmisc.richtext.LineNumberFactory; +@SuppressWarnings("unused") public class EditorController implements Initializable, DocumentListener { @FXML @@ -128,7 +130,7 @@ public class EditorController implements Initializable, DocumentListener { return; } try { - Renderer.init() + RenderPreparer.init() .input(input) .context(new Context()) .evaluateWithRuntimeBundle() @@ -157,6 +159,54 @@ public class EditorController implements Initializable, DocumentListener { } } + @FXML + private void handleMenuRender(ActionEvent event) { + log.setText(""); + val input = editor.getText(); + if (input.isEmpty()) { + return; + } + try { + RenderPreparer.init() + .input(input) + .context(new Context()) + .evaluateForRender() + .prepareStage(primaryStage) + .peek((stage, composition) -> { + val chooser = new DirectoryChooser(); + chooser.setTitle("Choose directory for rendering frames"); + val directory = chooser.showDialog(primaryStage); + if (directory == null || !directory.exists() || !directory.isDirectory()) { + return; + } + + val fxTimeline = composition.getTimeline().getFxTimeline(); + stage.setOnShown(e -> { + fxTimeline.playFromStart(); + fxTimeline.pause(); + val task = new RenderTask(directory, composition, stage.getScene()); + task.messageProperty().addListener(ev -> { + stage.setTitle(task.getMessage()); + }); + task.setOnFailed(ev -> { + logError(Exceptions.stackTraceToString(ev.getSource().getException())); + logPane.setExpanded(true); + stage.close(); + }); + task.setOnSucceeded(ev -> stage.close()); + new Thread(task).start(); + }); + stage.show(); + }); + } catch (RendererException re) { + logError(re.getMessage()); + logPane.setExpanded(true); + } catch (RuntimeException e) { + logError(Exceptions.stackTraceToString(e)); + logPane.setExpanded(true); + } + } + @Override public void initialize(URL location, ResourceBundle resources) { editor.setParagraphGraphicFactory(LineNumberFactory.get(editor)); diff --git a/app/src/main/java/com/annimon/hotarufx/ui/controller/RenderTask.java b/app/src/main/java/com/annimon/hotarufx/ui/controller/RenderTask.java new file mode 100644 index 0000000..27cd86d --- /dev/null +++ b/app/src/main/java/com/annimon/hotarufx/ui/controller/RenderTask.java @@ -0,0 +1,71 @@ +package com.annimon.hotarufx.ui.controller; + +import com.annimon.hotarufx.visual.Composition; +import com.annimon.hotarufx.visual.TimeLine; +import java.io.File; +import java.util.concurrent.CountDownLatch; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.Scene; +import javafx.scene.image.WritableImage; +import javafx.util.Duration; +import javax.imageio.ImageIO; +import lombok.val; + +public class RenderTask extends Task { + + private final File directory; + private final Composition composition; + private final Scene scene; + private final TimeLine timeLine; + private final double frameRate; + + public RenderTask(File directory, Composition composition, Scene scene) { + this.directory = directory; + this.composition = composition; + this.scene = scene; + this.timeLine = composition.getTimeline(); + frameRate = timeLine.getFrameRate(); + } + + + @Override + protected Boolean call() throws Exception { + val fxTimeline = timeLine.getFxTimeline(); + + val totalFrames = toFrame(fxTimeline.getTotalDuration()); + int frame = 0; + while (frame < totalFrames) { + updateProgress(frame, totalFrames); + updateMessage(String.format("%d / %d", frame + 1, totalFrames)); + fxTimeline.jumpTo(toDuration(frame)); + + val image = newImage(); + + val latch = new CountDownLatch(1); + Platform.runLater(() -> { + scene.snapshot(image); + latch.countDown(); + }); + latch.await(); + + val file = new File(directory, String.format("frame_%05d.png", frame + 1)); + ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); + frame++; + } + return Boolean.TRUE; + } + + private WritableImage newImage() { + return new WritableImage(composition.getSceneWidth(), composition.getSceneHeight()); + } + + private int toFrame(Duration d) { + return (int) (d.toMillis() * frameRate / 1000d); + } + + private Duration toDuration(int frame) { + return Duration.millis(frame * 1000d / frameRate); + } +} diff --git a/app/src/main/java/com/annimon/hotarufx/visual/Composition.java b/app/src/main/java/com/annimon/hotarufx/visual/Composition.java index c8420a7..06a9caa 100644 --- a/app/src/main/java/com/annimon/hotarufx/visual/Composition.java +++ b/app/src/main/java/com/annimon/hotarufx/visual/Composition.java @@ -62,7 +62,7 @@ public class Composition { return new VirtualScene(group, virtualWidth, virtualHeight); } - public Scene produceAnimationScene() { + public Scene producePreviewScene() { val fxScene = new Scene(scene.getGroup(), sceneWidth, sceneHeight, background); fxScene.setOnKeyPressed(e -> { switch (e.getCode()) { @@ -89,4 +89,10 @@ public class Composition { }); return fxScene; } + + public Scene produceRendererScene() { + val fxScene = new Scene(scene.getGroup(), sceneWidth, sceneHeight, background); + + return fxScene; + } } diff --git a/app/src/main/resources/fxml/Editor.fxml b/app/src/main/resources/fxml/Editor.fxml index a4d325e..670c14a 100644 --- a/app/src/main/resources/fxml/Editor.fxml +++ b/app/src/main/resources/fxml/Editor.fxml @@ -38,6 +38,7 @@ +