Skip to content

Instantly share code, notes, and snippets.

@SirPepe
Created August 8, 2024 12:35
Show Gist options
  • Save SirPepe/9dd1049699350f59161f9dffda1d945a to your computer and use it in GitHub Desktop.
Save SirPepe/9dd1049699350f59161f9dffda1d945a to your computer and use it in GitHub Desktop.
Animated syntax highlighting, 4k 60 FPS edition
// Set up the easing function
import BezierEasing from "bezier-easing";
const ease = BezierEasing(0.25, 0.1, 0.25, 1);
function value(from, to, step, steps) {
return from + (to - from) * ease(step / steps);
}
// Interpolate positions of text elements
function interpolateTextPositions(fromFrame, toFrame, steps, step) {
const positions = new Map();
const ids = new Set([...fromFrame.text.keys(), ...toFrame.text.keys()]);
for (const id of ids) {
const from = fromFrame.text.get(id);
const to = toFrame.text.get(id);
if (from && to) {
positions.set(id, {
x: value(from.x, to.x, step, steps),
y: value(from.y, to.y, step, steps),
a: value(from.a, to.a, step, steps),
});
}
}
return positions;
}
// Interpolate positions of text elements
function interpolateLineNumbers(fromFrame, toFrame, steps, step) {
const positions = new Map();
const ids = new Set([
...fromFrame.lineNumbers.keys(),
...toFrame.lineNumbers.keys(),
]);
for (const id of ids) {
const from = fromFrame.lineNumbers.get(id) ?? { a: 0 };
const to = toFrame.lineNumbers.get(id) ?? { a: 0 };
positions.set(id, {
a: value(from.a, to.a, step, steps),
});
}
return positions;
}
// Expand all keyframes in a given scene
export function interpolateFrames(scene, fps, transitionTime, loop) {
const keyframes = Array.from(scene.frames.keys()).sort((a, b) =>
a < b ? -1 : a > b ? 1 : 0
);
const steps = fps * (transitionTime / 1000);
const from = keyframes.at(0) ?? 0;
const to = keyframes.at(-1) ?? 0;
const outputFrames = new Map();
let frame = from * steps;
for (let frameIdx = from + 1; frameIdx <= to; frameIdx++) {
const fromFrame = scene.frames.get(frameIdx - 1);
const toFrame = scene.frames.get(frameIdx);
for (let step = 0; step < steps; step++) {
outputFrames.set(frame, {
isKeyframe: fromFrame.isKeyframe && step === 0,
text: interpolateTextPositions(fromFrame, toFrame, steps, step),
lineNumbers: interpolateLineNumbers(fromFrame, toFrame, steps, step),
decoration: new Map(),
cols: value(fromFrame.cols, toFrame.cols, step, steps),
rows: value(fromFrame.rows, toFrame.rows, step, steps),
});
frame++;
}
}
// Close the loop between last and first frame for looping animations
if (loop) {
const fromFrame = scene.frames.get(to);
const toFrame = scene.frames.get(from);
for (let step = 0; step < steps; step++) {
outputFrames.set(frame, {
isKeyframe: fromFrame.isKeyframe && step === 0,
text: interpolateTextPositions(fromFrame, toFrame, steps, step),
lineNumbers: interpolateLineNumbers(fromFrame, toFrame, steps, step),
decoration: new Map(),
cols: value(fromFrame.cols, toFrame.cols, step, steps),
rows: value(fromFrame.rows, toFrame.rows, step, steps),
});
frame++;
}
// Final keyframe
outputFrames.set(frame++, {
...scene.frames.get(from),
isKeyframe: true,
});
} else {
// This function interpolates from the current keyframe to just before the
// next keyframe, so the final keyframe is not part of the result unless
// we add it manually (or loop).
outputFrames.set(frame++, {
...scene.frames.get(to),
isKeyframe: true,
});
}
scene.frames = outputFrames;
}
// Load the information for the render job from a JSON module
// frames = keyframe list for fromStringsToScene()
// * languageModule = eg. "ecmascript" or "elixir"
// * languageOptions = eg. { ts: false, jsx: true } if languageModule = ecmascript
// * video options:
// - fps
// - width
// - height
// - transitionTime
// - pauseTime
import project from "./project.json" with { type: "json" };
const { frames, languageModule, languageOptions, videoOptions } = project;
// HTML scaffold to scale and customize the animation. This snippet uses
// JavaScript to scale the animation and CSS to customize the overall style.
// It is important to set --cm-animation-duration to 0s because this script will
// *compute the transitions for the animations manually* instead of relying on
// regular CSS transitions. Yes, this means that the browser will be confronted
// with literally hundreds of thousands of CSS rules. Yes, this is fine and
// works.
const baseHTML = `<style>
body {
/* this font-size ensures okay looking text no matter the scale */
font-size: 48px;
/* this script pre-computes all animations */
--cm-animation-duration: 0s;
margin: 0;
padding: 0;
}
/* Ensures the animation is centered in the frame */
.cm-animation {
transform-origin: top left;
position: absolute;
top: 50%;
left: 50%;
}
</style>
<script>
const root = document.querySelector(".cm-animation");
const { offsetWidth, offsetHeight } = root;
const { innerWidth, innerHeight } = window;
const scale = Math.min(innerWidth / offsetWidth, innerHeight / offsetHeight);
root.setAttribute("style", "transform:scale(" + scale + ") translate(-50%, -50%)");
</script>`;
// Use a regular browser to actually render the frames. We will literally just
// use Puppeteer to open a Chrome tab, take a PNG screenshot, and pump it into
// ffmpeg. Injected JS will change the current frame and repeat the process.
import { launch } from "puppeteer";
import { spawn } from "node:child_process";
const browser = await launch({
headless: true,
args: ["--hide-scrollbars", "--no-default-browser-check", "--no-first-run"],
});
// ffmpeg process set up to use the image2pipe muxer to turn PNG data piped to
// stdin into a video.
const ffmpeg = spawn("ffmpeg", [
"-loglevel",
"error",
"-r",
`${videoOptions.fps}`,
"-f",
"image2pipe",
"-s",
`${videoOptions.width}x${videoOptions.height}`,
"-i",
"pipe:0",
"-vcodec",
"libx264",
"-crf",
"25",
"-pix_fmt",
"yuv420p",
"-f",
"ismv",
"pipe:1",
]);
// Print errors that happen with ffmpeg
ffmpeg.stderr.on("data", (e) => console.log("FFMPEG stderr:", e.toString()));
// Once ffmpeg.stdin has .end() called on it, "output" is where the finished
// video file gets written to.
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createWriteStream } from "node:fs";
const output = join(fileURLToPath(import.meta.resolve("./")), "movie.mp4");
ffmpeg.stdout.pipe(createWriteStream(output));
// Load the core library and the language module, which gets instantiated right
// away with the language options defined in the job information JSON.
import { fromStringsToScene, toAnimationHTML } from "@codemovie/code-movie";
const language = (
await import(`@codemovie/code-movie/languages/${languageModule}`)
).default(languageOptions);
// Create a scene object with the language object as usual
const { scene } = fromStringsToScene(frames, { language, tabSize: 2 });
// Scene objects usually only contain keyframes and leave interpolation to the
// browser. For our purposes, the ease module takes the scene object and
// pre-computes animation information for every frame.
import { interpolateFrames } from "./ease.js";
interpolateFrames(scene, videoOptions.fps, videoOptions.transitionTime, true);
// Thanks to the pre-computed animations the HTML string is now MUCH MUCH larger
// than usual. But that's fine, today's browsers can handle loads of abuse.
const animationHtml = toAnimationHTML({ scene, language });
// Create the browser tab and pass in the HTML
const page = await browser.newPage();
await page.setViewport({
width: videoOptions.width,
height: videoOptions.height,
});
const pageContent = "<!doctype html>" + animationHtml + baseHTML;
await page.setContent(pageContent);
// To add pauses on keyframes, simply pump the relevant frame repeatedly into
// ffmpeg.
const extraKeyframeFrames = (videoOptions.pauseTime / 1000) * videoOptions.fps;
// Stringify the number of total frames so we can use this string length to pad
// the current frame number for stdout.
const totalFrames = String(scene.frames.size);
// For every frame, set the correct frame index via JS, then render a PNG buffer
// and hand it over to ffmpeg's stdin. Rinse and repeat.
for (const [frameIdx, { isKeyframe }] of scene.frames) {
// Switch the frame
await page.evaluate(
`document.querySelector(".cm-animation").classList.remove("frame${frameIdx - 1}");
document.querySelector(".cm-animation").classList.add("frame${frameIdx}");`,
);
// Waste some time to allow the browser to update. You may need to adjust
// this, or this might be entirely superfluous depending on your machine,
// software and project. 100ms works for me and my machine ¯\_(ツ)_/¯
await new Promise((r) => setTimeout(r, 100));
// Take the actual screenshot
const buffer = await page.screenshot({ type: "png", optimizeForSpeed: true });
ffmpeg.stdin.write(buffer);
// Pause on keyframes and the last frame
if (isKeyframe || frameIdx === scene.frames.size - 1) {
for (let i = 0; i < extraKeyframeFrames; i++) {
ffmpeg.stdin.write(buffer);
}
}
const currentFrame = String(frameIdx + 1).padStart(totalFrames.length, "0");
console.log(`Rendered frame ${currentFrame} of ${totalFrames}`);
}
// End input
ffmpeg.stdin.end();
// Close the browser to let the script terminate
await browser.close();
{
"type": "module",
"dependencies": {
"@codemovie/code-movie": "^0.0.16",
"bezier-easing": "^2.1.0",
"puppeteer": "^23.0.1"
}
}
{
"languageModule": "json",
"languageOptions": {},
"frames": [
{
"code": "[]"
},
{
"code": "[\"World\"]"
},
{
"code": "[\"Hello\", \"World\"]"
},
{
"code": "[\n \"Hello\",\n \"World\"\n]"
}
],
"videoOptions": {
"fps": 60,
"width": 1920,
"height": 1080,
"transitionTime": 500,
"pauseTime": 1000
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment