|
| 1 | +import fs from 'node:fs/promises'; |
| 2 | +import os from 'node:os'; |
| 3 | +import path from 'node:path'; |
| 4 | +import { spawn } from 'node:child_process'; |
| 5 | +import { pathToFileURL } from 'node:url'; |
| 6 | +import { FileManager, FFMPEG_CONSTANTS, generateId, initializeFFmpeg, ffmpegBin } from './core.js'; |
| 7 | + |
| 8 | +interface RlottieModule { |
| 9 | + cwrap: (name: string, ret: string | null, args: string[]) => (...args: number[]) => number | null; |
| 10 | + HEAPU8: Uint8Array; |
| 11 | + _malloc: (size: number) => number; |
| 12 | +} |
| 13 | + |
| 14 | +interface RlottieApi { |
| 15 | + lottie_init: () => number; |
| 16 | + lottie_destroy: (handle: number) => void; |
| 17 | + lottie_resize: (handle: number, w: number, h: number) => void; |
| 18 | + lottie_buffer: (handle: number) => number; |
| 19 | + lottie_render: (handle: number, frameNo: number) => void; |
| 20 | + lottie_load_from_data: (handle: number, ptr: number) => number; |
| 21 | + HEAPU8: Uint8Array; |
| 22 | + _malloc: (size: number) => number; |
| 23 | +} |
| 24 | + |
| 25 | +interface FflateSync { |
| 26 | + gunzipSync: (data: Uint8Array) => Uint8Array; |
| 27 | + unzipSync: (data: Uint8Array) => Record<string, Uint8Array>; |
| 28 | +} |
| 29 | + |
| 30 | +let _rlottie: RlottieApi | null = null; |
| 31 | +let _rlottieChecked = false; |
| 32 | +let _fflate: FflateSync | null = null; |
| 33 | +let _fflateChecked = false; |
| 34 | + |
| 35 | +async function resolveRlottieWasmPath(): Promise<{ wasmPath: string; jsPath: string } | null> { |
| 36 | + try { |
| 37 | + const wasmUrl: string = import.meta.resolve('rlottie/wasm'); |
| 38 | + const { fileURLToPath } = await import('node:url'); |
| 39 | + const wasmPath = fileURLToPath(wasmUrl); |
| 40 | + return { wasmPath, jsPath: wasmPath.replace('/rlottie.wasm', '/rlottie.js') }; |
| 41 | + } catch { |
| 42 | + /** ESM resolve unavailable — fall through to CJS */ |
| 43 | + } |
| 44 | + try { |
| 45 | + type RequireLike = { (id: string): unknown; resolve: (id: string) => string }; |
| 46 | + const req = (typeof require !== 'undefined' ? require : null) as RequireLike | null; |
| 47 | + if (req) { |
| 48 | + const apiPath: string = req.resolve('rlottie'); |
| 49 | + const dir = path.dirname(apiPath); |
| 50 | + return { wasmPath: path.join(dir, 'rlottie.wasm'), jsPath: path.join(dir, 'rlottie.js') }; |
| 51 | + } |
| 52 | + } catch { |
| 53 | + /** no-op */ |
| 54 | + } |
| 55 | + return null; |
| 56 | +} |
| 57 | + |
| 58 | +async function buildApiFromModule(m: RlottieModule): Promise<RlottieApi> { |
| 59 | + const wrap = <T extends (...a: number[]) => number | null>(name: string, ret: string | null, args: string[]): T => |
| 60 | + m.cwrap(name, ret, args) as T; |
| 61 | + return { |
| 62 | + lottie_init: wrap<() => number>('lottie_init', 'number', []), |
| 63 | + lottie_destroy: (h) => { m.cwrap('lottie_destroy', null, ['number'])(h); }, |
| 64 | + lottie_resize: (h, w, ht) => { m.cwrap('lottie_resize', null, ['number', 'number', 'number'])(h, w, ht); }, |
| 65 | + lottie_buffer: wrap<(h: number) => number>('lottie_buffer', 'number', ['number']), |
| 66 | + lottie_render: (h, f) => { m.cwrap('lottie_render', null, ['number', 'number'])(h, f); }, |
| 67 | + lottie_load_from_data: wrap<(h: number, ptr: number) => number>('lottie_load_from_data', 'number', ['number', 'number']), |
| 68 | + HEAPU8: m.HEAPU8, |
| 69 | + _malloc: m._malloc, |
| 70 | + }; |
| 71 | +} |
| 72 | + |
| 73 | +async function getRlottie(): Promise<RlottieApi | null> { |
| 74 | + if (_rlottieChecked) return _rlottie; |
| 75 | + _rlottieChecked = true; |
| 76 | + try { |
| 77 | + /** |
| 78 | + * Path A: import rlottie package directly — mockable in tests; works when fetch is available. |
| 79 | + * In plain Node the emscripten fetch will throw; we catch and fall through to path B. |
| 80 | + */ |
| 81 | + try { |
| 82 | + const { init } = (await import('rlottie')) as { init: (url?: string) => RlottieApi | Promise<RlottieApi> }; |
| 83 | + const paths = await resolveRlottieWasmPath(); |
| 84 | + const wasmUrl = paths ? pathToFileURL(paths.wasmPath).href : undefined; |
| 85 | + _rlottie = await init(wasmUrl); |
| 86 | + return _rlottie; |
| 87 | + } catch { |
| 88 | + /** fetch-based init failed — try binary load */ |
| 89 | + } |
| 90 | + |
| 91 | + /** Path B: read wasm binary directly; bypasses emscripten fetch, reliable in Node. */ |
| 92 | + const paths = await resolveRlottieWasmPath(); |
| 93 | + if (paths) { |
| 94 | + const wasmBinary = await fs.readFile(paths.wasmPath); |
| 95 | + const imported = (await import(pathToFileURL(paths.jsPath).href)) as { |
| 96 | + default: (opts: { wasmBinary: Uint8Array }) => Promise<RlottieModule>; |
| 97 | + }; |
| 98 | + _rlottie = await buildApiFromModule(await imported.default({ wasmBinary })); |
| 99 | + } |
| 100 | + } catch { |
| 101 | + _rlottie = null; |
| 102 | + } |
| 103 | + return _rlottie; |
| 104 | +} |
| 105 | + |
| 106 | +async function getFflate(): Promise<FflateSync | null> { |
| 107 | + if (_fflateChecked) return _fflate; |
| 108 | + _fflateChecked = true; |
| 109 | + try { |
| 110 | + const mod = (await import('fflate')) as FflateSync & { default?: FflateSync }; |
| 111 | + const candidate = mod.default ?? mod; |
| 112 | + if (typeof candidate.gunzipSync === 'function') _fflate = candidate; |
| 113 | + } catch { |
| 114 | + _fflate = null; |
| 115 | + } |
| 116 | + return _fflate; |
| 117 | +} |
| 118 | + |
| 119 | +interface LottieAsset { |
| 120 | + id?: string; |
| 121 | + u?: string; |
| 122 | + p?: string; |
| 123 | + e?: number; |
| 124 | + [k: string]: unknown; |
| 125 | +} |
| 126 | + |
| 127 | +interface LottieData { |
| 128 | + fr?: number; |
| 129 | + ip?: number; |
| 130 | + op?: number; |
| 131 | + w?: number; |
| 132 | + h?: number; |
| 133 | + layers?: unknown[]; |
| 134 | + assets?: LottieAsset[]; |
| 135 | + [k: string]: unknown; |
| 136 | +} |
| 137 | + |
| 138 | +const ZIP_SIG = [0x50, 0x4b, 0x03, 0x04] as const; |
| 139 | +const GZIP_SIG = [0x1f, 0x8b] as const; |
| 140 | + |
| 141 | +function bufStartsWith(buf: Buffer, sig: readonly number[]): boolean { |
| 142 | + return sig.every((b, i) => buf[i] === b); |
| 143 | +} |
| 144 | + |
| 145 | +export function isLottieWas(buffer: Buffer): boolean { |
| 146 | + if (buffer.length < 2) return false; |
| 147 | + if (bufStartsWith(buffer, ZIP_SIG)) return true; |
| 148 | + if (bufStartsWith(buffer, GZIP_SIG)) return true; |
| 149 | + if (buffer[0] === 0x7b) { |
| 150 | + try { |
| 151 | + const obj = JSON.parse(buffer.toString('utf8')) as LottieData; |
| 152 | + return typeof obj === 'object' && obj !== null && ('layers' in obj || 'fr' in obj); |
| 153 | + } catch { |
| 154 | + return false; |
| 155 | + } |
| 156 | + } |
| 157 | + return false; |
| 158 | +} |
| 159 | + |
| 160 | +async function extractLottieJson(buffer: Buffer): Promise<LottieData> { |
| 161 | + if (bufStartsWith(buffer, ZIP_SIG)) { |
| 162 | + const fflate = await getFflate(); |
| 163 | + if (!fflate) throw new Error('fflate not installed; run: pnpm add fflate'); |
| 164 | + const files = fflate.unzipSync(new Uint8Array(buffer)); |
| 165 | + const jsonKey = Object.keys(files).find( |
| 166 | + (k) => |
| 167 | + k === 'animation/data.json' || |
| 168 | + k === 'animation.json' || |
| 169 | + (k.endsWith('.json') && !k.includes('manifest')), |
| 170 | + ); |
| 171 | + if (!jsonKey) throw new Error('No Lottie JSON found in .was ZIP'); |
| 172 | + const data = JSON.parse(Buffer.from(files[jsonKey]!).toString('utf8')) as LottieData; |
| 173 | + |
| 174 | + if (Array.isArray(data.assets)) { |
| 175 | + for (const asset of data.assets) { |
| 176 | + if (asset['e'] === 0 && typeof asset['u'] === 'string' && typeof asset['p'] === 'string') { |
| 177 | + const imgPath = (asset['u'] as string) + (asset['p'] as string); |
| 178 | + const imgKey = Object.keys(files).find((k) => k === imgPath || k.endsWith('/' + (asset['p'] as string))); |
| 179 | + if (imgKey && files[imgKey]) { |
| 180 | + asset['p'] = 'data:image/png;base64,' + Buffer.from(files[imgKey]!).toString('base64'); |
| 181 | + asset['u'] = ''; |
| 182 | + asset['e'] = 1; |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + return data; |
| 188 | + } |
| 189 | + |
| 190 | + if (bufStartsWith(buffer, GZIP_SIG)) { |
| 191 | + const fflate = await getFflate(); |
| 192 | + if (!fflate) throw new Error('fflate not installed; run: pnpm add fflate'); |
| 193 | + const out = fflate.gunzipSync(new Uint8Array(buffer)); |
| 194 | + return JSON.parse(Buffer.from(out).toString('utf8')) as LottieData; |
| 195 | + } |
| 196 | + |
| 197 | + return JSON.parse(buffer.toString('utf8')) as LottieData; |
| 198 | +} |
| 199 | + |
| 200 | +async function rgbaToPng(rgba: Uint8Array, w: number, h: number, outPath: string): Promise<void> { |
| 201 | + await initializeFFmpeg(); |
| 202 | + return new Promise((resolve, reject) => { |
| 203 | + const args = [ |
| 204 | + '-y', |
| 205 | + '-f', 'rawvideo', |
| 206 | + '-pixel_format', 'rgba', |
| 207 | + '-video_size', `${w}x${h}`, |
| 208 | + '-i', 'pipe:0', |
| 209 | + '-frames:v', '1', |
| 210 | + '-f', 'image2', |
| 211 | + outPath, |
| 212 | + ]; |
| 213 | + const child = spawn(ffmpegBin, args, { stdio: ['pipe', 'ignore', 'ignore'] }); |
| 214 | + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg rawvideo exit ${code}`)))); |
| 215 | + child.on('error', reject); |
| 216 | + child.stdin!.end(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength)); |
| 217 | + }); |
| 218 | +} |
| 219 | + |
| 220 | +async function assembleWebp(framesDir: string, fps: number, w: number, h: number, quality: number): Promise<string> { |
| 221 | + await initializeFFmpeg(); |
| 222 | + const outPath = path.join(framesDir, 'out.webp'); |
| 223 | + const q = Math.max(1, Math.min(100, quality)); |
| 224 | + return new Promise((resolve, reject) => { |
| 225 | + const args = [ |
| 226 | + '-y', |
| 227 | + '-framerate', String(fps), |
| 228 | + '-i', path.join(framesDir, 'frame_%04d.png'), |
| 229 | + '-vf', `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,format=rgba`, |
| 230 | + '-vcodec', 'libwebp', |
| 231 | + '-loop', '0', |
| 232 | + '-q:v', String(q), |
| 233 | + '-preset', 'default', |
| 234 | + '-compression_level', String(FFMPEG_CONSTANTS.STICKER.COMPRESSION_LEVEL), |
| 235 | + '-an', |
| 236 | + outPath, |
| 237 | + ]; |
| 238 | + const child = spawn(ffmpegBin, args, { stdio: 'ignore' }); |
| 239 | + child.on('close', (code) => (code === 0 ? resolve(outPath) : reject(new Error(`ffmpeg webp assembly exit ${code}`)))); |
| 240 | + child.on('error', reject); |
| 241 | + }); |
| 242 | +} |
| 243 | + |
| 244 | +const LOTTIE_SIZE = FFMPEG_CONSTANTS.STICKER.SIZE; |
| 245 | +const LOTTIE_MAX_FPS = 15; |
| 246 | +const LOTTIE_MAX_DURATION_SEC = 3; |
| 247 | + |
| 248 | +export class LottieProcessor { |
| 249 | + static async toWebp(buffer: Buffer, quality: number = FFMPEG_CONSTANTS.STICKER.DEFAULT_QUALITY): Promise<Buffer> { |
| 250 | + const api = await getRlottie(); |
| 251 | + if (!api) throw new Error('rlottie not installed; run: pnpm add rlottie'); |
| 252 | + |
| 253 | + const lottieData = await extractLottieJson(buffer); |
| 254 | + const srcFps = Math.max(1, Number(lottieData.fr) || 24); |
| 255 | + const ip = Number(lottieData.ip) || 0; |
| 256 | + const op = Number(lottieData.op) || ip + srcFps; |
| 257 | + const totalFrames = op - ip; |
| 258 | + const targetFps = Math.min(srcFps, LOTTIE_MAX_FPS); |
| 259 | + const capFrames = Math.min(totalFrames, Math.ceil(LOTTIE_MAX_DURATION_SEC * targetFps)); |
| 260 | + |
| 261 | + const W = LOTTIE_SIZE, H = LOTTIE_SIZE; |
| 262 | + const handle = api.lottie_init(); |
| 263 | + const jsonBuf = Buffer.from(JSON.stringify(lottieData) + '\0'); |
| 264 | + const ptr = api._malloc(jsonBuf.length); |
| 265 | + const heap = api.HEAPU8; |
| 266 | + for (let i = 0; i < jsonBuf.length; i++) heap[ptr + i] = jsonBuf[i]!; |
| 267 | + api.lottie_load_from_data(handle, ptr); |
| 268 | + api.lottie_resize(handle, W, H); |
| 269 | + |
| 270 | + const tempDir = path.join(getTmpdir(), `lottie_${generateId()}`); |
| 271 | + await fs.mkdir(tempDir, { recursive: true }); |
| 272 | + |
| 273 | + try { |
| 274 | + const stride = W * H * 4; |
| 275 | + for (let f = 0; f < capFrames; f++) { |
| 276 | + api.lottie_render(handle, ip + f); |
| 277 | + const bufPtr = api.lottie_buffer(handle); |
| 278 | + const rgba = new Uint8Array(api.HEAPU8.buffer.slice(bufPtr, bufPtr + stride)); |
| 279 | + await rgbaToPng(rgba, W, H, path.join(tempDir, `frame_${String(f).padStart(4, '0')}.png`)); |
| 280 | + } |
| 281 | + api.lottie_destroy(handle); |
| 282 | + |
| 283 | + const outPath = await assembleWebp(tempDir, targetFps, W, H, quality); |
| 284 | + return await FileManager.safeReadFile(outPath); |
| 285 | + } finally { |
| 286 | + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); |
| 287 | + } |
| 288 | + } |
| 289 | +} |
| 290 | + |
| 291 | +function getTmpdir(): string { |
| 292 | + return os.tmpdir(); |
| 293 | +} |
0 commit comments