UNPKG

micro-lottie-react

Version:

The smallest React Lottie player. 15KB. Zero deps. Supports .lottie files.

1,314 lines (1,309 loc) 103 kB
(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) {