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;
}