0

What I want to accomplish:
Make a JavaFX animation that would eventually concertize in a video.mp4 (or any other extension)

One way to do it:
During the animation plays, export 30fps (or 60fps) as still images (i.e. png images). After that process the images with some tool and create video.

So how to create the frames of a JavaFX window?

Cristian
  • 1,590
  • 5
  • 23
  • 38
  • 1
    what's the problem, _exactly_? Take screenshots, save them and process with an external tool (or 3rd party fx framework, don't know if there is any) to combine them into a video. – kleopatra Nov 29 '21 at 10:36
  • Your question is a bit confusing... What's stopping you from just reading the pixels of your JavaFX canvas and encoding them as PNG, wait is a canvas even involved here or does it really have to be a window (eg: running an external application in a window)? **Not enough info** about what you've got so far and what else is needed to solve your issue. – VC.One Nov 29 '21 at 12:10
  • @klopatra: thanks for your sugesstion. Are you refering to take screenshots programatically of the window? Does JavaFX has a mechanism in which we can take like 30 screenshots of the window per seconds? That would be useful – Cristian Nov 29 '21 at 12:50
  • @VC.One: I was referring to the whole window. So I would make some stuff in JavaFX that would open in a OS window and I would like to make a video of everything that is happening inside that window. – Cristian Nov 29 '21 at 12:52
  • 2
    Not tested, but I would try the following: Create a `Scene` which is not displayed in a window containing your animation. Don't `play()` the animation, but repeatedly call `jumpTo(...)` on the animation to create each frame, snapshot the result, and write to a file. Then process the images with ffmpeg, or some similar tool. – James_D Nov 29 '21 at 13:34
  • 2
    @James_D Exactly. That's what I would propose too. I doubt that JavaFX (or any other GUI framework) is capable of creating so many snapshots in a smooth fashion. The result would probably be very jerky. It's better to render frame by frame with precise timing. – mipa Nov 29 '21 at 14:38
  • @Cristian If you're on Windows then you could try [using FFmpeg to record the window](https://stackoverflow.com/a/30911430/2057709). I've not personally tested the `gdigrab`. – VC.One Nov 30 '21 at 12:35

1 Answers1

2

Disclaimer

The following solution is just offered as a proof of concept, with no accompanying explanation, no support in comments, no warranty that it will work for you, no guarantee that it will be bug free (it probably has some small errors), and no promise that it will be fit for any purpose.

Solution Strategy

Capture uses techniques suggested in comments by James and mipa:

Create a Scene which is not displayed in a window containing your animation. Don't play() the animation, but repeatedly call jumpTo(...) on the animation to create each frame, snapshot the result, and write to a file.

JPEG is a lossy encoding method and the output of this example is a bit fuzzy (likely due to default ImageIO settings which could be tweaked).

If desired, instead of mjpeg, each frame could be output to a separate file in a lossless format (like png), and then run through third party processing software to create another video format such as mp4.

MjpegCaptureAndPlayApp.java

package com.example.mjpeg;

import javafx.animation.Animation;
import javafx.animation.TranslateTransition;
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;
import javafx.util.Duration;

import java.nio.file.Files;

public class MjpegCaptureAndPlayApp extends Application {
    final private double W = 100, H = 100;

    private MjpegPlayer player;

    @Override
    public void start(Stage stage) throws Exception {
        String movieFile = Files.createTempFile("mjpeg-capture", "mjpeg").toString();

        CaptureAnimation captureAnimation = createCaptureAnimation();
        MjpegCapture mjpegCapture = new MjpegCapture(
                movieFile,
                captureAnimation.root(),
                captureAnimation.animation()
        );
        mjpegCapture.capture();

        player = new MjpegPlayer(movieFile);
        StackPane viewer = new StackPane(player.getViewer());
        viewer.setPrefSize(W, H);

        VBox layout = new VBox(20);
        layout.setStyle("-fx-background-color: cornsilk;");
        layout.setPadding(new Insets(10));
        layout.setAlignment(Pos.CENTER);

        layout.getChildren().setAll(
                viewer,
                player.getControls()
        );

        stage.setScene(new Scene(layout));
        stage.show();

        player.getTimeline().playFromStart();
    }

    @Override
    public void stop() throws Exception {
        if (player != null) {
            player.dispose();
        }
    }

    record CaptureAnimation(Parent root, Animation animation) {}

    private CaptureAnimation createCaptureAnimation() {
        Pane root = new Pane();
        root.setMinSize(Pane.USE_PREF_SIZE, Pane.USE_PREF_SIZE);
        root.setPrefSize(W, H);
        root.setMaxSize(Pane.USE_PREF_SIZE, Pane.USE_PREF_SIZE);

        Circle circle = new Circle(W / 10, Color.PURPLE);
        root.getChildren().add(circle);

        TranslateTransition translateTransition = new TranslateTransition(
                Duration.seconds(5),
                circle
        );
        translateTransition.setFromX(0);
        translateTransition.setToX(W);
        translateTransition.setFromY(H/2);
        translateTransition.setToY(H/2);
        translateTransition.setAutoReverse(true);
        translateTransition.setCycleCount(2);

        // move to start pos.
        circle.setTranslateX(0);
        circle.setTranslateY(H/2);

        return new CaptureAnimation(root, translateTransition);
    }

    public static void main(String[] args) {
        launch(args);
    }
}

MjpegCapture.java

package com.example.mjpeg;

import javafx.animation.Animation;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.WritableImage;
import javafx.util.Duration;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

public class MjpegCapture {

    private final Duration SECS_PER_FRAME = Duration.seconds(1.0 / 24);

    private final String videoFilename;
    private final Parent root;
    private final Animation animation;

    public MjpegCapture(String videoFilename, Parent root, Animation animation) {
        this.videoFilename = videoFilename;
        this.root = root;
        this.animation = animation;
    }

    public void capture() throws IOException {
        VideoStreamOutput videoStreamOutput = new VideoStreamOutput(videoFilename);

        animation.playFromStart();
        Duration curPos = Duration.ZERO;

        SnapshotParameters snapshotParameters = new SnapshotParameters();
        // transparent fill not supported by jpeg I believe so not enabled.
        //snapshotParameters.setFill(Color.TRANSPARENT);

        Scene scene = new Scene(root);

        // force a layout pass so that we can measure the height and width of the root node.
        scene.snapshot(null);
        int w = (int) scene.getWidth();
        int h = (int) scene.getHeight();
        WritableImage fxImage = new WritableImage(w, h);

        boolean complete;
        ByteArrayOutputStream outputBuffer = new ByteArrayOutputStream();
        do {
            animation.jumpTo(curPos);
            root.snapshot(snapshotParameters, fxImage);

            // Get buffered image:
            // uses ugly, inefficient workaround from:
            //   https://stackoverflow.com/a/19605733/1155209
            BufferedImage image = SwingFXUtils.fromFXImage(fxImage, null);

            // Remove alpha-channel from buffered image:
            BufferedImage imageRGB = new BufferedImage(
                    image.getWidth(),
                    image.getHeight(),
                    BufferedImage.OPAQUE);

            Graphics2D graphics = imageRGB.createGraphics();
            graphics.drawImage(image, 0, 0, null);
            ImageIO.write(imageRGB, "jpg", outputBuffer);

            videoStreamOutput.writeNextFrame(outputBuffer.toByteArray());
            outputBuffer.reset();

            complete = curPos.greaterThanOrEqualTo(animation.getTotalDuration());

            if (curPos.add(SECS_PER_FRAME).greaterThan(animation.getTotalDuration())) {
                curPos = animation.getTotalDuration();
            } else {
                curPos = curPos.add(SECS_PER_FRAME);
            }
        } while(!complete);

        videoStreamOutput.close();
    }
}

MjpegPlayer.java

package com.example.mjpeg;

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.util.Duration;

import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Arrays;

public class MjpegPlayer {

    private final String videoFilename;
    private final Timeline timeline;
    private final ImageView viewer = new ImageView();
    private final HBox controls;
    private VideoStream videoStream;

    public MjpegPlayer(String filename) throws FileNotFoundException {
        videoFilename = filename;
        videoStream = new VideoStream(filename);
        timeline = createTimeline(viewer);
        controls = createControls(timeline);
    }

    private Timeline createTimeline(ImageView viewer) {
        final Timeline timeline = new Timeline();
        final byte[] buf = new byte[15000];

        timeline.getKeyFrames().setAll(
                new KeyFrame(Duration.ZERO, event -> {
                    try {
                        int len = videoStream.readNextFrame(buf);
                        if (len == -1) {
                            timeline.stop();
                            return;
                        }
                        viewer.setImage(
                                new Image(
                                        new ByteArrayInputStream(
                                                Arrays.copyOf(buf, len)
                                        )
                                )
                        );
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }),
                new KeyFrame(Duration.seconds(1.0 / 24))
        );
        timeline.setCycleCount(Timeline.INDEFINITE);

        return timeline;
    }

    private HBox createControls(final Timeline timeline) {
        Button play = new Button("Play");
        play.setOnAction(event -> timeline.play());

        Button pause = new Button("Pause");
        pause.setOnAction(event -> timeline.pause());

        Button restart = new Button("Restart");
        restart.setOnAction(event -> {
            try {
                timeline.stop();
                videoStream = new VideoStream(videoFilename);
                timeline.playFromStart();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        HBox controls = new HBox(10);
        controls.setAlignment(Pos.CENTER);
        controls.getChildren().setAll(
                play,
                pause,
                restart
        );

        return controls;
    }

    public void dispose() throws IOException {
        videoStream.close();
    }

    public String getVideoFilename() {
        return videoFilename;
    }

    public Timeline getTimeline() {
        return timeline;
    }

    public ImageView getViewer() {
        return viewer;
    }

    public HBox getControls() {
        return controls;
    }
}

VideoStream.java

package com.example.mjpeg;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

class VideoStream {

    private final FileInputStream fis; //video file

    VideoStream(String filename) throws FileNotFoundException {
        fis = new FileInputStream(filename);
    }

    int readNextFrame(byte[] frame) throws Exception {
        int length;
        String lengthAsString;
        byte[] lengthAsBytes = new byte[5];

        //read current frame length
        fis.read(lengthAsBytes, 0, 5);

        //transform lengthAsBytes to integer
        lengthAsString = new String(lengthAsBytes);
        try {
            length = Integer.parseInt(lengthAsString);
        } catch (Exception e) {
            return -1;
        }

        return (fis.read(frame, 0, length));
    }

    void close() throws IOException {
        fis.close();
    }
}

VideoStreamOutput.java

package com.example.mjpeg;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

class VideoStreamOutput {
    private FileOutputStream fos; //video file
    private int frameNum; //current frame nb

    public VideoStreamOutput(String filename) throws FileNotFoundException {
        fos = new FileOutputStream(filename);
        frameNum = 0;
    }

    public void writeNextFrame(byte[] frame) throws IOException {
        frameNum++;

        String lengthAsString = String.format("%05d", frame.length);
        byte[] lengthAsBytes = lengthAsString.getBytes(StandardCharsets.US_ASCII);

        fos.write(lengthAsBytes);
        fos.write(frame);

        System.out.println(frameNum + ": " + lengthAsString);
    }

    public void close() throws IOException {
        fos.flush();
        fos.close();
    }
}

module-info.java

module com.example.mjpeg {
    requires javafx.controls;
    requires javafx.swing;
    requires java.desktop;

    exports com.example.mjpeg;
}
jewelsea
  • 150,031
  • 14
  • 366
  • 406
  • I did something like this many, many years ago for a project via the Java Media Framework. (https://www.oracle.com/java/technologies/javase/java-media-framework.html) The advantage is that it can encode into a real video format which can be played and processed by standard video tools. – mipa Nov 30 '21 at 08:43
  • @jewlsea: looks interesting. I will check it out and come with an update. – Cristian Dec 01 '21 at 07:33