High-performance Lottie animation frame renderer using Skia. Renders animations to PNG frames for video encoding.
brew tap matrunchyk/lotio
brew install lotioThis installs:
- Binary:
lotio - Headers:
/opt/homebrew/include/lotio/(or/usr/local/include/lotio/) - Libraries:
/opt/homebrew/lib/(Skia static libraries)
Prerequisites (macOS):
brew install fontconfig freetype harfbuzz icu4c libpng ninja python@3.11
xcode-select --installBuild:
# Build lotio (binary build with zero bundled dependencies)
./scripts/build_binary.shlotio --data <input.json> [--output-format <format>] [--output <file>] [--debug] [--layer-overrides <config.json>] [--fonts <dir>] [--text-padding <0.0-1.0>] [--text-measurement-mode <fast|accurate|pixel-perfect>] [--fps <fps>] <output_dir>Options:
--data <input.json>- Path to input Lottie animation JSON file (required)--output-format <format>- Output format:png(default),raw,ffv1, ormov--output <file>- Output file path (use-to stream to stdout, required forraw,ffv1,movformats)--debug- Enable debug output--layer-overrides <config.json>- Path to layer overrides JSON (for text auto-fit, dynamic text values, and image path overrides)--fonts <dir>- Directory containing font files (.ttf); fonts are looked up here first, then in the JSON directory--text-padding <0.0-1.0>- Text padding factor (0.0-1.0, default: 0.97 = 3% padding)--text-measurement-mode <fast|accurate|pixel-perfect>- Text measurement mode:fast|accurate|pixel-perfect(default:accurate)--fps <fps>- Frames per second for output (optional, default: animation fps or 30)--version- Print version information and exit--help, -h- Show help message
Output Formats:
png(default) - PNG frames written to directory (or streamed to stdout with--output -)raw- Uncompressed RGBA video file (fastest, largest files, preserves alpha)ffv1- Lossless FFV1 codec in Matroska container (good compression, preserves alpha)mov- MOV container with QTRLE codec (fast encoding, preserves alpha, widely compatible)
Examples:
# Render to PNG frames (default)
lotio --data animation.json --fps 30 frames/
# Direct video encoding (no ffmpeg binary needed)
lotio --data animation.json --output-format mov --output video.mov --fps 30
lotio --data animation.json --output-format ffv1 --output video.mkv --fps 30
lotio --data animation.json --output-format raw --output video.rgb --fps 30
# Stream PNG to stdout (for piping to ffmpeg)
lotio --data animation.json --output-format png --output - --fps 30 | ffmpeg -f image2pipe -i - output.mp4
# With layer overrides
lotio --data animation.json --layer-overrides layer-overrides.json --fps 30 frames/
# With custom font directory
lotio --data animation.json --fonts ./fonts --fps 30 frames/Quick start:
docker run --rm -v $(pwd):/workspace matrunchyk/lotio:latest \
--data data.json --fps 30 --layer-overrides layer-overrides.json --output-format mov --output video.movAvailable images:
matrunchyk/lotio:latest- lotio binary with built-in video encoding support
Multi-platform support: The image supports linux/arm64 and linux/amd64.
See Docker Documentation for detailed usage.
Install from npm:
npm install lotioBasic Usage:
import Lotio, { State, TextMeasurementMode } from 'lotio';
// Load fonts
const fontResponse = await fetch('./fonts/OpenSans-Bold.ttf');
const fontData = new Uint8Array(await fontResponse.arrayBuffer());
// Load animation
const animationResponse = await fetch('./animation.json');
const animationData = await animationResponse.json();
// Create animation instance
const animation = new Lotio({
fonts: [{ name: 'OpenSans-Bold', data: fontData }],
fps: 30,
animation: animationData,
layerOverrides: { /* optional layer overrides */ },
textPadding: 0.97, // Optional: text padding factor (default: 0.97)
textMeasurementMode: TextMeasurementMode.ACCURATE, // Optional: TextMeasurementMode.FAST | TextMeasurementMode.ACCURATE | TextMeasurementMode.PIXEL_PERFECT
wasmPath: './lotio.wasm'
});
// Event handlers (fluent interface)
animation
.on('error', (error, anim) => {
console.error('Animation error:', error);
})
.on('loaded', (anim) => {
console.log('Animation loaded');
anim.start();
})
.on('start', (anim) => {
console.log('Animation started');
})
.on('pause', (anim) => {
console.log('Animation paused');
})
.on('stop', (anim) => {
console.log('Animation stopped');
})
.on('end', (anim) => {
console.log('Animation ended');
})
.on('frame', (frameNumber, time, anim) => {
// Render to canvas
const canvas = document.getElementById('canvas');
anim.renderToCanvas(canvas, '#2a2a2a');
});
// Control methods
animation
.setFps(60) // Change FPS
.seek(10) // Seek to frame 10
.start() // Start playback
.pause() // Pause
.stop(); // Stop and reset
// Getters
const fps = animation.getFps();
const state = animation.getState(); // 'stopped' | 'paused' | 'loaded' | 'error' | 'playing'
const frame = animation.getCurrentFrame();
const info = animation.getAnimationInfo();
// Render current frame to canvas
const canvas = document.getElementById('canvas');
animation.renderToCanvas(canvas, '#ffffff');
// Cleanup
animation.destroy();Full Example with Canvas:
<!DOCTYPE html>
<html>
<head>
<title>Lotio Animation</title>
</head>
<body>
<canvas id="canvas"></canvas>
<button id="playBtn">Play</button>
<button id="pauseBtn">Pause</button>
<button id="stopBtn">Stop</button>
<script type="module">
import Lotio from 'lotio';
let animation;
async function init() {
// Load font
const fontRes = await fetch('./fonts/OpenSans-Bold.ttf');
const fontData = new Uint8Array(await fontRes.arrayBuffer());
// Load animation
const animRes = await fetch('./animation.json');
const animData = await animRes.json();
// Create animation
animation = new Lotio({
fonts: [{ name: 'OpenSans-Bold', data: fontData }],
fps: 30,
animation: animData,
wasmPath: './lotio.wasm'
});
const canvas = document.getElementById('canvas');
// Render frames
animation.on('frame', () => {
animation.renderToCanvas(canvas);
});
// Controls
document.getElementById('playBtn').onclick = () => animation.start();
document.getElementById('pauseBtn').onclick = () => animation.pause();
document.getElementById('stopBtn').onclick = () => animation.stop();
}
init();
</script>
</body>
</html>The samples/ directory contains example Lottie animations and configurations:
-
samples/sample1/- Basic animation with layer overridesdata.json- Lottie animation filelayer-overrides.json- Text and image customization configurationoutput/- Rendered frames (run lotio to generate)
-
samples/sample2/- Animation with external imagesdata.json- Lottie animation file with image referencesimages/- External image assets referenced by the animationoutput/- Rendered frames (run lotio to generate)
-
samples/sample5/- Text under track matte (Bodymovin-style,tpomitted); lotio's Skottie build includes a track-matte patch so the matte source is the nearest prior layer withtd !== 0and it renders correctly without editing data.json.
Try the samples:
# Sample 1: Basic animation with text customization
cd samples/sample1
lotio --data data.json --layer-overrides layer-overrides.json --fps 30 output/
# Sample 2: Animation with external images
cd samples/sample2
lotio --data data.json --fps 30 output/Headers are installed at /opt/homebrew/include/lotio/ (or /usr/local/include/lotio/):
#include <lotio/core/animation_setup.h>
#include <lotio/text/text_processor.h>
#include <lotio/utils/logging.h>Link with Skia libraries:
g++ -I/opt/homebrew/include -L/opt/homebrew/lib \
-llotio -lskottie -lskia -lskparagraph -lsksg -lskshaper \
-lskunicode_icu -lskunicode_core -lskresources -ljsonreader \
your_app.cpp -o your_appOr use pkg-config (recommended):
g++ $(pkg-config --cflags --libs lotio) your_app.cpp -o your_appThe lotio package includes Skia headers and libraries, so you can use Skia features directly in your code:
// Use Skia directly
#include <skia/core/SkCanvas.h>
#include <skia/core/SkSurface.h>
#include <skia/modules/skottie/include/Skottie.h>
// Use lotio
#include <lotio/core/animation_setup.h>
int main() {
// Use Skia API directly
SkImageInfo info = SkImageInfo::MakeN32(800, 600, kOpaque_SkAlphaType);
auto surface = SkSurfaces::Raster(info);
// Use lotio functions
AnimationSetupResult result = setupAndCreateAnimation("input.json", "");
return 0;
}Compile with:
g++ $(pkg-config --cflags --libs lotio) your_app.cpp -o your_appThe pkg-config file includes all necessary include paths:
-I${includedir}- Lotio headers-I${includedir}/skia- Skia core headers-I${includedir}/skia/gen- Skia generated headers
The project uses GitHub Actions workflows for automated building, testing, and deployment:
graph TB
subgraph triggers["Event Triggers"]
mainPush["Push to main<br/>(creates semver tag)"]
tagPush["Tag push v*"]
lotioChanges["Changes to<br/>src/** or Dockerfile.lotio<br/>or build_binary.sh"]
docsChanges["Changes to<br/>docs/** or examples/**"]
manual["Manual<br/>workflow_dispatch"]
end
subgraph lotioWorkflow["build-lotio.yml<br/>Concurrency: build-lotio<br/>Cancel in-progress: true"]
buildLotioImg["Build Skia & lotio<br/>push matrunchyk/lotio"]
end
subgraph releaseWorkflow["release.yml<br/>Concurrency: release<br/>Cancel in-progress: true"]
versionTag["Generate version<br/>& create tag"]
buildMac["Build macOS<br/>binary & dev package"]
buildWasm["Build WASM<br/>library"]
buildHomebrew["Build Homebrew<br/>bottle"]
buildDocs["Build<br/>documentation"]
publishRelease["Create GitHub<br/>release"]
versionTag --> buildMac
versionTag --> buildWasm
versionTag --> buildHomebrew
buildWasm --> buildDocs
buildHomebrew --> buildDocs
buildDocs --> publishRelease
end
subgraph testWorkflow["test.yml<br/>Concurrency: test<br/>Cancel in-progress: true"]
testDocker["Test Docker<br/>image"]
testWasm["Test JS/WASM<br/>library"]
testHomebrew["Test Homebrew<br/>package"]
end
subgraph pagesWorkflow["pages.yml<br/>Concurrency: pages<br/>Cancel in-progress: false"]
buildPages["Build & deploy<br/>documentation"]
end
mainPush --> lotioWorkflow
mainPush --> releaseWorkflow
mainPush --> pagesWorkflow
tagPush --> lotioWorkflow
tagPush --> releaseWorkflow
lotioChanges --> lotioWorkflow
docsChanges --> pagesWorkflow
manual --> lotioWorkflow
manual --> releaseWorkflow
manual --> testWorkflow
manual --> pagesWorkflow
lotioWorkflow -->|Docker image ready| releaseWorkflow
releaseWorkflow -->|workflow_run<br/>after completion| testWorkflow
style lotioWorkflow fill:#e1f5ff
style releaseWorkflow fill:#fff4e1
style testWorkflow fill:#e8f5e9
style pagesWorkflow fill:#f3e5f5
build-lotio.yml - Builds and publishes matrunchyk/lotio Docker image
- Purpose: Create lotio binary Docker image using pre-built Skia base image
- Triggers: Main branch push, tag pushes, source code changes, Dockerfile.lotio changes, build_binary.sh changes, manual dispatch
- Logic: Uses
matrunchyk/skia:latestas base image (Skia pre-built), only compiles lotio source - Build chain:
Dockerfile.skia→Dockerfile.lotio(uses pre-built Skia) - Concurrency: Single instance per workflow (cancels in-progress runs when new one starts)
- Output:
matrunchyk/lotio:latestandmatrunchyk/lotio:v1.2.3(multi-platform: arm64, amd64) - Architecture tags: Also creates
-arm64and-amd64tags for clarity
release.yml - Builds all release artifacts and creates GitHub release
- Purpose: Build and package all distribution formats (binaries, WASM, Homebrew, docs)
- Triggers: Push to main (creates semver tag automatically), tag pushes (v*), manual dispatch
- Logic:
- Generates semver version from tag or creates new tag on main push
- Builds Skia from scratch using
build_binary.sh(zero bundled dependencies, fast build) - Builds in parallel: macOS, Linux, WASM, Homebrew
- Injects version into all artifacts
- Concurrency: Single instance per workflow (cancels in-progress runs when new one starts)
- Output: macOS dev package, WASM package, Homebrew bottle, GitHub release
test.yml - Integration tests for all built artifacts
- Purpose: Validate that all release artifacts work correctly
- Triggers: After
release.ymlcompletes successfully, manual dispatch - Tests:
- Docker image:
--help,--version, library functionality, video generation with--debug - JS/WASM library: Load, API functions, frame rendering
- Homebrew package: Installation,
--help,--version, basic functionality
- Docker image:
- Concurrency: Single instance per workflow (cancels in-progress runs when new one starts)
pages.yml - Builds and deploys documentation to GitHub Pages
- Purpose: Generate and deploy documentation with version injection
- Triggers: Changes to docs, examples, or build scripts; manual dispatch
- Logic: Installs lotio npm package, injects version from git tag
- Concurrency: Single instance per workflow (does not cancel in-progress runs)
- Output: Deployed to GitHub Pages
src/
├── core/ # Core functionality (argument parsing, animation setup, rendering)
├── text/ # Text processing (configuration, font handling, sizing)
└── utils/ # Utilities (logging, string utils, crash handling)
The project includes IDE configuration for Cursor/VS Code:
.vscode/c_cpp_properties.json- C/C++ extension settings.clangd- clangd language server settings
Reload Cursor/VS Code after cloning: Cmd+Shift+P → "Reload Window"
Skia build fails:
- Ensure all dependencies are installed
- Check sufficient disk space (Skia build is large)
- Review error messages in
scripts/build_binary.shoutput
Linker errors:
- Verify Skia libraries exist in
third_party/skia/skia/out/Release/ - Check library paths in build script
IDE include errors:
- Reload Cursor/VS Code
- Verify
.vscode/c_cpp_properties.jsonhas correct paths
See individual component licenses:
- Skia:
third_party/skia/skia/LICENSE