Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,5 @@ llvm-project-llvmorg-*
.claude

*.code-workspace
/example/output.mp4
/example/lottie_extracted
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,92 @@ path.simplify().toSVGString()
// => "M89.005 3.818L2.933 89.89Q1.526 91.296 0.765 93.134Q0.004 94.972 0.004 96.961Q0.004 98.95 0.765 100.788Q1.526 102.625 2.933 104.032Q4.339 105.439 6.177 106.2Q8.015 106.961 10.004 106.961Q11.993 106.961 13.831 106.2Q15.668 105.439 17.075 104.032L96.076 25.031L175.077 104.032Q176.484 105.439 178.322 106.2Q180.159 106.961 182.148 106.961Q184.138 106.961 185.975 106.2Q187.813 105.439 189.219 104.032Q190.626 102.625 191.387 100.788Q192.148 98.95 192.148 96.961Q192.148 94.972 191.387 93.134Q190.626 91.296 189.22 89.89L103.147 3.818Q101.741 2.411 99.903 1.65Q98.065 0.889 96.076 0.889Q94.087 0.889 92.249 1.65Q90.412 2.411 89.005 3.818Z"
```

## Lottie Animation

Render [Lottie](https://airbnb.io/lottie/) animations using Skia's [Skottie](https://skia.org/docs/user/modules/skottie/) module.

### Load Animation

```js
const { LottieAnimation } = require('@napi-rs/canvas')

// Load from file
const animation = LottieAnimation.loadFromFile('animation.json')

// Load from JSON string with resource path for external assets
const animation = LottieAnimation.loadFromData(jsonString, {
resourcePath: '/path/to/assets',
})
```

### Animation Properties

```js
animation.duration // Total duration in seconds
animation.fps // Frames per second
animation.frames // Total frame count
animation.width // Animation width
animation.height // Animation height
animation.version // Lottie format version
```

### Playback Control

```js
animation.seekFrame(30) // Seek to frame 30
animation.seek(1.5) // Seek to 1.5 seconds
```

### Render to Canvas

```js
const { createCanvas, LottieAnimation } = require('@napi-rs/canvas')

const animation = LottieAnimation.loadFromFile('animation.json')
const canvas = createCanvas(animation.width, animation.height)
const ctx = canvas.getContext('2d')

// Render at original size
animation.render(ctx)

// Render with custom destination rect
animation.render(ctx, { x: 0, y: 0, width: 800, height: 600 })
```

### Supported Features

- **Embedded images** - Base64-encoded images (`data:image/png;base64,...`)
- **Embedded fonts** - Vector glyph paths for text rendering without system fonts
- **External assets** - Load images from `resourcePath` directory
- **dotLottie format** - Extract `.lottie` ZIP files at runtime (see example)

### Example: Encode Lottie to Video

See [`example/lottie-to-video.ts`](./example/lottie-to-video.ts) for encoding Lottie animations to MP4 using [`@napi-rs/webcodecs`](https://github.com/Brooooooklyn/webcodecs-node).

```js
import { createCanvas, LottieAnimation } from '@napi-rs/canvas'
import {
VideoEncoder,
VideoFrame,
Mp4Muxer,
type EncodedVideoChunk,
type EncodedVideoChunkMetadata,
} from '@napi-rs/webcodecs'

const animation = LottieAnimation.loadFromFile('animation.json')
const canvas = createCanvas(animation.width, animation.height)
const ctx = canvas.getContext('2d')

for (let frame = 0; frame < animation.frames; frame++) {
animation.seekFrame(frame)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
animation.render(ctx)
// Encode frame to video...
}
```

# [Example](./example/tiger.js)

> The tiger.json was serialized from [gojs/samples/tiger](https://github.com/NorthwoodsSoftware/GoJS/blob/master/samples/tiger.html)
Expand Down
1 change: 1 addition & 0 deletions example/LoopingCircless.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions example/flat-lottie.json

Large diffs are not rendered by default.

145 changes: 145 additions & 0 deletions example/lottie-to-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'


Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra empty line after the imports. Remove this blank line for consistency with the codebase formatting.

Suggested change

Copilot uses AI. Check for mistakes.
import { createCanvas, LottieAnimation } from '../index.js'
import {
VideoEncoder,
VideoFrame,
Mp4Muxer,
type EncodedVideoChunk,
type EncodedVideoChunkMetadata,
} from '@napi-rs/webcodecs'

const __dirname = new URL('.', import.meta.url).pathname

async function main() {
// Load the Lottie animation from extracted data
const animation = LottieAnimation.loadFromData(readFileSync(join(__dirname, 'LoopingCircless.json'), 'utf-8'))

console.log('Animation loaded:')
console.log(` Duration: ${animation.duration.toFixed(2)}s`)
console.log(` FPS: ${animation.fps}`)
console.log(` Frames: ${animation.frames}`)
console.log(` Size: ${animation.width}x${animation.height}`)
console.log(` Version: ${animation.version}`)

// Use original animation size (ensure dimensions are even for video codecs)
const encodedWidth = Math.round(animation.width) % 2 === 0 ? Math.round(animation.width) : Math.round(animation.width) + 1
const encodedHeight = Math.round(animation.height) % 2 === 0 ? Math.round(animation.height) : Math.round(animation.height) + 1

console.log(`\nOutput size: ${encodedWidth}x${encodedHeight}`)

// Create the canvas for rendering
const canvas = createCanvas(encodedWidth, encodedHeight)
const ctx = canvas.getContext('2d')

// Calculate frame duration in microseconds
const fps = animation.fps
const frameDurationUs = Math.round(1_000_000 / fps)
const totalFrames = Math.round(animation.frames)

// Collect all encoded chunks and metadata first (following webcodecs test pattern)
const videoChunks: EncodedVideoChunk[] = []
const videoMetadatas: (EncodedVideoChunkMetadata | undefined)[] = []

// Create video encoder
const encoder = new VideoEncoder({
output: (chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) => {
videoChunks.push(chunk)
videoMetadatas.push(meta)

const count = videoChunks.length
if (count % 30 === 0 || count === totalFrames) {
console.log(` Encoded ${count}/${totalFrames} frames`)
}
},
error: (e: Error) => {
console.error('Encoder error:', e)
},
})

// Configure encoder for H.264 Baseline (no B-frames for smoother playback)
encoder.configure({
codec: 'avc1.42001f', // H.264 Baseline Profile Level 3.1
width: encodedWidth,
height: encodedHeight,
bitrate: 5_000_000, // 5 Mbps
framerate: fps,
latencyMode: 'realtime', // Disable B-frames for smoother sequential playback
})

console.log('\nEncoding frames...')

// Render and encode each frame
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
// Seek to exact frame for precise animation timing
animation.seekFrame(frameIndex)

// Clear the canvas with white background
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, encodedWidth, encodedHeight)

// Render the animation with destination rect for proper scaling
// Note: ctx.scale() doesn't affect Skottie rendering - must use dst rect
animation.render(ctx, { x: 0, y: 0, width: encodedWidth, height: encodedHeight })

// Create a VideoFrame from the canvas
const timestamp = frameIndex * frameDurationUs
const frame = new VideoFrame(canvas, {
timestamp,
duration: frameDurationUs,
})

// Encode the frame (request keyframe every 2 seconds)
const isKeyFrame = frameIndex % Math.round(fps * 2) === 0
encoder.encode(frame, { keyFrame: isKeyFrame })

// Close the frame to release resources
frame.close()
}

// Flush the encoder to ensure all frames are processed
console.log('\nFlushing encoder...')
await encoder.flush()
encoder.close()

console.log(`\nCollected ${videoChunks.length} chunks`)

// Now create the muxer and add all chunks
// Note: fastStart is not compatible with in-memory muxing
const muxer = new Mp4Muxer()

// Get codec description from the first keyframe's metadata
const description = videoMetadatas[0]?.decoderConfig?.description

// Add video track with the codec description (avcC box for H.264)
muxer.addVideoTrack({
codec: 'avc1.42001f',
width: encodedWidth,
height: encodedHeight,
description,
})

console.log('Muxing chunks...')

// Add all chunks to the muxer
for (let i = 0; i < videoChunks.length; i++) {
muxer.addVideoChunk(videoChunks[i], videoMetadatas[i])
}

// Flush and finalize the muxer
console.log('Finalizing MP4...')
await muxer.flush()
const mp4Data = muxer.finalize()
muxer.close()

// Write to file
const outputPath = join(__dirname, 'output.mp4')
writeFileSync(outputPath, mp4Data)

console.log(`\nVideo saved to: ${outputPath}`)
console.log(`File size: ${(mp4Data.byteLength / 1024 / 1024).toFixed(2)} MB`)
}

main().catch(console.error)
96 changes: 96 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,3 +957,99 @@ export declare class PDFDocument {
endPage(): void
close(): Buffer
}

export interface LottieAnimationOptions {
/** Base path for resolving external resources (images, fonts) */
resourcePath?: string
}

export interface LottieRenderRect {
x: number
y: number
width: number
height: number
}

/**
* Lottie animation class for loading and rendering Lottie JSON files.
*
* @example
* ```typescript
* import { LottieAnimation, createCanvas } from '@napi-rs/canvas'
*
* const animation = LottieAnimation.loadFromFile('./animation.json')
* const canvas = createCanvas(animation.width, animation.height)
* const ctx = canvas.getContext('2d')
*
* // Render frame 0
* animation.seekFrame(0)
* animation.render(ctx)
*
* // Save to PNG
* const buffer = canvas.toBuffer('image/png')
* ```
*/
export declare class LottieAnimation {
/**
* Load animation from JSON string or Buffer
* @param data - JSON string or Buffer containing Lottie animation data
* @param options - Optional configuration
*/
static loadFromData(data: string | Buffer, options?: LottieAnimationOptions): LottieAnimation

/**
* Load animation from file path
* @param path - Path to the Lottie JSON file
* @param options - Optional configuration
*/
static loadFromFile(path: string, options?: LottieAnimationOptions): LottieAnimation

/** Animation duration in seconds */
readonly duration: number

/** Frame rate (frames per second) */
readonly fps: number

/** Total frame count */
readonly frames: number

/** Animation width in pixels */
readonly width: number

/** Animation height in pixels */
readonly height: number

/** Lottie format version string */
readonly version: string

/** Animation in-point (start frame) */
readonly inPoint: number

/** Animation out-point (end frame) */
readonly outPoint: number

/**
* Seek to normalized position
* @param t - Position between 0.0 (start) and 1.0 (end)
*/
seek(t: number): void

/**
* Seek to specific frame index
* @param frame - Frame index (0 = first frame)
*/
seekFrame(frame: number): void

/**
* Seek to specific time in seconds
* @param seconds - Time in seconds from start
*/
seekTime(seconds: number): void

/**
* Render current frame to canvas context
* @param ctx - Canvas 2D rendering context
* @param dst - Optional destination rectangle for scaling/positioning
*/
render(ctx: CanvasRenderingContext2D, dst?: LottieRenderRect): void
}
3 changes: 3 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
PdfDocument,
GifEncoder,
GifDisposal,
LottieAnimation,
} = require('./js-binding')

const { DOMPoint, DOMMatrix, DOMRect } = require('./geometry')
Expand Down Expand Up @@ -190,4 +191,6 @@ module.exports = {
// GIF encoding
GifEncoder,
GifDisposal,
// Lottie animation
LottieAnimation,
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"bench": "node --import @oxc-node/core/register benchmark/bench.ts",
"build": "napi build --platform --release --js js-binding.js",
"build:debug": "napi build --platform --js js-binding.js",
"example-lottie": "yarn oxnode ./example/lottie-to-video.ts",
"format": "run-p format:source format:rs format:toml",
"format:rs": "cargo fmt",
"format:source": "prettier . -w",
Expand All @@ -77,7 +78,9 @@
"@jimp/jpeg": "^0.22.12",
"@jimp/png": "^0.22.12",
"@napi-rs/cli": "^3.1.1",
"@napi-rs/webcodecs": "^1.1.0",
"@octokit/rest": "^22.0.0",
"@oxc-node/cli": "^0.0.35",
"@oxc-node/core": "^0.0.35",
"@taplo/cli": "^0.7.0",
"@types/lodash": "^4.17.17",
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-skia.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const GN_ARGS = [
`skia_enable_discrete_gpu=false`,
`skia_enable_ganesh=false`,
`skia_enable_pdf=true`,
`skia_enable_skottie=false`,
`skia_enable_skottie=true`,
`skia_enable_skshaper=true`,
`skia_enable_tools=false`,
`skia_enable_svg=true`,
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-skia-binary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if (TARGET && TARGET.startsWith('--target=')) {
TARGET_TRIPLE = TARGET.replace('--target=', '')
}

const LIB = ['skia', 'skparagraph', 'skshaper', 'svg', 'skunicode_core', 'skunicode_icu']
const LIB = ['skia', 'skparagraph', 'skshaper', 'svg', 'skunicode_core', 'skunicode_icu', 'skottie', 'skresources', 'sksg', 'jsonreader']
const ICU_DAT = 'icudtl.dat'

const CLIENT = new Octokit({
Expand Down
Loading