@effing/annie
Generate and read TAR archives of PNG, JPEG or QOI frames for animated layers.
Part of the Effing family — programmatic video creation with TypeScript.
Annie is a simple animation format: a TAR archive containing sequentially-named PNG, JPEG or QOI frames. Generate frames server-side, stream them to the browser or FFmpeg, and read them back with the same package.
Installation
npm install @effing/annie
Concepts
Annie Format
An Annie is a TAR archive where each entry is a PNG, JPEG or QOI frame:
frame_00000
frame_00001
frame_00002
...
Frames may vary in pixel format, channels (with or without alpha) and size, but all frames of an annie must be one format: all PNG, all JPEG or all QOI. FFS and FFmpeg decode an annie with the decoder for its first frame.
This format is:
- Streamable — frames can be written as they're generated
- Universal — TAR is supported everywhere
- FFmpeg-compatible — can be piped directly to FFmpeg as image input
Generation Patterns
Annie supports two generation patterns:
Buffer — Collect all frames, return complete archive:
const archive = await annieBuffer(frames);
Stream — Yield chunks as frames are generated:
const stream = annieStream(frames);
Quick Start
import { annieStream, annieBuffer } from "@effing/annie";
import { createCanvas, renderReactElement } from "@effing/canvas";
import { tween, easeOutQuad } from "@effing/tween";
// Define a frame generator
async function* generateFrames() {
yield* tween(90, async ({ lower: progress }) => {
const scale = 1 + 0.3 * easeOutQuad(progress);
const canvas = createCanvas(1080, 1920);
const ctx = canvas.getContext("2d");
await renderReactElement(
ctx,
<div style={{
width: 1080,
height: 1920,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 72,
transform: `scale(${scale})`
}}>
Hello World!
</div>,
{ fonts: myFonts }
);
return canvas.encode("png");
});
}
// Stream response
const stream = annieStream(generateFrames(), { signal: request.signal });
return new Response(stream, {
headers: { "Content-Type": "application/x-tar" }
});
API Overview
Functions
annieStream(frames, options?)
Create a ReadableStream that produces TAR chunks as frames are generated.
function annieStream(
frames: AsyncIterable<Buffer>,
options?: AnnieStreamOptions,
): ReadableStream<Buffer>;
Options:
signal— AbortSignal for cancellation
annieBuffer(frames)
Collect all frames and return a complete TAR buffer.
function annieBuffer(frames: AsyncIterable<Buffer>): Promise<Buffer>;
annieFrames(source)
Async-iterate the frames of an existing Annie, in ascending index order.
function annieFrames(source: AnnieSource): AsyncGenerator<AnnieFrame>;
The source can be a Uint8Array/ArrayBuffer, an (async) iterable of byte chunks (such as a Node.js Readable), or a Web ReadableStream. Each frame carries its index, entry name, raw data bytes, and a contentType sniffed from the frame's magic bytes (the archive itself does not record the image format): "image/png", "image/jpeg", "image/qoi", or "application/octet-stream" for anything else.
sniffImageContentType(bytes)
Detect a PNG, JPEG or QOI image from its leading bytes, the same way annieFrames does: the full 8-byte PNG signature, FF D8 FF for JPEG, or qoif for QOI (with 3 or 4 channels and colorspace 0 or 1 when the 14-byte header is there). Returns "image/png", "image/jpeg", "image/qoi" or undefined.
function sniffImageContentType(bytes: Uint8Array): ImageContentType | undefined;
Examples
With Express/Node.js
import { Readable } from "stream";
import { annieStream } from "@effing/annie";
app.get("/animation.tar", async (req, res) => {
const stream = annieStream(generateFrames());
res.setHeader("Content-Type", "application/x-tar");
Readable.fromWeb(stream).pipe(res);
});
With React Router / Remix
import { annieStream } from "@effing/annie";
export async function loader({ request }: LoaderFunctionArgs) {
const stream = annieStream(generateFrames(), { signal: request.signal });
return new Response(stream, {
headers: { "Content-Type": "application/x-tar" },
});
}
Saving to File
import { writeFile } from "fs/promises";
import { annieBuffer } from "@effing/annie";
const buffer = await annieBuffer(generateFrames());
await writeFile("animation.tar", buffer);
Reading an Annie
import { readFile } from "fs/promises";
import { annieFrames } from "@effing/annie";
for await (const frame of annieFrames(await readFile("animation.tar"))) {
console.log(frame.name, frame.contentType, frame.data.length);
}
FFmpeg Integration
Note that Annie TAR archives can be piped directly to FFmpeg. Extract the frames with tar -xO and pipe to FFmpeg's image2pipe input. This works when every frame has the same format; QOI frames need FFmpeg 5.1 or newer.
# Create animated PNG (loops forever)
tar -xO < animation.tar | ffmpeg -f image2pipe -framerate 30 -i - -plays 0 -c:v apng -f apng output.png
# Create MP4 video
tar -xO < animation.tar | ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -pix_fmt yuv420p output.mp4
# Create GIF
tar -xO < animation.tar | ffmpeg -f image2pipe -framerate 30 -i - output.gif
Related Packages
@effing/tween— Step iteration and easing functions for frame generation@effing/canvas— Render JSX to a PNG, JPEG or QOI image for each frame@effing/annie-player— Play Annies in the browser@effing/effie— Use Annies as layers in video compositions