Created
August 8, 2024 12:35
-
-
Save SirPepe/9dd1049699350f59161f9dffda1d945a to your computer and use it in GitHub Desktop.
Animated syntax highlighting, 4k 60 FPS edition
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"type": "module", | |
"dependencies": { | |
"@codemovie/code-movie": "^0.0.16", | |
"bezier-easing": "^2.1.0", | |
"puppeteer": "^23.0.1" | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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