Create videos from code snippets with this Node.js script

Written in January 4, 2022 - 🕒 6 min. read

When I was working on my Game Devlog videos, I wondered if there was any way to automatically transform my code snippets into a video, so I could add them to my Devlog, but I couldn’t find any solution for it, so I gave up on this idea.

Then a couple of weeks ago I was playing around with AST and rendering React via Node.js with Babel when I had the idea to use Puppeteer to open a headless browser and record a page with my code snippet.

The goal is for the result to be like the following GIF.

Let’s get coding

I will start by creating a script.js to be run via terminal using node.

require('@babel/register');

const { readFileSync } = require('fs');
const puppeteer = require('puppeteer');
const { PuppeteerScreenRecorder } = require('puppeteer-screen-recorder');

// Utils
const { generateHtml } = require('./utils');

// Constants
const SCALE = 4;

const generateVideo = async (filePath) => {
    // TODO
};

// get node script param
const filePath =
    process.argv[2] || './examples/Test.jsx';

generateVideo(filePath);

My code will use ES Modules, so I will install esm so I can run my script like node -r esm src/script.js my_file.js.

For the generateVideo function, I will follow the Puppeteer’s quick-start example to open a headless browser and then start recording it with puppeteer-screen-recorder.

const generateVideo = async (filePath) => {
    // load file content
    const code = readFileSync(filePath, {
        encoding: 'utf8',
    });
    const lines = code.split('\n');

    // Puppeteer config
    const browser = await puppeteer.launch({
        headless: true,
        args: ['--window-size=1920,1080'],
        defaultViewport: {
            width: 1920,
            height: 1080,
        },
    });

    // open a new empty page
    const page = await browser.newPage();
    const config = {
        followNewTab: false,
        fps: 25,
        ffmpeg_Path: null,
        videoFrame: {
            width: 1920,
            height: 1080,
        },
        aspectRatio: '16:9',
    };

    const recorder = new PuppeteerScreenRecorder(
        page,
        config
    );

    // start recording
    await recorder.start('./output.mp4');
    await page.setContent('<p>Hello World</p>');

    await page.waitForTimeout(1000);

    await recorder.stop();
    await browser.close();
};

This will open a headless browser tab, set the HTML to <p>Hello World</p> and make a 1-second video of it. That’s not what I want, but I will get there.

Rendering a React component

Now I will create a function that receives the code snippet as a parameter and then render a beautiful syntax highlighted code in HTML format using Prism.js.

With Prism.highlight I can pass a code string and get an HTML with the syntax-highlighted code and renderToStaticMarkup to render the React component to an HTML string. I don’t need React here, but since the whole idea started with React and Babel, I kept it.

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { readFileSync } from 'fs';

// Prism.js
import Prism from 'prismjs';
import loadLanguages from 'prismjs/components/';

// My custom React component
import CodeHighlighter from './CodeHighlighter.jsx';

const styling = readFileSync(
    './node_modules/prism-themes/themes/prism-material-dark.css',
    { encoding: 'utf8' }
);

export const generateHtml = (
    code,
    currentLine,
    totalLines,
    language
) => {
    loadLanguages([language]);
    const codeHtml = Prism.highlight(
        code,
        Prism.languages[language],
        language
    );

    // get HTML string
    const html = renderToStaticMarkup((
        <CodeHighlighter
            codeHtml={codeHtml}
            totalLines={totalLines}
            currentLine={currentLine}
        />
    ));

    const { window } = new JSDOM(html);
    const { document } = window;

    // Add Prism.js styling to the HTML document
    const style = document.createElement('style');
    style.textContent = styling;
    document.head.appendChild(style);

    return document.getElementsByTagName('html')[0].outerHTML;
};

Other than code, the generateHtml function also receives currentLine and totalLines as parameters. currentLine will be used to highlight the current line of code, and totalLines to show the line numbers on the left.

The React component

The basic implementation of the CodeHighlighter I will simply return a code tag with the dangerouslySetInnerHTML set to the Prism.js HTML.

import React from 'react';

const SCALE = 4;

function CodeHighlighter({
    codeHtml,
    totalLines,
    currentLine,
}) {
    return (
        <html lang="en">
            <body
                style={{
                    width: `${1920 / SCALE}px`,
                    height: `${1080 / SCALE}px`,
                    background: '#272822',
                    transform: `scale(${SCALE})`,
                    transformOrigin: '0% 0% 0px',
                    margin: 0,
                }}
            >
                <div
                    style={{
                        display: 'flex',
                        margin: '20px 0 0 2px',
                    }}
                >
                    <pre
                        style={{
                            margin: 0,
                        }}
                    >
                        <code
                            style={{
                                fontFamily: "Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace",
                            }}
                            dangerouslySetInnerHTML={{
                                __html: codeHtml,
                            }}
                        />
                    </pre>
                </div>
            </body>
        </html>
    );
}

export default CodeHighlighter;

The code above is still missing the line highlighter and the line numbers, for that I will use the totalLines variable to create the line numbers, and currentLine to create a div with full width and move it with the margin-top CSS property. Check the full code for this React component on GitHub.

import React from 'react';

const SCALE = 4;

function CodeHighlighter({
    codeHtml,
    totalLines,
    currentLine,
}) {
    // add the line numbers
    const lines = new Array(totalLines).fill(null).map((v, index) => ((
        <span
            key={index}
            style={{
                height: '16px',
                width: `${8 * (totalLines).toString().length}px`,
            }}
        >
            {index + 1}
        </span>
    )));

    return (
        <html lang="en">
            <style
                dangerouslySetInnerHTML={{
                    __html: `
                    body { color: white; }
                `,
                }}
            />
            <body
                style={{
                    width: `${1920 / SCALE}px`,
                    height: `${1080 / SCALE}px`,
                    background: '#272822',
                    transform: `scale(${SCALE})`,
                    transformOrigin: '0% 0% 0px',
                    margin: 0,
                }}
            >
                <div
                    style={{
                        display: 'flex',
                        margin: '20px 0 0 2px',
                    }}
                >
                    <div
                        style={{
                            width: '100%',
                            position: 'absolute',
                            height: `${4 * SCALE}px`,
                            backgroundColor: '#44463a',
                            zIndex: -1,
                            marginTop: `${16 * currentLine}px`,
                        }}
                    />
                    <div
                        style={{
                            display: 'grid',
                            margin: '0 5px 0 2px',
                            color: '#DD6',
                        }}
                    >
                        {lines}
                    </div>
                    <pre
                        style={{
                            margin: 0,
                        }}
                    >
                        <code
                            style={{
                                fontFamily: "Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace",
                            }}
                            dangerouslySetInnerHTML={{
                                __html: codeHtml,
                            }}
                        />
                    </pre>
                </div>
            </body>
        </html>
    );
}

export default CodeHighlighter;

Now in the script, I can get the syntax highlighted HTML string and set it as the headless browser HTML.

const page = await browser.newPage();

const html = generateHtml(
    code,
    0,
    lines.length,
    'javascript'
);

await page.setContent(html);

Showing code line by line

For now, I’m dumping all the code into the React component, but ideally, the code should be shown line by line, for that, I will loop through the lines array and then add line by line to a new array and pass it to the generateHtml function.

const page = await browser.newPage();

let index = 0;
let codeToParse = [];
const scrollThreshold = 9;

for (const line of lines) {
    codeToParse.push(line);

    const html = generateHtml(
        codeToParse.join('\n'),
        index,
        lines.length + scrollThreshold,
        language
    );

    await page.setContent(html);

    await page.waitForTimeout(1000);
    index += 1;
}

await browser.close();

This works but the problem is that if the code is too long, then the browser needs to scroll down to follow the new lines, for that I need to use the loop index variable to keep track of the current line and use the Puppeteer’s page.evaluate() to scroll the page down using the page’s window object.

const page = await browser.newPage();

let index = 0;
let prevPosY = 0;
const basePosY = 7;
let codeToParse = [];
const scrollThreshold = 8;

for (const line of lines) {
    codeToParse.push(line);

    // get full page HTML
    const html = generateHtml(
        codeToParse.join('\n'),
        index,
        lines.length + scrollThreshold,
        language
    );

    // set page HTML
    await page.setContent(html);

    const diff = index - scrollThreshold;
    const posY = Math.max(
        (basePosY + (16 * diff)) * SCALE,
        0
    );

    // scroll down or up if needed
    if (prevPosY !== posY) {
        await page.evaluate((posY) => {
            window.scrollTo({
                top: posY,
                behavior: 'smooth',
            });
        }, posY);
    }

    await page.waitForTimeout(1000);
    prevPosY = posY;
    index += 1;
}

await browser.close();

Conclusion

You can access the full code from this blog post in my GitHub repo in the git hash d595cf30cec13021fb857f9acb83a853e26610d7.

The project started with this code, but it has now grown a little, and the current code in the main branch of the repo has a typing effect for each letter, which makes it way nicer. Feel free to dive into the code on the code-video-creator project page.

I hope this was somehow insightful for you, please leave a comment if you have any questions. See you in the next one!

Tags:


Post a comment

Comments

No comments yet.