codinggames

Crie videos dos seus códigos de programação com esse script de Node.js

Escrito em 4 de janeiro de 2022 - 🕒 7 min. de leitura

Quando eu estava trabalhando nos videos dos meus Game Devlogs, eu ficava pensando se seria possível criar automaticamente videos dos meus códigos, que dai ficaria bem mais fácil para criar os meus vídeos, mas como não achei nada relacionado a isso na internet, eu meio que desisti dessa idéia.

Até que algumas semanas atrás eu estava mexendo com AST e renderizando componentes em React usando Node.js e Babel e eu percebi que seria possível usar a função renderToStaticMarkup para renderizar um HTML com os meus códigos em um headless browser com o Puppeteer e fazer uma gravação dessa página.

O objetivo é conseguir criar um vídeo como o do GIF abaixo.

Partiu programar

Vou começar criando um arquivo script.js onde vou executar pela linha de comando.

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

Esse código vai usar ES Modules, então eu preciso instalar o pacote esm para eu poder rodar o meu script com o comando node -r esm src/script.js my_file.js.

Para a função generateVideo, seguirei o exemplo de básico do Puppeteer para abrir um headless browser e, em seguida, começar a gravar a sessão com o 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();
};

Esse código vai abrir uma aba no headless browser, definir o HTML da página para <p>Hello World</p> e gravar um vídeo de um segundo. Não é bem isso que eu quero, mas estou chegando lá.

Renderizando um componente em React

Agora eu vou criar uma função para renderizar o meu componente em React. Essa função vai receber como parâmetro o meu código e retornar um HTML com a sintaxe do código toda colorida pelo Prism.js.

Com a função Prism.highlight eu pego o HTML com a sintaxe do código colorida e passo para o meu componente em React, que então vai adicionar outras tags de HTML e o CSS. Para renderizar esse componente em React, vou usar a função renderToStaticMarkup, que vai transformar o componente em uma string de HTML. Na real eu nem precisava usar o React para esse projeto, mas como tudo começou com eu mexendo com React e Babel, eu deixei como está.

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

Além da variável code, a função generateHtml também recebe currentLine e totalLines como parâmetros. A variável currentLine será usada para destacar a linha de código atual, e totalLines para mostrar os números das linhas à esquerda.

O componente em React

A implementação básica do CodeHighlighter eu simplesmente retornarei uma tag code com o atributo dangerouslySetInnerHTML definido para o HTML do Prism.js.

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;

O código acima ainda está faltando o destaque da linha de código atual e os números das linhas, para isso usarei a variável totalLines para criar os números da linhas, e currentLine para criar uma div com largura total da página e movê-la para cima ou para baixo com a propriedade margem-top do CSS. Verifique o código completo para este componente no 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;

Agora eu posso pegar a string desse HTML, com toda a sintaxe de código colorida e números de linhas, e colocar como o HTML do headless browser pelo Puppeteer.

const page = await browser.newPage();

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

await page.setContent(html);

Mostrando o código linha por linha

Por enquanto, estou colocando todo o código no componente React de uma vez só, mas idealmente, o código deve ser mostrado linha por linha, e para isso irei percorrer a array de linhas e, em seguida, adicionar linha por linha a uma nova array e passá-la para a função generateHtml.

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();

Isso funciona, mas o problema é que se o código for muito longo, o browser precisa rolar para baixo para seguir as novas linhas, e para isso eu preciso usar a variável index do meu loop para acompanhar a linha atual e usar a função page.evaluate() do Puppeteer para rolar a página para baixo usando a função window.scrollTo().

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();

Conclusão

Você pode acessar o código completo desta postagem do blog no meu repositório GitHub na hash d595cf30cec13021fb857f9acb83a853e26610d7 do git.

O projeto começou com o código desse post, mas agora cresceu um pouco mais, e o código atual no branch main do repositório tem um efeito de digitação para cada letra, que faz o vídeo ficar bem mais maneiro. Sinta-se à vontade para mergulhar no código fonte na página do projeto code-video-creator.

Espero que esse post tenha sido útil para você. Deixe um comentário se tiver alguma dúvida e até a próxima!

Tags:


Publicar um comentário

Comentários

Nenhum comentário.