It's not pretty, but I have managed to get a prototype working.
Firstly, all animations need to be powered by a single main animation controller so that we can step through to any part of the animation we want. Secondly, the widget tree that we want to record has to be wrapped inside a RepaintBoundary with a global key. The RepaintBoundary and it's key can produce snapshots of the widget tree as such:
Future<Uint8List> _capturePngToUint8List() async {
// renderBoxKey is the global key of my RepaintBoundary
RenderRepaintBoundary boundary = renderBoxKey.currentContext.findRenderObject();
// pixelratio allows you to render it at a higher resolution than the actual widget in the application.
ui.Image image = await boundary.toImage(pixelRatio: 2.0);
ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);
Uint8List pngBytes = byteData.buffer.asUint8List();
return pngBytes;
}
The above method can then be used inside a loop which captures the widget tree into pngBytes, and steps the animationController forward by a deltaT specified by the framerate you want as such:
double t = 0;
int i = 1;
setState(() {
animationController.value = 0.0;
});
Map<int, Uint8List> frames = {};
double dt = (1 / 60) / animationController.duration.inSeconds.toDouble();
while (t <= 1.0) {
print("Rendering... ${t * 100}%");
var bytes = await _capturePngToUint8List();
frames[i] = bytes;
t += dt;
setState(() {
animationController.value = t;
});
i++;
}
Finally, all these png frames can be piped into an ffmpeg subprocess to be written into a video.
I haven't managed to get this part working nicely yet (UPDATE: Scroll down to see solution to this), so what I've done instead is write out all the png-frames into actual png files and I then manually run ffmpeg inside the folder where they are written. (Note: I've used flutter desktop to be able to access my installation of ffmpeg, but there is a package on pub.dev to get ffmpeg on mobile too)
List<Future<File>> fileWriterFutures = [];
frames.forEach((key, value) {
fileWriterFutures.add(_writeFile(bytes: value, location: r"D:\path\to\my\images\folder\" + "frame_$key.png"));
});
await Future.wait(fileWriterFutures);
_runFFmpeg();
Here is my file-writer help-function:
Future<File> _writeFile({@required String location, @required Uint8List bytes}) async {
File file = File(location);
return file.writeAsBytes(bytes);
}
And here is my FFmpeg runner function:
void _runFFmpeg() async {
// ffmpeg -y -r 60 -start_number 1 -i frame_%d.png -c:v libx264 -preset medium -tune animation -pix_fmt yuv420p test.mp4
var process = await Process.start(
"ffmpeg",
[
"-y", // replace output file if it already exists
"-r", "60", // framrate
"-start_number", "1",
"-i", r"./test/frame_%d.png", // <- Change to location of images
"-an", // don't expect audio
"-c:v", "libx264rgb", // H.264 encoding
"-preset", "medium",
"-crf",
"10", // Ranges 0-51 indicates lossless compression to worst compression. Sane options are 0-30
"-tune", "animation",
"-preset", "medium",
"-pix_fmt", "yuv420p",
r"./test/test.mp4" // <- Change to location of output
],
mode: ProcessStartMode.inheritStdio // This mode causes some issues at times, so just remove it if it doesn't work. I use it mostly to debug the ffmpeg process' output
);
print("Done Rendering");
}
Update:
Since this answer was posted I have figured out how to pipe the images directly to ffmpeg without the need of first writing out all the files. The following is my updated render function taken from one of my widgets. Some variables are present within the widget's context, but I hope that their values can be inferred from the context:
void render([double? pixelRatio]) async {
// If already rendering, return
if (isRendering) return;
String outputFileLocation = "final.mp4";
setState(() {
isRendering = true;
});
timeline.stop();
await timeline.animateTo(0.0, duration: const Duration(milliseconds: 700), curve: Curves.easeInOutQuad);
setState(() {
timeline.value = 0.0;
});
await Future.delayed(const Duration(milliseconds: 100));
try {
int width = canvasSize.width.toInt();
int height = canvasSize.height.toInt();
int frameRate = 60;
int numberOfFrames = frameRate * (timeline.duration!.inSeconds);
print("starting ffmpeg..");
var process = await Process.start(
"ffmpeg",
[
"-y", // replace output file if it already exists
// "-f", "rawvideo",
// "-pix_fmt", "rgba",
"-s", "${width}x$height", // size
"-r", "$frameRate", // framrate
"-i", "-",
"-frames", "$numberOfFrames",
"-an", // don't expect audio
"-c:v", "libx264rgb", // H.264 encoding
"-preset", "medium",
"-crf",
"10", // Ranges 0-51 indicates lossless compression to worst compression. Sane options are 0-30
"-tune", "animation",
"-preset", "medium",
"-pix_fmt", "yuv420p",
"-vf",
"pad=ceil(iw/2)*2:ceil(ih/2)*2", // ensure width and height is divisible by 2
outputFileLocation
],
mode: ProcessStartMode.detachedWithStdio,
runInShell: true);
print("writing to ffmpeg...");
RenderRepaintBoundary boundary = paintKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
pixelRatio = pixelRatio ?? 1.0;
print("Pixel Ratio: $pixelRatio");
for (int i = 0; i <= numberOfFrames; i++) {
Timeline.startSync("Render Video Frame");
double t = (i.toDouble() / numberOfFrames.toDouble());
// await timeline.animateTo(t, duration: Duration.zero);
timeline.value = t;
ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
ByteData? rawData = await image.toByteData(format: ui.ImageByteFormat.png);
var rawIntList = rawData!.buffer.asInt8List().toList();
Timeline.finishSync();
if (i % frameRate == 0) {
print("${((t * 100.0) * 100).round() / 100}%");
}
process.stdin.add(rawIntList);
image.dispose();
}
await process.stdin.flush();
print("stopping ffmpeg...");
await process.stdin.close();
process.kill();
print("done!");
} catch (e) {
print(e);
} finally {
await timeline.animateTo(beforeValue, duration: const Duration(milliseconds: 500), curve: Curves.easeInOutQuad);
setState(() {
isRendering = false;
});
}
}