I’ve been working on a more involved animation project with Motion Canvas
lately, and have wondered if I’d be able to speed up the rendering process by
using a bunch of Docker containers. Here’s what I’ve found.
The Puppeteer Part
To start, I need a way to render my project automatically. I used puppeteer for
this. I start by creating an app to test against, and a test file. These use the
existing motion canvas vite server. I’ll need to grab packages first:
Then create the app itself:
src/app/app.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
import * as path from "path";
import puppeteer, { Page } from "puppeteer";
import { fileURLToPath } from "url";
import { createServer } from "vite";
const Root = fileURLToPath(new URL(".", import.meta.url));
export interface App {
page: Page;
stop: () => Promise<void>;
}
export async function start(): Promise<App> {
const [browser, server] = await Promise.all([
puppeteer.launch({
headless: true,
protocolTimeout: 15 * 60 * 1000,
args: ["--no-sandbox"],
}),
createServer({
root: path.resolve(Root, "../../"),
configFile: path.resolve(Root, "../../vite.config.ts"),
server: {
port: 9000,
},
}),
]);
const portPromise = new Promise<number>((resolve) => {
server.httpServer.once("listening", async () => {
const port = (server.httpServer.address() as any).port;
resolve(port);
});
});
await server.listen();
const port = await portPromise;
const page = await browser.newPage();
await page.goto(`http://localhost:${port}`, {
waitUntil: "networkidle2",
});
return {
page,
async stop() {
await Promise.all([browser.close(), server.close()]);
},
};
}
|
And the actual “test”:
src/app/rendering.test.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { App, start } from "./app";
describe("Rendering", () => {
let app: App;
beforeAll(async () => {
app = await start();
}, 30 * 1000);
afterAll(async () => {
await app.stop();
}, 30 * 1000);
test(
"Animation renders correctly",
{
timeout: 15 * 60 * 1000,
},
async () => {
await app.page.evaluateHandle("document.fonts.ready");
await new Promise((resolve) => setTimeout(resolve, 5_000));
await app.page.screenshot();
const rendering = await app.page.waitForSelector(
"::-p-xpath(//div[contains(text(), 'Video Settings')])"
);
if (rendering) {
const tab = await app.page.evaluateHandle(
(el) => el.parentElement,
rendering
);
await tab.click();
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
await app.page.select(
"::-p-xpath(//div[contains(text(), 'Rendering')]/parent::div//label[contains(text(), 'exporter')]/parent::div//select)",
"Image sequence"
);
const render = await app.page.waitForSelector("#render");
await render.click();
await app.page.waitForSelector('#render[data-rendering="true"]', {
timeout: 2 * 1000,
});
await app.page.waitForSelector('#render:not([data-rendering="true"])', {
timeout: 15 * 60 * 1000,
});
expect(true).toBe(true);
}
);
});
|
I’ll also save the script in my package.json
package.json
1
2
3
4
5
|
{
"scripts": {
"test": "vitest run"
}
}
|
If your animation is simple, giving this a test run with
should give you a good idea what happens. It now will render the animation as an
image sequence to your output folder, just by running the command. You may need
to adjust timeouts depending on the amount of work you’re doing.
The Docker Part
I started with a simple dockerfile. I’m using alpine-chrome
so that WebGL
works properly, since I’m personally using shaders.
Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
|
FROM zenika/alpine-chrome:with-node
USER root
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY vite.config.ts ./
COPY public ./public
COPY src ./src
CMD npm run test
|
I can then run it by building the image and running it:
Linux
Windows (Powershell)
Windows (CMD)
This one works, but isn’t very good because each time you run it, it will
render the full animation. My real goal with this was to render the animation in
pieces simultaneously.
So I’ll update it to take in an environment variable to set the start and end
seconds. I’ll then overwrite the project meta file with the new range inside of
the container without affecting the host.
Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
FROM zenika/alpine-chrome:with-node
USER root
RUN apk update \
&& apk add --no-cache jq \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY vite.config.ts ./
COPY public ./public
COPY src ./src
ENV START=0
ENV END=5
CMD tmp=$(mktemp) \
&& jq --arg start "$START" --arg end "$END" '.shared.range[0] = ($start|tonumber) | .shared.range[1] = ($end|tonumber)' src/project.meta > $tmp \
&& mv $tmp src/project.meta \
&& npm run test -- run
|
I can then run it with the following, which will render the first 5 seconds of
the animation.
Linux
Windows (Powershell)
Windows (CMD)
The Real Fun Part (Docker Compose)
I can then use docker-compose to run multiple containers at once. I’ll create a
devops/docker-compose.yml
file that looks like this:
devops/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
services:
render: &render
build:
context: ..
dockerfile: Dockerfile
container_name: render-1
environment:
- START=0
- END=10
volumes:
- ../container-output:/app/output
render2:
<<: *render
container_name: render-2
environment:
- START=10
- END=20
render3:
<<: *render
container_name: render-3
environment:
- START=20
- END=30
render4:
<<: *render
container_name: render-4
environment:
- START=30
- END=40
render5:
<<: *render
container_name: render-5
environment:
- START=40
- END=50
render6:
<<: *render
container_name: render-6
environment:
- START=50
- END=60
render7:
<<: *render
container_name: render-7
environment:
- START=60
- END=70
render8:
<<: *render
container_name: render-8
environment:
- START=70
- END=80
render9:
<<: *render
container_name: render-9
environment:
- START=80
- END=90
render10:
<<: *render
container_name: render-10
environment:
- START=90
- END=100
|
You can make as many of these as your computer can handle. Then run it from the
root folder with:
If this is actually efficient at all probably depends on your setup. I can see
myself publishing an image to a private container repository and running it on
local compute resources, or hooking it up to a fleet of cloud machines to render
animations in parallel. So your mileage may vary.
As always, if you have questions or comments, feel free to reach out to me on
Discord, either on my personal server or the
Motion Canvas server.