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:

Command
$
 npm install puppeteer vitest --save-dev 

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

Command
$
 npm run test 

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

Commands
$
 docker build -t my-animation-renderer . docker run
$
--rm -v $(pwd)/container-output:/app/output my-animation-renderer

Windows (Powershell)

Commands
$
 docker build -t my-animation-renderer
$
. docker run --rm -v ${PWD}/container-output:/app/output my-animation-renderer

Windows (CMD)

Commands
$
 docker build -t my-animation-renderer .
$
docker run --rm -v %cd%/container-output:/app/output my-animation-renderer

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

Commands
$
 docker build -t my-animation-renderer . docker run
$
--rm -v $(pwd)/container-output:/app/output -e START=0 -e END=5
$
my-animation-renderer 

Windows (Powershell)

Commands
$
 docker build -t my-animation-renderer
$
. docker run --rm -v ${PWD}/container-output:/app/output -e START=0 -e END=5
$
my-animation-renderer 

Windows (CMD)

Commands
$
 docker build -t my-animation-renderer .
$
docker run --rm -v %cd%/container-output:/app/output -e START=0 -e END=5
$
my-animation-renderer 

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:

Command
$
 docker-compose -f devops/docker-compose.yml up --build

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.