@boruto_vk7/stickengine 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,84 +1,137 @@
1
1
  import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import os from 'node:os';
4
5
  import { execFile } from 'node:child_process';
5
6
  import { EventEmitter } from 'node:events';
7
+ import Crypto from 'node:crypto';
6
8
  import axios from 'axios';
7
9
  import ffmpeg from 'fluent-ffmpeg';
8
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
9
- // @ts-ignore
10
10
  import webpMux from 'node-webpmux';
11
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
12
- // @ts-ignore
13
11
  import Jimp from 'jimp';
14
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
15
- // @ts-ignore
16
- import { removeBackgroundFromImageFile } from 'remove.bg';
17
- import { fileURLToPath as __stick_fileURLToPath } from 'node:url';
18
- const moduleDir = path.dirname(__stick_fileURLToPath(import.meta.url));
19
- // ── Helpers ────────────────────────────────────────────────────────────────
12
+ import { fileURLToPath } from 'node:url';
13
+ import { createRequire } from 'node:module';
14
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
15
+ let ffmpegPath = null;
16
+ let ffprobePath = null;
17
+ try {
18
+ const require = createRequire(import.meta.url);
19
+ const installer = require('@ffmpeg-installer/ffmpeg');
20
+ if (installer?.path) {
21
+ ffmpegPath = installer.path;
22
+ ffmpeg.setFfmpegPath(ffmpegPath);
23
+ }
24
+ } catch {}
25
+ try {
26
+ const require = createRequire(import.meta.url);
27
+ const installer = require('@ffprobe-installer/ffprobe');
28
+ if (installer?.path) {
29
+ ffprobePath = installer.path;
30
+ ffmpeg.setFfprobePath(ffprobePath);
31
+ }
32
+ } catch {}
20
33
  export async function getBuffer(url, options = {}) {
21
34
  try {
22
35
  const { data } = await axios({
23
36
  method: 'get',
24
37
  url,
25
38
  headers: {
26
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
27
- DNT: '1',
39
+ 'user-agent': 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36',
40
+ 'DNT': 1,
41
+ 'Upgrade-Insecure-Request': 1,
42
+ ...(options.headers || {})
28
43
  },
29
44
  ...options,
30
45
  responseType: 'arraybuffer',
31
- timeout: 45000,
46
+ timeout: options.timeout || 45000
32
47
  });
33
- return Buffer.from(data);
48
+ return Buffer.isBuffer(data) ? data : Buffer.from(data);
34
49
  }
35
50
  catch (err) {
36
- const fallback = path.join(moduleDir, 'emror.jpg');
37
- if (fs.existsSync(fallback))
38
- return fs.readFileSync(fallback);
39
- throw new Error(`Failed to download: ${err instanceof Error ? err.message : String(err)}`);
51
+ const candidates = [
52
+ path.join(moduleDir, 'emror.jpg'),
53
+ path.join(moduleDir, '../src/emror.jpg'),
54
+ path.join(moduleDir, 'logo.png'),
55
+ path.join(process.cwd(), 'src/emror.jpg'),
56
+ path.join(process.cwd(), 'dist/cjs/emror.jpg'),
57
+ path.join(process.cwd(), 'dist/esm/emror.jpg'),
58
+ path.join(os.tmpdir(), 'emror.jpg'),
59
+ path.resolve('./src/emror.jpg')
60
+ ];
61
+ for (const p of candidates) {
62
+ try {
63
+ if (fs.existsSync(p))
64
+ return fs.readFileSync(p);
65
+ }
66
+ catch { }
67
+ }
68
+ return Buffer.alloc(0);
40
69
  }
41
70
  }
42
71
  function resolveWebpImage() {
43
- const Image = webpMux.Image || webpMux.default?.Image;
72
+ const Image = webpMux.Image || webpMux.default?.Image || webpMux.default;
44
73
  if (!Image)
45
74
  throw new Error('node-webpmux Image class not found');
46
75
  return Image;
47
76
  }
48
77
  async function fileTypeFromBuf(buf) {
49
- const ft = await import('file-type');
50
- const mod = ft;
51
- const fn = mod.fileTypeFromBuffer ||
52
- mod.fromBuffer ||
53
- mod.default?.fileTypeFromBuffer ||
54
- mod.default?.fromBuffer;
55
- if (!fn)
56
- return undefined;
57
- return fn(buf);
78
+ try {
79
+ const ft = await import('file-type');
80
+ const fn = ft.fileTypeFromBuffer || ft.fromBuffer || ft.default?.fileTypeFromBuffer || ft.default?.fromBuffer;
81
+ if (!fn)
82
+ return undefined;
83
+ return await fn(buf);
84
+ }
85
+ catch {
86
+ try {
87
+ const ft = eval("require")('file-type');
88
+ const fn = ft.fileTypeFromBuffer || ft.fromBuffer || ft.default?.fileTypeFromBuffer || ft.default?.fromBuffer;
89
+ if (!fn)
90
+ return undefined;
91
+ return await fn(buf);
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
58
97
  }
59
98
  function isHttp(s) {
60
99
  return /^https?:\/\//i.test(s);
61
100
  }
62
- // ── Engine ─────────────────────────────────────────────────────────────────
63
- /**
64
- * WhatsApp sticker engine (static + animated WebP + EXIF pack metadata).
65
- *
66
- * @example
67
- * ```ts
68
- * import StickEngine from '@boruto_vk7/stickengine';
69
- *
70
- * const sticker = await StickEngine.create('./cat.gif', {
71
- * metadata: { pack: 'My Pack', author: 'Me' },
72
- * });
73
- * // sticker.buffer → send to WhatsApp
74
- * // sticker.path → temp file path
75
- * ```
76
- */
101
+ function isDataURI(s) {
102
+ return /^data:.*?\/.*?;base64,/i.test(s);
103
+ }
104
+ function parseDataURItoBuffer(dataURI) {
105
+ const base64 = dataURI.split(',')[1];
106
+ return Buffer.from(base64, 'base64');
107
+ }
108
+ function normalizeMetadata(options = {}) {
109
+ const meta = options.metadata || options;
110
+ const pack = meta.packname || meta.pack || options.packname || options.pack || 'Lagos Solutions';
111
+ const author = meta.author || options.author || 'StickEngine';
112
+ const emojis = meta.categories || meta.emojis || options.categories || options.emojis || ['✨'];
113
+ const id = meta.id || options.id;
114
+ return { pack, author, emojis, id };
115
+ }
116
+ function resolveTempDir(options) {
117
+ const dir = options.tempDir || options.dirTemp || options.Dirtemp || options.DirTemp || options.dirtemp || options.TempDir || path.join(os.tmpdir(), 'stickengine');
118
+ return path.resolve(dir);
119
+ }
120
+ function resolveWatermark(options) {
121
+ const wm = options.watermark ?? options.marcaDagua ?? options.marca_agua ?? options.MarcaDaAgua ?? options.marcaDaAgua ?? false;
122
+ return wm;
123
+ }
77
124
  export class StickEngine extends EventEmitter {
125
+ options;
126
+ tempDir;
127
+ queue = [];
128
+ createdFiles = [];
78
129
  constructor(options = {}) {
79
130
  super();
80
- this.queue = [];
81
- this.createdFiles = [];
131
+ const normalizedMeta = normalizeMetadata(options);
132
+ this.tempDir = resolveTempDir(options);
133
+ fs.mkdirSync(this.tempDir, { recursive: true });
134
+ const watermarkValue = resolveWatermark(options);
82
135
  this.options = {
83
136
  webp: true,
84
137
  edit: false,
@@ -88,44 +141,44 @@ export class StickEngine extends EventEmitter {
88
141
  quality: 75,
89
142
  maxDuration: 6,
90
143
  fit: 'natural',
91
- metadata: {
92
- pack: 'StickEngine',
93
- author: 'Borutovk7',
94
- emojis: ['✨'],
95
- },
144
+ watermark: watermarkValue,
96
145
  ...options,
146
+ tempDir: this.tempDir,
147
+ dirTemp: this.tempDir,
148
+ metadata: {
149
+ pack: normalizedMeta.pack,
150
+ author: normalizedMeta.author,
151
+ emojis: normalizedMeta.emojis,
152
+ id: normalizedMeta.id,
153
+ ...(options.metadata || {})
154
+ }
97
155
  };
98
- this.tempDir = path.resolve(options.tempDir || path.join(process.cwd(), 'tmp_stickengine'));
99
- fs.mkdirSync(this.tempDir, { recursive: true });
156
+ if (options.packname)
157
+ this.options.metadata.pack = options.packname;
158
+ if (options.author)
159
+ this.options.metadata.author = options.author;
160
+ if (options.categories)
161
+ this.options.metadata.emojis = options.categories;
162
+ const wm = resolveWatermark(options);
163
+ if (wm !== undefined)
164
+ this.options.watermark = wm;
100
165
  }
101
- // ── One-shot API (simplest) ──────────────────────────────────────────────
102
- /**
103
- * Create a sticker in one call. Returns path + buffer + base64.
104
- */
105
166
  static async create(input, options = {}) {
106
167
  const engine = new StickEngine({ ...options, autoClean: false });
107
- try {
108
- return await engine.build(input);
109
- }
110
- finally {
111
- // keep result file; drop other temps if any
112
- }
168
+ return await engine.build(input);
113
169
  }
114
- /**
115
- * Create sticker and return only the Buffer (handy for bots).
116
- */
117
170
  static async toBuffer(input, options = {}) {
118
171
  const r = await StickEngine.create(input, options);
119
172
  return r.buffer;
120
173
  }
121
- /**
122
- * Create sticker and return base64 string.
123
- */
124
174
  static async toBase64String(input, options = {}) {
125
175
  const r = await StickEngine.create(input, options);
126
176
  return r.base64;
127
177
  }
128
- /** Instance one-shot (uses constructor options). */
178
+ static async toFile(input, options = {}) {
179
+ const r = await StickEngine.create(input, options);
180
+ return r.path;
181
+ }
129
182
  async build(input) {
130
183
  const filePath = await this.processFile(input, 0);
131
184
  const buffer = await fsp.readFile(filePath);
@@ -138,12 +191,11 @@ export class StickEngine extends EventEmitter {
138
191
  animated,
139
192
  width: info.width || 512,
140
193
  height: info.height || 512,
141
- size: buffer.length,
194
+ size: buffer.length
142
195
  };
143
196
  this.emit('st.done', result);
144
197
  return result;
145
198
  }
146
- // ── Queue API (batch) ────────────────────────────────────────────────────
147
199
  addFile(file) {
148
200
  this.queue.push(file);
149
201
  return this;
@@ -152,81 +204,59 @@ export class StickEngine extends EventEmitter {
152
204
  this.queue.push(...files);
153
205
  return this;
154
206
  }
155
- /**
156
- * Process the queue. Returns settled results with `value` = absolute path.
157
- * Prefer `build()` / `StickEngine.create()` for a simpler API.
158
- */
159
207
  async start() {
160
208
  if (!this.queue.length)
161
- throw new Error('Queue is empty. Use addFile() or StickEngine.create().');
162
- const jobs = this.queue.map((file, i) => this.processFile(file, i));
209
+ throw new Error('Queue is empty');
210
+ const jobs = this.queue.map((f, i) => this.processFile(f, i));
163
211
  this.queue = [];
164
212
  return Promise.allSettled(jobs);
165
213
  }
166
- /** Delete tracked temp files. */
167
214
  clean() {
168
215
  for (const file of this.createdFiles) {
169
216
  try {
170
217
  if (fs.existsSync(file))
171
218
  fs.unlinkSync(file);
172
219
  }
173
- catch {
174
- /* ignore */
175
- }
220
+ catch { }
176
221
  }
177
222
  this.createdFiles = [];
178
223
  }
179
- // ── Core pipeline ────────────────────────────────────────────────────────
180
224
  track(file) {
181
225
  this.createdFiles.push(file);
182
226
  return file;
183
227
  }
184
228
  async processFile(input, index) {
185
- const ts = `${Date.now()}_${index}_${Math.random().toString(36).slice(2, 7)}`;
229
+ const ts = `${Date.now()}_${index}_${Crypto.randomBytes(3).toString('hex')}`;
186
230
  const local = [];
187
231
  try {
188
- this.emit('st.start', { index, input: typeof input === 'string' ? input : '<buffer>' });
232
+ this.emit('st.start', { index, input: typeof input === 'string' ? (input.length > 100 ? input.slice(0, 100) + '...' : input) : '<buffer>' });
189
233
  const { filePath, extension } = await this.materializeInput(input, ts);
190
- local.push(filePath);
234
+ if (filePath.includes(this.tempDir))
235
+ local.push(filePath);
191
236
  const mediaInfo = await this.getMediaInfo(filePath);
192
237
  this.emit('st.info', { index, ...mediaInfo, extension });
193
238
  let current = filePath;
194
- let animated = mediaInfo.duration > 0.05 ||
195
- ['gif', 'mp4', 'webm', 'mov', 'mkv', 'avi'].includes(extension) ||
196
- (extension === 'webp' && (await this.detectAnimatedWebp(filePath)));
197
- // Static edits only
239
+ let animated = mediaInfo.duration > 0.05 || ['gif', 'mp4', 'webm', 'mov', 'mkv', 'avi'].includes(extension) || (extension === 'webp' && await this.detectAnimatedWebp(filePath));
198
240
  if (this.options.edit && !animated) {
199
241
  const edited = path.join(this.tempDir, `edited_${ts}.png`);
200
242
  await this.applyJimpEdits(current, edited, this.options.edit);
201
243
  local.push(edited);
202
244
  current = edited;
203
245
  }
204
- // remove.bg static only
205
- if (this.options.transparent && !animated) {
206
- const key = Array.isArray(this.options.transparent)
207
- ? this.options.transparent[Math.floor(Math.random() * this.options.transparent.length)]
208
- : this.options.transparent;
209
- if (typeof key === 'string' && key.length > 5) {
210
- const out = path.join(this.tempDir, `nobg_${ts}.png`);
211
- const result = await removeBackgroundFromImageFile({
212
- path: current,
213
- apiKey: key,
214
- size: 'auto',
215
- type: 'auto',
216
- });
217
- fs.writeFileSync(out, Buffer.from(result.base64img, 'base64'));
218
- local.push(out);
219
- current = out;
220
- }
246
+ const watermark = this.options.watermark;
247
+ if (watermark && !animated) {
248
+ const watermarked = path.join(this.tempDir, `wm_${ts}.png`);
249
+ await this.applyWatermark(current, watermarked, watermark);
250
+ local.push(watermarked);
251
+ current = watermarked;
221
252
  }
222
253
  const webpPath = path.join(this.tempDir, `sticker_${ts}.webp`);
223
254
  await this.convertToWebp(current, webpPath, animated);
224
255
  local.push(webpPath);
225
256
  current = webpPath;
226
- // Re-check after ffmpeg (source of truth)
227
257
  animated = await this.detectAnimatedWebp(current);
228
- if (this.options.metadata !== undefined && this.options.metadata !== null) {
229
- await this.addExif(current, this.options.metadata || {});
258
+ if (this.options.metadata) {
259
+ await this.addExif(current, this.options.metadata);
230
260
  }
231
261
  for (const f of local)
232
262
  this.track(f);
@@ -240,89 +270,93 @@ export class StickEngine extends EventEmitter {
240
270
  if (fs.existsSync(f))
241
271
  fs.unlinkSync(f);
242
272
  }
243
- catch {
244
- /* ignore */
245
- }
273
+ catch { }
246
274
  }
247
275
  throw error;
248
276
  }
249
277
  }
250
278
  async materializeInput(input, ts) {
251
279
  if (Buffer.isBuffer(input)) {
280
+ if (input.length === 0)
281
+ throw new Error('Buffer vazio');
252
282
  const type = await fileTypeFromBuf(input);
253
- const extension = type?.ext || 'bin';
283
+ const extension = type?.ext || 'png';
254
284
  const filePath = path.join(this.tempDir, `input_${ts}.${extension}`);
255
285
  fs.writeFileSync(filePath, input);
256
286
  return { filePath, extension };
257
287
  }
258
- if (typeof input === 'string' && isHttp(input)) {
259
- const buffer = await getBuffer(input);
260
- const type = await fileTypeFromBuf(buffer);
261
- const extension = type?.ext || 'bin';
262
- const filePath = path.join(this.tempDir, `input_${ts}.${extension}`);
263
- fs.writeFileSync(filePath, buffer);
264
- return { filePath, extension };
265
- }
266
- if (typeof input === 'string' && fs.existsSync(input)) {
267
- const filePath = path.resolve(input);
268
- const extension = path.extname(filePath).replace('.', '').toLowerCase() || 'bin';
269
- return { filePath, extension };
288
+ if (typeof input === 'string') {
289
+ if (isDataURI(input)) {
290
+ const buffer = parseDataURItoBuffer(input);
291
+ if (buffer.length === 0)
292
+ throw new Error('Base64 inválido');
293
+ const type = await fileTypeFromBuf(buffer);
294
+ const extension = type?.ext || 'png';
295
+ const filePath = path.join(this.tempDir, `input_${ts}.${extension}`);
296
+ fs.writeFileSync(filePath, buffer);
297
+ return { filePath, extension };
298
+ }
299
+ if (isHttp(input)) {
300
+ const buffer = await getBuffer(input);
301
+ if (buffer.length === 0)
302
+ throw new Error('Falha ao baixar');
303
+ const type = await fileTypeFromBuf(buffer);
304
+ const extension = type?.ext || path.extname(new URL(input).pathname).replace('.', '') || 'bin';
305
+ const filePath = path.join(this.tempDir, `input_${ts}.${extension}`);
306
+ fs.writeFileSync(filePath, buffer);
307
+ return { filePath, extension: extension.toLowerCase() };
308
+ }
309
+ if (fs.existsSync(input)) {
310
+ const filePath = path.resolve(input);
311
+ const extension = path.extname(filePath).replace('.', '').toLowerCase() || 'bin';
312
+ return { filePath, extension };
313
+ }
314
+ if (/^[A-Za-z0-9+/=]+$/.test(input.slice(0, 100)) && input.length > 100) {
315
+ try {
316
+ const buffer = Buffer.from(input, 'base64');
317
+ if (buffer.length > 50) {
318
+ const type = await fileTypeFromBuf(buffer);
319
+ if (type) {
320
+ const filePath = path.join(this.tempDir, `input_${ts}.${type.ext}`);
321
+ fs.writeFileSync(filePath, buffer);
322
+ return { filePath, extension: type.ext };
323
+ }
324
+ }
325
+ }
326
+ catch { }
327
+ }
328
+ throw new Error('Invalid input: ' + input);
270
329
  }
271
- throw new Error('Invalid input. Pass a file path, http(s) URL, or Buffer.');
330
+ throw new Error('Invalid input type');
272
331
  }
273
- // ── EXIF (fixes animated save error) ─────────────────────────────────────
274
- /**
275
- * Write WhatsApp sticker pack EXIF.
276
- * Animated WebP MUST use muxAnim — save() throws on animations.
277
- */
278
332
  async addExif(webpPath, metadata) {
279
333
  const Image = resolveWebpImage();
280
334
  const img = new Image();
281
335
  const json = {
282
336
  'sticker-pack-id': metadata.id || `stickengine-${Date.now()}`,
283
- 'sticker-pack-name': metadata.pack || 'StickEngine',
284
- 'sticker-pack-publisher': metadata.author || 'Borutovk7',
285
- emojis: metadata.emojis?.length ? metadata.emojis : ['✨'],
337
+ 'sticker-pack-name': metadata.pack || 'Lagos Solutions',
338
+ 'sticker-pack-publisher': metadata.author || 'StickEngine',
339
+ 'emojis': metadata.emojis?.length ? metadata.emojis : metadata.categories?.length ? metadata.categories : ['✨']
286
340
  };
287
- const exifAttr = Buffer.from([
288
- 0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00,
289
- 0x00, 0x00, 0x16, 0x00, 0x00, 0x00,
290
- ]);
341
+ const exifAttr = Buffer.from([0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00]);
291
342
  const jsonBuff = Buffer.from(JSON.stringify(json), 'utf-8');
292
343
  const exif = Buffer.concat([exifAttr, jsonBuff]);
293
344
  exif.writeUIntLE(jsonBuff.length, 14, 4);
294
345
  await img.load(webpPath);
295
346
  img.exif = exif;
296
- const frameCount = typeof img.frameCount === 'number'
297
- ? img.frameCount
298
- : Array.isArray(img.frames)
299
- ? img.frames.length
300
- : 0;
347
+ const frameCount = typeof img.frameCount === 'number' ? img.frameCount : Array.isArray(img.frames) ? img.frames.length : 0;
301
348
  const animated = !!img.hasAnim || frameCount > 1;
302
- /**
303
- * node-webpmux compatibility layer
304
- *
305
- * v3.x (current):
306
- * await img.save(path) // works for static AND animated (passes frames)
307
- *
308
- * v1.x / early forks:
309
- * static → img.save(path)
310
- * anim → img.muxAnim({ path, exif: true }) OR Image.muxAnim({ frames, exif, path })
311
- * calling save() on anim throws:
312
- * "Using `save` for animations is not currently supported. Use `muxAnim` instead"
313
- */
314
349
  const saveV3 = async () => {
315
- // v3 instance save supports both; pass exif explicitly when supported
316
350
  return img.save(webpPath, {
317
351
  exif: true,
318
352
  frames: animated ? img.frames : undefined,
319
353
  width: img.width,
320
354
  height: img.height,
321
355
  loops: img.anim?.loops ?? img.anim?.loopCount ?? 0,
322
- bgColor: img.anim?.bgColor ?? img.anim?.backgroundColor ?? [0, 0, 0, 0],
356
+ bgColor: img.anim?.bgColor ?? img.anim?.backgroundColor ?? [0, 0, 0, 0]
323
357
  });
324
358
  };
325
- const saveLegacyMuxAnim = async () => {
359
+ const saveLegacy = async () => {
326
360
  const opts = {
327
361
  path: webpPath,
328
362
  frames: img.frames || [],
@@ -330,22 +364,12 @@ export class StickEngine extends EventEmitter {
330
364
  height: img.height || 512,
331
365
  loops: img.anim?.loopCount ?? img.anim?.loops ?? 0,
332
366
  bgColor: img.anim?.backgroundColor ?? img.anim?.bgColor ?? [0, 0, 0, 0],
333
- exif,
367
+ exif
334
368
  };
335
- if (typeof img.muxAnim === 'function') {
336
- return img.muxAnim({
337
- path: webpPath,
338
- exif: true,
339
- width: opts.width,
340
- height: opts.height,
341
- loops: opts.loops,
342
- bgColor: opts.bgColor,
343
- });
344
- }
345
- if (typeof Image.muxAnim === 'function') {
369
+ if (typeof img.muxAnim === 'function')
370
+ return img.muxAnim({ path: webpPath, exif: true, width: opts.width, height: opts.height, loops: opts.loops, bgColor: opts.bgColor });
371
+ if (typeof Image.muxAnim === 'function')
346
372
  return Image.muxAnim(opts);
347
- }
348
- // absolute last resort: try plain save (v3)
349
373
  return img.save(webpPath);
350
374
  };
351
375
  try {
@@ -355,41 +379,31 @@ export class StickEngine extends EventEmitter {
355
379
  }
356
380
  catch (err) {
357
381
  const msg = String(err?.message || err);
358
- if (/muxAnim|animations is not currently supported|Using `save`/i.test(msg)) {
359
- await saveLegacyMuxAnim();
360
- }
382
+ if (/muxAnim|not currently supported/i.test(msg))
383
+ await saveLegacy();
361
384
  else if (animated) {
362
- // some builds want save without second arg
363
385
  try {
364
386
  await img.save(webpPath);
365
387
  }
366
- catch (err2) {
367
- const m2 = String(err2?.message || err2);
368
- if (/muxAnim|animations is not currently supported|Using `save`/i.test(m2)) {
369
- await saveLegacyMuxAnim();
370
- }
371
- else {
372
- throw err2;
373
- }
388
+ catch (e2) {
389
+ if (/muxAnim|not currently supported/i.test(String(e2?.message)))
390
+ await saveLegacy();
391
+ else
392
+ throw e2;
374
393
  }
375
394
  }
376
- else {
395
+ else
377
396
  throw err;
378
- }
379
397
  }
380
398
  }
381
- else if (typeof Image.save === 'function') {
382
- // very old static save(path, image)
399
+ else if (typeof Image.save === 'function')
383
400
  await Image.save(webpPath, img);
384
- }
385
- else {
386
- await saveLegacyMuxAnim();
387
- }
401
+ else
402
+ await saveLegacy();
388
403
  }
389
404
  catch (err) {
390
- const msg = String(err?.message || err);
391
- if (/muxAnim|animations is not currently supported|Using `save`/i.test(msg)) {
392
- await saveLegacyMuxAnim();
405
+ if (/muxAnim|not currently supported/i.test(String(err?.message))) {
406
+ await saveLegacy();
393
407
  return;
394
408
  }
395
409
  throw err;
@@ -402,91 +416,51 @@ export class StickEngine extends EventEmitter {
402
416
  const Image = resolveWebpImage();
403
417
  const img = new Image();
404
418
  await img.load(filePath);
405
- return (!!img.hasAnim ||
406
- (typeof img.frameCount === 'number' && img.frameCount > 1) ||
407
- (Array.isArray(img.frames) && img.frames.length > 1));
419
+ return !!img.hasAnim || (typeof img.frameCount === 'number' && img.frameCount > 1) || (Array.isArray(img.frames) && img.frames.length > 1);
408
420
  }
409
421
  catch {
410
422
  return false;
411
423
  }
412
424
  }
413
- // ── FFmpeg / Jimp ────────────────────────────────────────────────────────
414
- /**
415
- * Build FFmpeg -vf chain.
416
- *
417
- * Normal WhatsApp stickers are NOT forced square:
418
- * the longest side becomes 512 and the other side stays proportional
419
- * (e.g. 512×320 or 280×512) with real alpha — same look as hand-made stickers.
420
- */
421
425
  buildScaleFilter(isAnimated, fps) {
422
426
  const fit = this.options.fit || 'natural';
423
427
  const parts = ['format=rgba'];
424
- if (isAnimated) {
428
+ if (isAnimated)
425
429
  parts.push(`fps=${Math.max(1, fps)}`);
426
- }
427
430
  switch (fit) {
428
431
  case 'cover':
429
- // Force square sticker: fill 512×512, center-crop overflow
430
432
  parts.push('scale=512:512:force_original_aspect_ratio=increase:flags=lanczos', 'crop=512:512:(iw-ow)/2:(ih-oh)/2');
431
433
  break;
432
434
  case 'contain':
433
- // Full image inside a square 512×512 with transparent pad (no black bars)
434
435
  parts.push('scale=512:512:force_original_aspect_ratio=decrease:flags=lanczos', 'pad=512:512:(ow-iw)/2:(oh-ih)/2:color=black@0');
435
436
  break;
436
437
  case 'fill':
437
438
  parts.push('scale=512:512:flags=lanczos');
438
439
  break;
439
440
  case 'none':
440
- // Shrink only if larger than 512; never upscale; keep ratio
441
441
  parts.push("scale='min(512,iw)':'min(512,ih)':force_original_aspect_ratio=decrease:flags=lanczos");
442
442
  break;
443
- case 'natural':
444
443
  default:
445
- // DEFAULT — real WhatsApp sticker geometry:
446
- // longest side = 512, other side proportional, NO pad, NO crop
447
- // Example: 1080×1920 → 288×512 | 1920×1080 → 512×288
448
- parts.push(
449
- // -2 keeps aspect and forces even dims (friendlier for some encoders)
450
- "scale='if(gt(iw,ih),512,-2)':'if(gt(ih,iw),512,-2)':flags=lanczos");
444
+ parts.push("scale='if(gt(iw,ih),512,-2)':'if(gt(ih,iw),512,-2)':flags=lanczos");
451
445
  break;
452
446
  }
453
- // Ensure even width/height (webp/yuv safety) without changing aspect much
454
447
  parts.push("scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos", 'setsar=1', 'format=rgba');
455
448
  return parts.join(',');
456
449
  }
457
450
  async convertToWebp(input, output, isAnimated) {
458
451
  const run = (quality, fps) => new Promise((resolve, reject) => {
459
- const ff = ffmpeg(input).on('error', reject).on('end', () => resolve());
460
452
  const vf = this.buildScaleFilter(isAnimated, fps);
461
453
  const q = Math.max(1, Math.min(100, quality));
462
- const opts = [
463
- '-vcodec',
464
- 'libwebp',
465
- '-vf',
466
- vf,
467
- '-loop',
468
- '0',
469
- '-an',
470
- '-vsync',
471
- '0',
472
- '-preset',
473
- 'default',
474
- ];
475
- if (isAnimated) {
476
- // Animated: lossy + size budget
454
+ const opts = ['-vcodec', 'libwebp', '-vf', vf, '-loop', '0', '-an', '-vsync', '0', '-preset', 'default'];
455
+ if (isAnimated)
477
456
  opts.push('-lossless', '0', '-compression_level', '6', '-q:v', String(q), '-quality', String(q), '-t', String(this.options.maxDuration || 6));
478
- }
479
- else {
480
- // Static anime/art stickers look much better near-lossless
457
+ else
481
458
  opts.push('-lossless', q >= 90 ? '1' : '0', '-compression_level', '6', '-q:v', String(q), '-quality', String(q));
482
- }
483
- ff.addOutputOptions(opts).toFormat('webp').save(output);
459
+ ffmpeg(input).on('error', reject).on('end', () => resolve()).addOutputOptions(opts).toFormat('webp').save(output);
484
460
  });
485
- // Static defaults higher quality; animated stays flexible for 1MB cap
486
461
  let quality = this.options.quality ?? (isAnimated ? 80 : 95);
487
462
  let fps = this.options.fps ?? 15;
488
463
  await run(quality, fps);
489
- // WhatsApp animated stickers should stay under ~1MB
490
464
  if (isAnimated && fs.existsSync(output)) {
491
465
  let attempts = 0;
492
466
  while (fs.statSync(output).size > 1000000 && attempts < 4) {
@@ -496,9 +470,8 @@ export class StickEngine extends EventEmitter {
496
470
  attempts++;
497
471
  }
498
472
  }
499
- if (!fs.existsSync(output) || fs.statSync(output).size < 20) {
500
- throw new Error('FFmpeg failed to produce a valid WebP sticker.');
501
- }
473
+ if (!fs.existsSync(output) || fs.statSync(output).size < 20)
474
+ throw new Error('FFmpeg failed');
502
475
  }
503
476
  async applyJimpEdits(input, output, options) {
504
477
  const image = await Jimp.read(input);
@@ -521,98 +494,267 @@ export class StickEngine extends EventEmitter {
521
494
  if (options.text?.content) {
522
495
  const fontPath = options.text.color === 'BLACK' ? Jimp.FONT_SANS_32_BLACK : Jimp.FONT_SANS_32_WHITE;
523
496
  const font = await Jimp.loadFont(fontPath);
524
- const printOptions = {
525
- text: options.text.content,
526
- alignmentX: options.text.alignmentX || Jimp.HORIZONTAL_ALIGN_CENTER,
527
- alignmentY: options.text.alignmentY || Jimp.VERTICAL_ALIGN_BOTTOM,
528
- };
497
+ const printOptions = { text: options.text.content, alignmentX: options.text.alignmentX || Jimp.HORIZONTAL_ALIGN_CENTER, alignmentY: options.text.alignmentY || Jimp.VERTICAL_ALIGN_BOTTOM };
529
498
  if (options.text.stroke) {
530
499
  const strokeFont = await Jimp.loadFont(options.text.color === 'BLACK' ? Jimp.FONT_SANS_32_WHITE : Jimp.FONT_SANS_32_BLACK);
531
- for (let x = -2; x <= 2; x++) {
532
- for (let y = -2; y <= 2; y++) {
533
- if (x !== 0 || y !== 0) {
500
+ for (let x = -2; x <= 2; x++)
501
+ for (let y = -2; y <= 2; y++)
502
+ if (x !== 0 || y !== 0)
534
503
  image.print(strokeFont, x, y, printOptions, image.bitmap.width, image.bitmap.height);
535
- }
536
- }
537
- }
538
504
  }
539
505
  image.print(font, 0, 0, printOptions, image.bitmap.width, image.bitmap.height);
540
506
  }
541
507
  await image.writeAsync(output);
542
508
  }
543
- getMediaInfo(file) {
544
- return new Promise((resolve) => {
545
- const args = [
546
- '-v',
547
- 'error',
548
- '-select_streams',
549
- 'v:0',
550
- '-show_entries',
551
- 'stream=width,height,duration',
552
- '-of',
553
- 'csv=p=0:s=x',
554
- file,
509
+ async applyWatermark(input, output, watermark) {
510
+ const findLogo = () => {
511
+ const candidates = [
512
+ path.join(moduleDir, 'logo.png'),
513
+ path.join(moduleDir, '../src/logo.png'),
514
+ path.join(moduleDir, '../logo.png'),
515
+ path.join(process.cwd(), 'logo.png'),
516
+ path.join(process.cwd(), 'src/logo.png'),
517
+ path.join(process.cwd(), 'dist/cjs/logo.png'),
518
+ path.join(process.cwd(), 'dist/esm/logo.png'),
519
+ path.resolve('./logo.png')
555
520
  ];
556
- execFile('ffprobe', args, { timeout: 20000 }, (err, stdout) => {
521
+ for (const p of candidates) {
522
+ try {
523
+ if (fs.existsSync(p) && fs.statSync(p).size > 100)
524
+ return p;
525
+ }
526
+ catch { }
527
+ }
528
+ return null;
529
+ };
530
+ let isImageWatermark = false;
531
+ let logoPath = null;
532
+ let text = 'Lagos Solutions';
533
+ let position = 'bottom-right';
534
+ let margin = 10;
535
+ let sizePercent = 0.25;
536
+ if (watermark === true) {
537
+ isImageWatermark = true;
538
+ logoPath = findLogo();
539
+ }
540
+ else if (typeof watermark === 'string') {
541
+ const possiblePath = path.resolve(watermark);
542
+ if ((watermark.endsWith('.png') || watermark.endsWith('.jpg') || watermark.endsWith('.jpeg') || watermark.endsWith('.webp')) && fs.existsSync(possiblePath)) {
543
+ isImageWatermark = true;
544
+ logoPath = possiblePath;
545
+ }
546
+ else if (fs.existsSync(watermark)) {
547
+ isImageWatermark = true;
548
+ logoPath = watermark;
549
+ }
550
+ else {
551
+ text = watermark;
552
+ isImageWatermark = false;
553
+ }
554
+ }
555
+ else if (typeof watermark === 'object') {
556
+ text = watermark.text || text;
557
+ position = watermark.position || position;
558
+ margin = watermark.margin ?? margin;
559
+ sizePercent = watermark.size ?? watermark.sizePercent ?? sizePercent;
560
+ if (watermark.image || watermark.logo || watermark.path) {
561
+ isImageWatermark = true;
562
+ logoPath = watermark.image || watermark.logo || watermark.path;
563
+ if (logoPath && !fs.existsSync(logoPath)) {
564
+ const resolved = path.resolve(logoPath);
565
+ if (fs.existsSync(resolved))
566
+ logoPath = resolved;
567
+ else
568
+ logoPath = findLogo();
569
+ }
570
+ }
571
+ else if (watermark.text) {
572
+ isImageWatermark = false;
573
+ }
574
+ else {
575
+ isImageWatermark = true;
576
+ logoPath = findLogo();
577
+ }
578
+ }
579
+ if (isImageWatermark) {
580
+ if (!logoPath)
581
+ logoPath = findLogo();
582
+ if (!logoPath || !fs.existsSync(logoPath)) {
583
+ fs.copyFileSync(input, output);
584
+ return;
585
+ }
586
+ try {
587
+ const main = await Jimp.read(input);
588
+ const logo = await Jimp.read(logoPath);
589
+ const mainWidth = main.bitmap.width;
590
+ const mainHeight = main.bitmap.height;
591
+ const logoTargetWidth = Math.floor(mainWidth * sizePercent);
592
+ logo.resize(logoTargetWidth, Jimp.AUTO);
593
+ if (typeof watermark === 'object' && watermark.opacity !== undefined) {
594
+ logo.opacity(watermark.opacity);
595
+ }
596
+ else {
597
+ logo.opacity(0.85);
598
+ }
599
+ let x = mainWidth - logo.bitmap.width - margin;
600
+ let y = mainHeight - logo.bitmap.height - margin;
601
+ if (position === 'bottom-left') {
602
+ x = margin;
603
+ y = mainHeight - logo.bitmap.height - margin;
604
+ }
605
+ if (position === 'top-right') {
606
+ x = mainWidth - logo.bitmap.width - margin;
607
+ y = margin;
608
+ }
609
+ if (position === 'top-left') {
610
+ x = margin;
611
+ y = margin;
612
+ }
613
+ if (position === 'center') {
614
+ x = (mainWidth - logo.bitmap.width) / 2;
615
+ y = (mainHeight - logo.bitmap.height) / 2;
616
+ }
617
+ main.composite(logo, x, y, { mode: Jimp.BLEND_SOURCE_OVER, opacitySource: 1, opacityDest: 1 });
618
+ await main.writeAsync(output);
619
+ return;
620
+ }
621
+ catch {
622
+ fs.copyFileSync(input, output);
623
+ return;
624
+ }
625
+ }
626
+ if (!text) {
627
+ fs.copyFileSync(input, output);
628
+ return;
629
+ }
630
+ const image = await Jimp.read(input);
631
+ const font = await Jimp.loadFont(Jimp.FONT_SANS_16_WHITE);
632
+ const textWidth = Jimp.measureText(font, text);
633
+ const textHeight = Jimp.measureTextHeight(font, text, image.bitmap.width);
634
+ let x = image.bitmap.width - textWidth - margin;
635
+ let y = image.bitmap.height - textHeight - margin;
636
+ if (position === 'bottom-left') {
637
+ x = margin;
638
+ y = image.bitmap.height - textHeight - margin;
639
+ }
640
+ if (position === 'top-right') {
641
+ x = image.bitmap.width - textWidth - margin;
642
+ y = margin;
643
+ }
644
+ if (position === 'top-left') {
645
+ x = margin;
646
+ y = margin;
647
+ }
648
+ if (position === 'center') {
649
+ x = (image.bitmap.width - textWidth) / 2;
650
+ y = (image.bitmap.height - textHeight) / 2;
651
+ }
652
+ const bg = new Jimp(textWidth + 10, textHeight + 6, 0x00000088);
653
+ image.composite(bg, x - 5, y - 3);
654
+ image.print(font, x, y, text);
655
+ await image.writeAsync(output);
656
+ }
657
+ getMediaInfo(file) {
658
+ const probePath = ffprobePath || 'ffprobe';
659
+ return new Promise(resolve => {
660
+ execFile(probePath, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,duration', '-of', 'csv=p=0:s=x', file], { timeout: 20000 }, (err, stdout) => {
557
661
  if (err)
558
662
  return resolve({ width: 0, height: 0, duration: 0 });
559
663
  const parts = String(stdout).trim().split('x');
560
- resolve({
561
- width: parseInt(parts[0], 10) || 0,
562
- height: parseInt(parts[1], 10) || 0,
563
- duration: parseFloat(parts[2]) || 0,
564
- });
664
+ resolve({ width: parseInt(parts[0], 10) || 0, height: parseInt(parts[1], 10) || 0, duration: parseFloat(parts[2]) || 0 });
565
665
  });
566
666
  });
567
667
  }
568
- // ── Extras ───────────────────────────────────────────────────────────────
569
668
  static fileToBase64(filePath) {
570
669
  return fs.readFileSync(filePath).toString('base64');
571
670
  }
572
- /** 96×96 tray icon WebP for sticker packs. */
573
- async createTrayIcon(input) {
574
- const ts = Date.now();
575
- const pngPath = path.join(this.tempDir, `tray_${ts}.png`);
576
- const webpPath = path.join(this.tempDir, `tray_${ts}.webp`);
577
- let buffer;
578
- if (Buffer.isBuffer(input))
579
- buffer = input;
580
- else if (typeof input === 'string' && isHttp(input))
581
- buffer = await getBuffer(input);
582
- else if (typeof input === 'string' && fs.existsSync(input))
583
- buffer = fs.readFileSync(input);
584
- else
585
- throw new Error('Invalid tray icon input.');
586
- const image = await Jimp.read(buffer);
587
- await image.cover(96, 96).writeAsync(pngPath);
588
- await new Promise((resolve, reject) => {
589
- ffmpeg(pngPath)
590
- .on('error', reject)
591
- .on('end', () => resolve())
592
- .addOutputOptions(['-vcodec', 'libwebp', '-lossless', '1', '-q:v', '80'])
593
- .toFormat('webp')
594
- .save(webpPath);
595
- });
596
- this.track(pngPath);
597
- this.track(webpPath);
598
- return webpPath;
671
+ }
672
+ export async function sendImageAsSticker(conn, jid, input, quoted, options = {}) {
673
+ const normalized = normalizeMetadata(options);
674
+ const engine = new StickEngine({
675
+ metadata: { pack: normalized.pack, author: normalized.author, emojis: normalized.emojis, id: normalized.id },
676
+ fit: options.fit || 'natural',
677
+ quality: options.quality ?? 95,
678
+ tempDir: resolveTempDir(options),
679
+ watermark: resolveWatermark(options),
680
+ ...options
681
+ });
682
+ let finalInput = input;
683
+ if (typeof input === 'string') {
684
+ if (isDataURI(input))
685
+ finalInput = parseDataURItoBuffer(input);
686
+ else if (isHttp(input))
687
+ finalInput = await getBuffer(input);
688
+ }
689
+ const result = await engine.build(finalInput);
690
+ await conn.sendMessage(jid, { sticker: { url: result.buffer }, ...options }, { quoted });
691
+ return result.buffer;
692
+ }
693
+ export async function sendVideoAsSticker(conn, jid, input, quoted, options = {}) {
694
+ const normalized = normalizeMetadata(options);
695
+ const engine = new StickEngine({
696
+ metadata: { pack: normalized.pack, author: normalized.author, emojis: normalized.emojis, id: normalized.id },
697
+ fit: options.fit || 'natural',
698
+ fps: options.fps || 15,
699
+ maxDuration: options.maxDuration || 6,
700
+ quality: options.quality ?? 80,
701
+ tempDir: resolveTempDir(options),
702
+ watermark: resolveWatermark(options),
703
+ ...options
704
+ });
705
+ let finalInput = input;
706
+ if (typeof input === 'string') {
707
+ if (isDataURI(input))
708
+ finalInput = parseDataURItoBuffer(input);
709
+ else if (isHttp(input))
710
+ finalInput = await getBuffer(input);
599
711
  }
600
- /** Text-only sticker (transparent 512×512). */
601
- async createTextSticker(text, options = {}) {
602
- const ts = Date.now();
603
- const pngPath = path.join(this.tempDir, `text_${ts}.png`);
604
- const image = new Jimp(512, 512, options.background ?? 0x00000000);
605
- const fontPath = options.color === 'BLACK' ? Jimp.FONT_SANS_64_BLACK : Jimp.FONT_SANS_64_WHITE;
606
- const font = await Jimp.loadFont(fontPath);
607
- image.print(font, 0, 0, {
608
- text,
609
- alignmentX: Jimp.HORIZONTAL_ALIGN_CENTER,
610
- alignmentY: Jimp.VERTICAL_ALIGN_MIDDLE,
611
- }, 512, 512);
612
- await image.writeAsync(pngPath);
613
- this.track(pngPath);
614
- return this.build(pngPath);
712
+ const result = await engine.build(finalInput);
713
+ await conn.sendMessage(jid, { sticker: { url: result.buffer }, ...options }, { quoted });
714
+ return result.buffer;
715
+ }
716
+ export const sendImageAsSticker2 = sendImageAsSticker;
717
+ export const sendVideoAsSticker2 = sendVideoAsSticker;
718
+ export async function imageToWebp(media) {
719
+ const engine = new StickEngine({ fit: 'natural', quality: 95 });
720
+ const ts = `${Date.now()}_${Crypto.randomBytes(3).toString('hex')}`;
721
+ const filePath = path.join(engine.tempDir, `img_${ts}.png`);
722
+ fs.writeFileSync(filePath, media);
723
+ const out = path.join(engine.tempDir, `out_${ts}.webp`);
724
+ await engine.convertToWebp(filePath, out, false);
725
+ const buf = fs.readFileSync(out);
726
+ try {
727
+ fs.unlinkSync(filePath);
728
+ fs.unlinkSync(out);
615
729
  }
730
+ catch { }
731
+ return buf;
732
+ }
733
+ export async function videoToWebp(media) {
734
+ const engine = new StickEngine({ fit: 'natural', fps: 15, maxDuration: 5 });
735
+ const ts = `${Date.now()}_${Crypto.randomBytes(3).toString('hex')}`;
736
+ const filePath = path.join(engine.tempDir, `vid_${ts}.mp4`);
737
+ fs.writeFileSync(filePath, media);
738
+ const out = path.join(engine.tempDir, `out_${ts}.webp`);
739
+ await engine.convertToWebp(filePath, out, true);
740
+ const buf = fs.readFileSync(out);
741
+ try {
742
+ fs.unlinkSync(filePath);
743
+ fs.unlinkSync(out);
744
+ }
745
+ catch { }
746
+ return buf;
747
+ }
748
+ export async function writeExifImg(media, metadata) {
749
+ const result = await StickEngine.create(media, { metadata: { pack: metadata.packname || metadata.pack, author: metadata.author, emojis: metadata.categories || metadata.emojis } });
750
+ return result.path;
751
+ }
752
+ export async function writeExifVid(media, metadata) {
753
+ return writeExifImg(media, metadata);
616
754
  }
755
+ export const imageToWebp2 = imageToWebp;
756
+ export const videoToWebp2 = videoToWebp;
757
+ export const writeExifImg2 = writeExifImg;
758
+ export const writeExifVid2 = writeExifVid;
617
759
  export default StickEngine;
618
760
  //# sourceMappingURL=StickEngine.js.map