micro-lottie-react
Version:
The smallest React Lottie player. 15KB. Zero deps. Supports .lottie files.
1,314 lines (1,309 loc) • 103 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react/jsx-runtime'), require('react')) :
typeof define === 'function' && define.amd ? define(['exports', 'react/jsx-runtime', 'react'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.MicroLottieReact = {}, global.jsxRuntime, global.React));
})(this, (function (exports, jsxRuntime, react) { 'use strict';
/**
* Efficient file loader for Lottie animations
* Supports both .json and .lottie formats with caching and retry logic
*/
class LottieLoader {
/**
* Load Lottie animation from URL
*/
static async load(url, options = {}) {
const cacheKey = this.getCacheKey(url, options);
// Return cached result if available
if (options.cache !== false && this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
const loadPromise = this.performLoad(url, {
url,
method: "GET",
timeout: this.DEFAULT_TIMEOUT,
retry: this.DEFAULT_RETRY,
cache: true,
...options,
});
// Cache the promise if caching is enabled
if (options.cache !== false) {
this.cache.set(cacheKey, loadPromise);
}
return loadPromise;
}
/**
* Load animation data from various sources
*/
static async loadFromSource(source) {
const startTime = performance.now();
if (typeof source === "string") {
// URL or JSON string
if (this.isUrl(source)) {
return this.load(source);
}
else {
// JSON string
return {
data: source,
format: "json",
size: source.length,
loadTime: performance.now() - startTime,
};
}
}
else if (source instanceof ArrayBuffer) {
// Binary data (.lottie file)
return {
data: source,
format: "lottie",
size: source.byteLength,
loadTime: performance.now() - startTime,
};
}
else if (typeof source === "object") {
// Animation data object
const jsonString = JSON.stringify(source);
return {
data: jsonString,
format: "json",
size: jsonString.length,
loadTime: performance.now() - startTime,
};
}
throw new Error("Unsupported source type");
}
/**
* Preload animations for better performance
*/
static async preload(urls, options = {}) {
const loadPromises = urls.map((url) => this.load(url, options));
return Promise.all(loadPromises);
}
/**
* Clear cache to free memory
*/
static clearCache() {
this.cache.clear();
}
/**
* Get cache size information
*/
static getCacheInfo() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys()),
};
}
/**
* Remove specific item from cache
*/
static removeCacheItem(url, options = {}) {
const cacheKey = this.getCacheKey(url, options);
return this.cache.delete(cacheKey);
}
/**
* Perform the actual load operation
*/
static async performLoad(url, options) {
const startTime = performance.now();
let lastError = null;
const retryCount = options.retry || this.DEFAULT_RETRY;
for (let attempt = 0; attempt < retryCount; attempt++) {
try {
const response = await this.fetchWithTimeout(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const format = this.detectFormat(url, response);
let data;
let size;
if (format === "lottie") {
data = await response.arrayBuffer();
size = data.byteLength;
}
else {
data = await response.text();
size = data.length;
}
return {
data,
format,
size,
loadTime: performance.now() - startTime,
};
}
catch (error) {
lastError = error;
// Don't retry on certain errors
if (this.isNonRetryableError(error)) {
break;
}
// Wait before retrying (exponential backoff)
if (attempt < retryCount - 1) {
await this.delay(Math.pow(2, attempt) * 100);
}
}
}
throw lastError || new Error("Failed to load animation");
}
/**
* Fetch with timeout support
*/
static async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
try {
const response = await fetch(url, {
method: options.method,
headers: options.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
}
catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Request timeout after ${options.timeout}ms`);
}
throw error;
}
}
/**
* Detect file format from URL and response
*/
static detectFormat(url, response) {
// Check file extension
const urlLower = url.toLowerCase();
if (urlLower.endsWith(".lottie")) {
return "lottie";
}
if (urlLower.endsWith(".json")) {
return "json";
}
// Check content type
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return "json";
}
if (contentType.includes("application/octet-stream") ||
contentType.includes("application/zip")) {
return "lottie";
}
// Default to JSON for unknown types
return "json";
}
/**
* Check if string is a URL
*/
static isUrl(str) {
try {
new URL(str);
return true;
}
catch (_a) {
// Check for relative URLs
return (str.startsWith("/") || str.startsWith("./") || str.startsWith("../"));
}
}
/**
* Check if error should not be retried
*/
static isNonRetryableError(error) {
const message = error.message.toLowerCase();
return (message.includes("404") ||
message.includes("403") ||
message.includes("401") ||
message.includes("syntax error") ||
message.includes("invalid json"));
}
/**
* Create cache key from URL and options
*/
static getCacheKey(url, options) {
const relevantOptions = {
method: options.method || "GET",
headers: options.headers || {},
};
return `${url}:${JSON.stringify(relevantOptions)}`;
}
/**
* Delay helper for retry logic
*/
static delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Validate loaded animation data
*/
static validateAnimationData(data, format) {
try {
if (format === "json") {
const parsed = JSON.parse(data);
return this.isValidLottieJson(parsed);
}
else {
return this.isValidLottieFile(data);
}
}
catch (_a) {
return false;
}
}
/**
* Check if JSON data is valid Lottie format
*/
static isValidLottieJson(data) {
return (typeof data === "object" &&
data !== null &&
typeof data.v === "string" &&
typeof data.w === "number" &&
typeof data.h === "number" &&
Array.isArray(data.layers));
}
/**
* Check if binary data is valid .lottie file
*/
static isValidLottieFile(data) {
if (data.byteLength < 4)
return false;
const header = new Uint8Array(data.slice(0, 4));
const zipSignature = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
return header.every((byte, index) => byte === zipSignature[index]);
}
/**
* Get file size in human readable format
*/
static formatFileSize(bytes) {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Estimate animation complexity
*/
static estimateComplexity(data) {
if (typeof data === "string") {
try {
data = JSON.parse(data);
}
catch (_a) {
return "low";
}
}
if (!data || !Array.isArray(data.layers)) {
return "low";
}
const layerCount = data.layers.length;
const totalShapes = data.layers.reduce((total, layer) => {
return total + (layer.shapes ? layer.shapes.length : 0);
}, 0);
if (layerCount < 5 && totalShapes < 10) {
return "low";
}
else if (layerCount < 20 && totalShapes < 50) {
return "medium";
}
else {
return "high";
}
}
/**
* Optimize animation data for better performance
*/
static optimizeForPerformance(data) {
if (typeof data === "string") {
try {
data = JSON.parse(data);
}
catch (_a) {
return data;
}
}
const optimized = JSON.parse(JSON.stringify(data));
// Remove unused assets
if (optimized.assets) {
const usedAssets = new Set();
const findUsedAssets = (layers) => {
layers.forEach((layer) => {
if (layer.refId)
usedAssets.add(layer.refId);
if (layer.layers)
findUsedAssets(layer.layers);
});
};
findUsedAssets(optimized.layers);
optimized.assets = optimized.assets.filter((asset) => usedAssets.has(asset.id));
}
// Round numbers to reduce precision
this.roundNumbers(optimized, 2);
return optimized;
}
/**
* Round numbers in object to reduce file size
*/
static roundNumbers(obj, precision = 2) {
if (typeof obj === "number") {
return (Math.round(obj * Math.pow(10, precision)) / Math.pow(10, precision));
}
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
if (typeof item === "number") {
obj[index] =
Math.round(item * Math.pow(10, precision)) /
Math.pow(10, precision);
}
else if (typeof item === "object" && item !== null) {
this.roundNumbers(item, precision);
}
});
}
else if (typeof obj === "object" && obj !== null) {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === "number") {
obj[key] =
Math.round(obj[key] * Math.pow(10, precision)) /
Math.pow(10, precision);
}
else if (typeof obj[key] === "object") {
this.roundNumbers(obj[key], precision);
}
});
}
}
}
LottieLoader.cache = new Map();
LottieLoader.DEFAULT_TIMEOUT = 10000; // 10 seconds
LottieLoader.DEFAULT_RETRY = 3;
/**
* Ultra-lightweight Lottie parser
* Parses both JSON and binary .lottie formats without dependencies
*/
class LottieParser {
/**
* Parse Lottie animation data
*/
static async parse(options) {
// const startTime = performance.now(); // TODO: Add performance tracking
try {
let data;
if (options.format === "lottie" || this.isLottieFormat(options.data)) {
data = await this.parseLottieFile(options.data);
}
else {
data = this.parseJSON(options.data);
}
// const parseTime = performance.now() - startTime; // TODO: Add performance tracking
return {
data,
duration: this.calculateDuration(data),
frameRate: data.fr || 30,
totalFrames: data.op - data.ip || 0,
width: data.w || 512,
height: data.h || 512,
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to parse Lottie animation: ${errorMessage}`);
}
}
/**
* Check if data is in .lottie (binary) format
*/
static isLottieFormat(data) {
if (!data || !(data instanceof ArrayBuffer)) {
return false;
}
const header = new Uint8Array(data.slice(0, 4));
return this.arrayEquals(header, this.LOTTIE_MAGIC);
}
/**
* Parse binary .lottie file (ZIP format)
*/
static async parseLottieFile(buffer) {
if (buffer.byteLength > this.MAX_FILE_SIZE) {
throw new Error("File too large");
}
// Simplified ZIP parsing - extract the main animation.json
const view = new DataView(buffer);
// let offset = 0; // TODO: Implement full ZIP parsing if needed
// Find central directory
const cdOffset = this.findCentralDirectory(view);
if (cdOffset === -1) {
throw new Error("Invalid .lottie file format");
}
// Extract animation.json
const animationData = this.extractAnimationJson(view, cdOffset);
return JSON.parse(animationData);
}
/**
* Parse JSON format
*/
static parseJSON(jsonString) {
try {
const data = JSON.parse(jsonString);
this.validateLottieData(data);
return data;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Invalid JSON format: ${errorMessage}`);
}
}
/**
* Find central directory in ZIP file
*/
static findCentralDirectory(view) {
// Simplified - look for end of central directory signature
const signature = 0x06054b50;
for (let i = view.byteLength - 22; i >= 0; i--) {
if (view.getUint32(i, true) === signature) {
const cdOffset = view.getUint32(i + 16, true);
return cdOffset;
}
}
return -1;
}
/**
* Extract animation.json from ZIP central directory
*/
static extractAnimationJson(view, cdOffset) {
// Simplified extraction - assumes animation.json is the first file
const signature = view.getUint32(cdOffset, true);
if (signature !== 0x02014b50) {
// Central directory file header signature
throw new Error("Invalid central directory");
}
const fileNameLength = view.getUint16(cdOffset + 28, true);
const fileName = this.readString(view, cdOffset + 46, fileNameLength);
if (!fileName.includes("animation.json")) {
throw new Error("animation.json not found in .lottie file");
}
const localHeaderOffset = view.getUint32(cdOffset + 42, true);
const compressedSize = view.getUint32(cdOffset + 20, true);
// Skip local file header (30 bytes + filename + extra field)
const localFileNameLength = view.getUint16(localHeaderOffset + 26, true);
const localExtraFieldLength = view.getUint16(localHeaderOffset + 28, true);
const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength;
// Read compressed data (assuming no compression for simplicity)
return this.readString(view, dataOffset, compressedSize);
}
/**
* Read string from DataView
*/
static readString(view, offset, length) {
const bytes = new Uint8Array(view.buffer, offset, length);
return new TextDecoder().decode(bytes);
}
/**
* Validate Lottie data structure
*/
static validateLottieData(data) {
if (!data || typeof data !== "object") {
throw new Error("Invalid Lottie data structure");
}
const requiredFields = ["v", "w", "h", "layers"];
for (const field of requiredFields) {
if (!(field in data)) {
throw new Error(`Missing required field: ${field}`);
}
}
if (!Array.isArray(data.layers)) {
throw new Error("Layers must be an array");
}
if (typeof data.w !== "number" || typeof data.h !== "number") {
throw new Error("Width and height must be numbers");
}
}
/**
* Calculate animation duration in milliseconds
*/
static calculateDuration(data) {
const frameRate = data.fr || 30;
const totalFrames = (data.op || 0) - (data.ip || 0);
return (totalFrames / frameRate) * 1000;
}
/**
* Compare two Uint8Arrays
*/
static arrayEquals(a, b) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
}
/**
* Optimize animation data for performance
*/
static optimize(data) {
// Create a deep copy to avoid mutating original data
const optimized = JSON.parse(JSON.stringify(data));
// Remove unnecessary properties
this.removeUnusedAssets(optimized);
this.simplifyPaths(optimized);
this.roundNumbers(optimized);
return optimized;
}
/**
* Remove unused assets to reduce memory usage
*/
static removeUnusedAssets(data) {
if (!data.assets || !Array.isArray(data.assets))
return;
const usedAssets = new Set();
// Find used asset IDs in layers
const findUsedAssets = (layers) => {
layers.forEach((layer) => {
if (layer.refId)
usedAssets.add(layer.refId);
if (layer.layers)
findUsedAssets(layer.layers);
});
};
findUsedAssets(data.layers);
// Filter out unused assets
data.assets = data.assets.filter((asset) => usedAssets.has(asset.id));
}
/**
* Simplify complex paths for better performance
*/
static simplifyPaths(data) {
// Simplified path optimization
// In a real implementation, this would intelligently reduce path complexity
const simplifyLayer = (layer) => {
if (layer.shapes) {
layer.shapes.forEach((shape) => {
if (shape.it) {
this.simplifyShapeItems(shape.it);
}
});
}
if (layer.layers) {
layer.layers.forEach(simplifyLayer);
}
};
data.layers.forEach(simplifyLayer);
}
/**
* Simplify shape items
*/
static simplifyShapeItems(items) {
items.forEach((item) => {
if (item.ty === "sh" && item.ks && item.ks.k && item.ks.k.v) {
// Simplify bezier paths by reducing control points
const vertices = item.ks.k.v;
if (vertices.length > 100) {
// Reduce complexity for performance
item.ks.k.v = this.reducePathComplexity(vertices);
}
}
});
}
/**
* Reduce path complexity by removing redundant points
*/
static reducePathComplexity(vertices) {
if (vertices.length <= 3)
return vertices;
const simplified = [vertices[0]];
const tolerance = 0.5;
for (let i = 1; i < vertices.length - 1; i++) {
const prev = vertices[i - 1];
const curr = vertices[i];
const next = vertices[i + 1];
// Check if current point is significant
const distance = this.pointToLineDistance(curr, prev, next);
if (distance > tolerance) {
simplified.push(curr);
}
}
simplified.push(vertices[vertices.length - 1]);
return simplified;
}
/**
* Calculate distance from point to line
*/
static pointToLineDistance(point, lineStart, lineEnd) {
const [px, py] = point;
const [x1, y1] = lineStart;
const [x2, y2] = lineEnd;
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
if (lenSq === 0)
return Math.sqrt(A * A + B * B);
const param = dot / lenSq;
const xx = param < 0 ? x1 : param > 1 ? x2 : x1 + param * C;
const yy = param < 0 ? y1 : param > 1 ? y2 : y1 + param * D;
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Round numbers to reduce precision and file size
*/
static roundNumbers(obj, precision = 3) {
if (typeof obj === "number") {
return (Math.round(obj * Math.pow(10, precision)) / Math.pow(10, precision));
}
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
if (typeof item === "number") {
obj[index] =
Math.round(item * Math.pow(10, precision)) /
Math.pow(10, precision);
}
else if (typeof item === "object") {
this.roundNumbers(item, precision);
}
});
}
else if (typeof obj === "object" && obj !== null) {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === "number") {
obj[key] =
Math.round(obj[key] * Math.pow(10, precision)) /
Math.pow(10, precision);
}
else if (typeof obj[key] === "object") {
this.roundNumbers(obj[key], precision);
}
});
}
}
}
LottieParser.LOTTIE_MAGIC = new Uint8Array([
0x50, 0x4b, 0x03, 0x04,
]); // ZIP signature
LottieParser.MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB limit
/**
* High-performance Canvas renderer for Lottie animations
* Optimized for mobile devices and complex animations
*/
class CanvasRenderer {
constructor(container, animationData) {
this.currentFrame = 0;
this.scaleFactor = 1;
this.isDestroyed = false;
this.animationData = animationData;
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d");
if (!this.ctx) {
throw new Error("Canvas 2D context not supported");
}
this.setupCanvas(container);
this.transformMatrix = new DOMMatrix();
}
/**
* Setup canvas element and context
*/
setupCanvas(container) {
const { w: width, h: height } = this.animationData;
// Set canvas size
this.canvas.width = width;
this.canvas.height = height;
// Set display size
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.canvas.style.display = "block";
// Calculate scale factor for high DPI displays
const dpr = window.devicePixelRatio || 1;
this.scaleFactor = dpr;
// Scale canvas for high DPI
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.ctx.scale(dpr, dpr);
// Optimize rendering
this.ctx.imageSmoothingEnabled = true;
this.ctx.imageSmoothingQuality = "high";
container.appendChild(this.canvas);
}
/**
* Render frame at specific time
*/
render(frame) {
if (this.isDestroyed)
return;
this.currentFrame = frame;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Save context state
this.ctx.save();
try {
// Render all layers
this.renderLayers(this.animationData.layers, frame);
}
catch (error) {
console.error("Rendering error:", error);
}
finally {
// Restore context state
this.ctx.restore();
}
}
/**
* Render array of layers
*/
renderLayers(layers, frame) {
// Sort layers by index for proper rendering order
const sortedLayers = [...layers].sort((a, b) => a.ind - b.ind);
for (const layer of sortedLayers) {
if (this.isLayerVisible(layer, frame)) {
this.renderLayer(layer, frame);
}
}
}
/**
* Check if layer is visible at current frame
*/
isLayerVisible(layer, frame) {
return frame >= layer.ip && frame < layer.op;
}
/**
* Render single layer
*/
renderLayer(layer, frame) {
this.ctx.save();
try {
// Apply layer transform
this.applyTransform(layer.ks, frame);
// Apply layer opacity
const opacity = this.getAnimatedValue(layer.ks.o, frame);
if (opacity !== undefined) {
this.ctx.globalAlpha *= opacity / 100;
}
// Render based on layer type
switch (layer.ty) {
case 4: // Shape layer
this.renderShapeLayer(layer, frame);
break;
case 0: // Precomp layer
this.renderPrecompLayer(layer, frame);
break;
case 1: // Solid layer
this.renderSolidLayer(layer, frame);
break;
case 2: // Image layer
this.renderImageLayer(layer, frame);
break;
case 5: // Text layer
this.renderTextLayer(layer, frame);
break;
default:
// Unknown layer type, skip
break;
}
}
finally {
this.ctx.restore();
}
}
/**
* Apply transform to context
*/
applyTransform(transform, frame) {
// Get animated values
const anchor = this.getAnimatedValue(transform.a, frame) || [0, 0];
const position = this.getAnimatedValue(transform.p, frame) || [0, 0];
const scale = this.getAnimatedValue(transform.s, frame) || [100, 100];
const rotation = this.getAnimatedValue(transform.r, frame) || 0;
// Apply transformations in correct order
this.ctx.translate(position[0], position[1]);
this.ctx.rotate((rotation * Math.PI) / 180);
this.ctx.scale(scale[0] / 100, scale[1] / 100);
this.ctx.translate(-anchor[0], -anchor[1]);
}
/**
* Render shape layer
*/
renderShapeLayer(layer, frame) {
if (!layer.shapes)
return;
for (const shape of layer.shapes) {
this.renderShape(shape, frame);
}
}
/**
* Render shape
*/
renderShape(shape, frame) {
if (shape.hd)
return; // Skip hidden shapes
this.ctx.save();
try {
switch (shape.ty) {
case "gr": // Group
this.renderShapeGroup(shape, frame);
break;
case "rc": // Rectangle
this.renderRectangle(shape, frame);
break;
case "el": // Ellipse
this.renderEllipse(shape, frame);
break;
case "sh": // Path
this.renderPath(shape, frame);
break;
case "fl": // Fill
this.applyFill(shape, frame);
break;
case "st": // Stroke
this.applyStroke(shape, frame);
break;
case "tr": // Transform
this.applyShapeTransform(shape, frame);
break;
default:
// Unknown shape type
break;
}
}
finally {
this.ctx.restore();
}
}
/**
* Render shape group
*/
renderShapeGroup(shape, frame) {
if (!shape.it)
return;
// Render all items in the group
for (const item of shape.it) {
this.renderShape(item, frame);
}
}
/**
* Render rectangle
*/
renderRectangle(shape, frame) {
const size = this.getAnimatedValue(shape.s, frame) || [100, 100];
const position = this.getAnimatedValue(shape.p, frame) || [0, 0];
const roundness = this.getAnimatedValue(shape.r, frame) || 0;
const [width, height] = size;
const [x, y] = position;
this.ctx.beginPath();
if (roundness > 0) {
this.ctx.roundRect(x - width / 2, y - height / 2, width, height, roundness);
}
else {
this.ctx.rect(x - width / 2, y - height / 2, width, height);
}
}
/**
* Render ellipse
*/
renderEllipse(shape, frame) {
const size = this.getAnimatedValue(shape.s, frame) || [100, 100];
const position = this.getAnimatedValue(shape.p, frame) || [0, 0];
const [width, height] = size;
const [x, y] = position;
this.ctx.beginPath();
this.ctx.ellipse(x, y, width / 2, height / 2, 0, 0, Math.PI * 2);
}
/**
* Render path
*/
renderPath(shape, frame) {
const pathData = this.getAnimatedValue(shape.pt, frame);
if (!pathData || !pathData.v)
return;
const vertices = pathData.v;
const inTangents = pathData.i || [];
const outTangents = pathData.o || [];
const closed = pathData.c || false;
this.ctx.beginPath();
if (vertices.length > 0) {
this.ctx.moveTo(vertices[0][0], vertices[0][1]);
for (let i = 1; i < vertices.length; i++) {
const prevVertex = vertices[i - 1];
const currentVertex = vertices[i];
const prevOutTangent = outTangents[i - 1] || [0, 0];
const currentInTangent = inTangents[i] || [0, 0];
this.ctx.bezierCurveTo(prevVertex[0] + prevOutTangent[0], prevVertex[1] + prevOutTangent[1], currentVertex[0] + currentInTangent[0], currentVertex[1] + currentInTangent[1], currentVertex[0], currentVertex[1]);
}
if (closed && vertices.length > 2) {
const lastVertex = vertices[vertices.length - 1];
const firstVertex = vertices[0];
const lastOutTangent = outTangents[vertices.length - 1] || [0, 0];
const firstInTangent = inTangents[0] || [0, 0];
this.ctx.bezierCurveTo(lastVertex[0] + lastOutTangent[0], lastVertex[1] + lastOutTangent[1], firstVertex[0] + firstInTangent[0], firstVertex[1] + firstInTangent[1], firstVertex[0], firstVertex[1]);
this.ctx.closePath();
}
}
}
/**
* Apply fill style
*/
applyFill(shape, frame) {
const color = this.getAnimatedValue(shape.c, frame);
const opacity = this.getAnimatedValue(shape.o, frame);
if (color) {
const [r, g, b] = color.map((c) => Math.round(c * 255));
const a = opacity ? opacity / 100 : 1;
this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
this.ctx.fill();
}
}
/**
* Apply stroke style
*/
applyStroke(shape, frame) {
const color = this.getAnimatedValue(shape.c, frame);
const opacity = this.getAnimatedValue(shape.o, frame);
const width = this.getAnimatedValue(shape.w, frame);
if (color && width) {
const [r, g, b] = color.map((c) => Math.round(c * 255));
const a = opacity ? opacity / 100 : 1;
this.ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
this.ctx.lineWidth = width;
// Set line cap and join
this.ctx.lineCap = this.getLineCap(shape.lc);
this.ctx.lineJoin = this.getLineJoin(shape.lj);
if (shape.ml) {
this.ctx.miterLimit = shape.ml;
}
this.ctx.stroke();
}
}
/**
* Apply shape transform
*/
applyShapeTransform(shape, frame) {
// Shape transforms have different property names than layer transforms
const position = this.getAnimatedValue(shape.p, frame) || [0, 0];
const scale = this.getAnimatedValue(shape.s, frame) || [100, 100];
const rotation = this.getAnimatedValue(shape.r, frame) || 0;
this.ctx.translate(position[0], position[1]);
this.ctx.rotate((rotation * Math.PI) / 180);
this.ctx.scale(scale[0] / 100, scale[1] / 100);
}
/**
* Render precomp layer
*/
renderPrecompLayer(_layer, _frame) {
var _a;
// Find referenced composition
const comp = (_a = this.animationData.assets) === null || _a === void 0 ? void 0 : _a.find((asset) => asset.id === _layer.refId);
if (comp && comp.layers) {
this.renderLayers(comp.layers, _frame);
}
}
/**
* Render solid layer
*/
renderSolidLayer(layer, _frame) {
// Use layer's solid color if available
const solidColor = layer.sc || "#ff0000"; // Default to red for testing
const width = layer.sw || layer.w || 100;
const height = layer.sh || layer.h || 100;
this.ctx.fillStyle = solidColor;
this.ctx.fillRect(0, 0, width, height);
}
/**
* Render image layer
*/
renderImageLayer(_layer, _frame) {
// Image rendering would require loading external assets
// This is a placeholder for image layer implementation
}
/**
* Render text layer
*/
renderTextLayer(_layer, _frame) {
// Text rendering would require complex text layout
// This is a placeholder for text layer implementation
}
/**
* Get animated value at specific frame
*/
getAnimatedValue(animatable, frame) {
if (!animatable)
return undefined;
// Static value
if (!animatable.a) {
return animatable.k;
}
// Animated value
const keyframes = animatable.k;
if (!Array.isArray(keyframes)) {
return keyframes;
}
// Find appropriate keyframes
let prevKeyframe = keyframes[0];
let nextKeyframe = keyframes[keyframes.length - 1];
for (let i = 0; i < keyframes.length - 1; i++) {
if (frame >= keyframes[i].t && frame < keyframes[i + 1].t) {
prevKeyframe = keyframes[i];
nextKeyframe = keyframes[i + 1];
break;
}
}
// Linear interpolation
const progress = (frame - prevKeyframe.t) / (nextKeyframe.t - prevKeyframe.t);
return this.interpolateValue(prevKeyframe.s, nextKeyframe.s, Math.max(0, Math.min(1, progress)));
}
/**
* Interpolate between two values
*/
interpolateValue(start, end, progress) {
if (typeof start === "number" && typeof end === "number") {
return start + (end - start) * progress;
}
if (Array.isArray(start) && Array.isArray(end)) {
return start.map((startVal, index) => {
const endVal = end[index] || startVal;
return typeof startVal === "number"
? startVal + (endVal - startVal) * progress
: startVal;
});
}
return progress < 0.5 ? start : end;
}
/**
* Get line cap style
*/
getLineCap(cap) {
switch (cap) {
case 1:
return "butt";
case 2:
return "round";
case 3:
return "square";
default:
return "butt";
}
}
/**
* Get line join style
*/
getLineJoin(join) {
switch (join) {
case 1:
return "miter";
case 2:
return "round";
case 3:
return "bevel";
default:
return "miter";
}
}
/**
* Resize canvas
*/
resize() {
if (this.isDestroyed)
return;
const container = this.canvas.parentElement;
if (!container)
return;
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
// Re-render current frame
this.render(this.currentFrame);
}
/**
* Destroy renderer and clean up resources
*/
destroy() {
this.isDestroyed = true;
if (this.canvas.parentElement) {
this.canvas.parentElement.removeChild(this.canvas);
}
}
/**
* Get canvas element
*/
getCanvas() {
return this.canvas;
}
}
/**
* Lightweight SVG renderer for Lottie animations
* Better for simple animations and better scalability
*/
class SVGRenderer {
constructor(container, animationData) {
this.currentFrame = 0;
this.layerElements = new Map();
this.isDestroyed = false;
this.container = container;
this.animationData = animationData;
this.setupSVG();
this.createLayerElements();
}
/**
* Setup SVG element
*/
setupSVG() {
const { w: width, h: height } = this.animationData;
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
this.svg.setAttribute("width", "100%");
this.svg.setAttribute("height", "100%");
this.svg.style.display = "block";
this.container.appendChild(this.svg);
}
/**
* Create SVG elements for all layers
*/
createLayerElements() {
// Sort layers by index
const sortedLayers = [...this.animationData.layers].sort((a, b) => a.ind - b.ind);
for (const layer of sortedLayers) {
const layerGroup = this.createLayerGroup(layer);
this.layerElements.set(layer.ind, layerGroup);
this.svg.appendChild(layerGroup);
}
}
/**
* Create SVG group for layer
*/
createLayerGroup(layer) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("data-layer-index", layer.ind.toString());
group.setAttribute("data-layer-name", layer.nm || "");
return group;
}
/**
* Render frame at specific time
*/
render(frame) {
if (this.isDestroyed)
return;
this.currentFrame = frame;
// Update all layers
for (const layer of this.animationData.layers) {
this.updateLayer(layer, frame);
}
}
/**
* Update layer at specific frame
*/
updateLayer(layer, frame) {
const layerGroup = this.layerElements.get(layer.ind);
if (!layerGroup)
return;
// Check visibility
const isVisible = frame >= layer.ip && frame < layer.op;
layerGroup.style.display = isVisible ? "block" : "none";
if (!isVisible)
return;
// Apply transform
const transform = this.getTransformString(layer.ks, frame);
layerGroup.setAttribute("transform", transform);
// Apply opacity
const opacity = this.getAnimatedValue(layer.ks.o, frame);
if (opacity !== undefined) {
layerGroup.setAttribute("opacity", (opacity / 100).toString());
}
// Update layer content based on type
switch (layer.ty) {
case 4: // Shape layer
this.updateShapeLayer(layer, layerGroup, frame);
break;
case 0: // Precomp layer
this.updatePrecompLayer(layer, layerGroup, frame);
break;
case 1: // Solid layer
this.updateSolidLayer(layer, layerGroup, frame);
break;
case 2: // Image layer
this.renderImageLayer(layer, layerGroup, frame);
break;
case 5: // Text layer
this.renderTextLayer(layer, layerGroup, frame);
break;
}
}
/**
* Get transform string from transform object
*/
getTransformString(transform, frame) {
const transforms = [];
// Get animated values
const anchor = this.getAnimatedValue(transform.a, frame) || [0, 0];
const position = this.getAnimatedValue(transform.p, frame) || [0, 0];
const scale = this.getAnimatedValue(transform.s, frame) || [100, 100];
const rotation = this.getAnimatedValue(transform.r, frame) || 0;
// Apply transforms in correct order
if (position[0] !== 0 || position[1] !== 0) {
transforms.push(`translate(${position[0]}, ${position[1]})`);
}
if (rotation !== 0) {
transforms.push(`rotate(${rotation})`);
}
if (scale[0] !== 100 || scale[1] !== 100) {
transforms.push(`scale(${scale[0] / 100}, ${scale[1] / 100})`);
}
if (anchor[0] !== 0 || anchor[1] !== 0) {
transforms.push(`translate(${-anchor[0]}, ${-anchor[1]})`);
}
return transforms.join(" ");
}
/**
* Update shape layer
*/
updateShapeLayer(layer, layerGroup, frame) {
if (!layer.shapes)
return;
// Clear existing content
layerGroup.innerHTML = "";
// Create shape elements
for (const shape of layer.shapes) {
const shapeElement = this.createShapeElement(shape, frame);
if (shapeElement) {
layerGroup.appendChild(shapeElement);
}
}
}
/**
* Create SVG element for shape
*/
createShapeElement(shape, frame) {
if (shape.hd)
return null; // Skip hidden shapes
switch (shape.ty) {
case "gr": // Group
return this.createShapeGroup(shape, frame);
case "rc": // Rectangle
return this.createRectangle(shape, frame);
case "el": // Ellipse
return this.createEllipse(shape, frame);
case "sh": // Path
return this.createPath(shape, frame);
default:
return null;
}
}
/**
* Create shape group
*/
createShapeGroup(shape, frame) {
if (!shape.it)
return null;
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
// Process group items
const pathElements = [];
let fillStyle = null;
let strokeStyle = null;
let strokeWidth = null;
let transform = null;
for (const item of shape.it) {