Skip to content

Commit 97d42be

Browse files
committed
feat(media): convert Lottie/WAS premium stickers to animated WebP
1 parent 10d481e commit 97d42be

9 files changed

Lines changed: 510 additions & 10 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ GEMINI.md
4040
# superpowers workflow scratch (specs, plans, sdd ledger) — local only
4141
docs/superpowers/
4242
.superpowers/
43+
44+
# scratch reports
45+
.lib-lottie-report.md
46+
*-report.md

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@
118118
"optional": true
119119
}
120120
},
121+
"optionalDependencies": {
122+
"rlottie": "^0.1.2",
123+
"fflate": "^0.8.3"
124+
},
121125
"pnpm": {
122126
"onlyBuiltDependencies": [
123127
"better-sqlite3"

src/media/ffmpeg/core.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ let ffmpegPath = 'ffmpeg';
4848
let ffprobePath = 'ffprobe';
4949
let ffmpegInitialized = false;
5050

51+
export let ffmpegBin = 'ffmpeg';
52+
5153
export const initializeFFmpeg = async (disable: boolean = false) => {
5254
if (disable || ffmpegInitialized) return;
5355
ffmpegInitialized = true;
@@ -56,6 +58,7 @@ export const initializeFFmpeg = async (disable: boolean = false) => {
5658
const ffmpegInstaller = (await import('@ffmpeg-installer/ffmpeg')).default;
5759
if (ffmpegInstaller?.path) {
5860
ffmpegPath = ffmpegInstaller.path;
61+
ffmpegBin = ffmpegPath;
5962
const dir = path.dirname(ffmpegInstaller.path);
6063
const sep = process.platform === 'win32' ? ';' : ':';
6164
const current = process.env['PATH'] ?? '';

src/media/ffmpeg/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './video.js';
55
export * from './image.js';
66
export * from './sticker.js';
77
export * from './document.js';
8+
export * from './lottie.js';

src/media/ffmpeg/lottie.ts

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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+
}

src/media/ffmpeg/sticker.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BufferConverter, FFMPEG_CONSTANTS, MimeValidator, detectFileType, gener
33
import { ffmpegTransform } from './transform.js';
44
import { ImageProcessor } from './image.js';
55
import { VideoProcessor } from './video.js';
6+
import { isLottieWas, LottieProcessor } from './lottie.js';
67

78
export type StickerShapeType = 'circle' | 'rounded' | 'oval' | 'default';
89

@@ -19,32 +20,41 @@ export class StickerProcessor {
1920
static async create(input: MediaInput, metadata?: StickerMetadataType): Promise<Buffer> {
2021
try {
2122
const buffer = await BufferConverter.toBuffer(input);
23+
const quality = metadata?.quality || FFMPEG_CONSTANTS.STICKER.DEFAULT_QUALITY;
24+
const shape = metadata?.shape || 'default';
25+
26+
if (isLottieWas(buffer)) {
27+
const webpBuffer = await LottieProcessor.toWebp(buffer, quality);
28+
return await this.applyExif(webpBuffer, metadata);
29+
}
30+
2231
const fileType = await detectFileType(buffer);
2332

2433
if (!fileType) throw new Error('Unable to detect file type');
2534

26-
const quality = metadata?.quality || FFMPEG_CONSTANTS.STICKER.DEFAULT_QUALITY;
2735
const isAnimated = MimeValidator.isAnimated(fileType.mime);
28-
const shape = metadata?.shape || 'default';
2936

3037
const webpBuffer = fileType.mime === 'image/webp'
3138
? buffer
3239
: isAnimated
3340
? await this.processAnimated(buffer, fileType.mime, quality)
3441
: await ImageProcessor.resizeForSticker(buffer, quality, shape);
3542

36-
const exif = this.createExifMetadata(metadata);
37-
const img = new webp.Image();
38-
await img.load(webpBuffer);
39-
img.exif = exif;
40-
41-
const finalBuffer = await img.save(null);
42-
return Buffer.isBuffer(finalBuffer) ? finalBuffer : Buffer.from(finalBuffer as Uint8Array);
43+
return await this.applyExif(webpBuffer, metadata);
4344
} catch (error: unknown) {
4445
throw new Error(`Sticker creation failed: ${error instanceof Error ? error.message : String(error)}`);
4546
}
4647
}
4748

49+
private static async applyExif(webpBuffer: Buffer, metadata?: StickerMetadataType): Promise<Buffer> {
50+
const exif = this.createExifMetadata(metadata);
51+
const img = new webp.Image();
52+
await img.load(webpBuffer);
53+
img.exif = exif;
54+
const finalBuffer = await img.save(null);
55+
return Buffer.isBuffer(finalBuffer) ? finalBuffer : Buffer.from(finalBuffer as Uint8Array);
56+
}
57+
4858
private static createExifMetadata(metadata?: StickerMetadataType): Buffer {
4959
const json = {
5060
'sticker-pack-id': generateId(),

0 commit comments

Comments
 (0)