was-sticker 0.1.1 → 2.0.0

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.
package/README.md CHANGED
@@ -3,20 +3,23 @@
3
3
  [![CI](https://github.com/thxmxx/was-sticker/actions/workflows/ci.yml/badge.svg)](https://github.com/thxmxx/was-sticker/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/was-sticker.svg)](https://www.npmjs.com/package/was-sticker)
5
5
 
6
- Build WhatsApp animated stickers (`.was`) from an image plus a Lottie templatepure JS, no shell `zip` binary required.
6
+ Re-brand WhatsApp Lottie stickers (`.was`) and send them through Baileys as a proper `lottieStickerMessage`so they actually render on mobile.
7
7
 
8
- - **Zero shell dependencies** uses [JSZip](https://stuk.github.io/jszip/), works on Linux, macOS, Windows, Termux.
9
- - ✅ **Async everywhere** — non-blocking I/O.
10
- - **Flexible inputs** template can be a folder, a JSON file, a `Buffer`, or a parsed object.
11
- - ✅ **Flexible asset selection** — by index, asset id, or a predicate. Defaults to the first base64 image.
12
- - **Returns a `Buffer`** — feed it directly to Baileys without touching disk.
13
- - ✅ **MIME sniffing** — detects PNG/JPG/WEBP from extension *or* magic bytes.
14
- - **CLI included** `npx was-sticker -i face.png -t ./template -o out.was`.
8
+ > ⚠️ **What this is and isn't.** WhatsApp signs every animated Lottie sticker with an ES256 JWT (`trust_token`) carrying the SHA-256 of the animation JSON. Any change to the animation invalidates the token and the client silently drops the sticker. This library does **not** let you put your own image inside a Lottie animation. What it lets you do is **re-brand** a genuine WhatsApp Lottie sticker (change the pack name, publisher, emojis, group link) without breaking the signature. The animation itself stays a Meta-original sticker.
9
+ >
10
+ > If you want a sticker with your own artwork, you need WebP animated, not Lottie `.was`. That is not what this package does.
11
+
12
+ ## How it actually works
13
+
14
+ 1. **Source** a `.was` whose `trust_token` is genuine. The library ships a Baileys helper, `captureNextLottieSticker`, that listens for the next animated sticker delivered to your account and saves the encrypted `.was` to disk.
15
+ 2. **Customize** only the `overridden_metadata` inside the archive. The animation JSON and the trust_token are preserved byte-for-byte, so the client-side SHA check still passes.
16
+ 3. **Send** via `sendLottieSticker`, which wraps your sticker in a `lottieStickerMessage` (FutureProofMessage at field 74). Baileys' regular `sock.sendMessage({ sticker, mimetype })` emits `stickerMessage` (field 26) — Web tolerates it, mobile does not.
15
17
 
16
18
  ## Install
17
19
 
18
20
  ```bash
19
21
  npm install was-sticker
22
+ npm install @whiskeysockets/baileys # peer dep, only needed for send/capture
20
23
  ```
21
24
 
22
25
  Requires Node.js ≥ 18.
@@ -24,91 +27,135 @@ Requires Node.js ≥ 18.
24
27
  ## Quick start
25
28
 
26
29
  ```js
27
- import { buildLottieSticker, bundledTemplate } from 'was-sticker';
28
-
29
- const { buffer } = await buildLottieSticker({
30
- image: 'face.png',
31
- template: bundledTemplate('pulse'),
30
+ import { readFile, writeFile } from 'node:fs/promises';
31
+ import {
32
+ inspectWAS,
33
+ customizeMetadata,
34
+ sendLottieSticker,
35
+ captureNextLottieSticker,
36
+ } from 'was-sticker';
37
+
38
+ // 1. Source a .was by waiting for one to arrive.
39
+ const { buffer } = await captureNextLottieSticker(sock, { timeoutMs: 120_000 });
40
+ await writeFile('./pumpkin.was', buffer);
41
+
42
+ // 2. Inspect it (sanity check).
43
+ console.log(await inspectWAS(buffer));
44
+ // → { animation: { nm: 'WA_Harvest_…', w: 512, h: 512, fps: 60, ... },
45
+ // trustToken: { kid: '196', alg: 'ES256', claimedSha: '...' },
46
+ // shaMatches: true, ... }
47
+
48
+ // 3. Re-brand it.
49
+ const rebranded = await customizeMetadata(buffer, {
50
+ packId: 'my-bot-pack-v1',
51
+ packName: 'My Bot Pack',
52
+ publisher: 'Bot\nMade with was-sticker',
53
+ accessibilityText: 'A custom animated sticker',
54
+ emojis: ['💎', '✨'],
32
55
  });
33
- // → buffer is a ready-to-send `.was` sticker
34
- ```
35
56
 
36
- The package ships with a `pulse` template (1-second scale pulse, 30 fps) so you can produce a sticker out of the box without sourcing a Lottie file.
57
+ // 4. Send it on Baileys with the right wrapper.
58
+ await sendLottieSticker(sock, '5511…@s.whatsapp.net', rebranded);
59
+ ```
37
60
 
38
61
  ## API
39
62
 
40
- ```js
41
- import { buildLottieSticker } from 'was-sticker';
42
-
43
- const { buffer, output, mime } = await buildLottieSticker({
44
- image: 'face.png', // path | Buffer | { buffer, path, mime }
45
- template: './templates/heart', // folder | JSON path | Buffer | parsed object
46
- output: './out/heart.was', // optional — omit to keep it in-memory
47
- assetSelector: 0, // optional — number | string (id) | (asset) => boolean
48
- jsonEntryName: 'animation/anim.json', // optional — path inside the .was for the JSON
49
- extraFiles: { 'meta.json': '{}' }, // optional — additional files to bundle
50
- });
51
- ```
63
+ ### `extractFromWAS(buffer)`
64
+
65
+ Parses the ZIP archive. Returns `{ files, jsonPath, animation, trustToken, trustTokenPath, metadata, metadataPath }`.
52
66
 
53
- | Input form | What it does |
54
- | ------------------- | ----------------------------------------------------------- |
55
- | `image: 'face.png'` | Reads file, sniffs MIME from extension and magic bytes. |
56
- | `image: buffer` | Sniffs MIME from magic bytes. |
57
- | `image: { buffer, mime }` | Trust caller-provided MIME. |
58
- | `template: './folder'` | Walks folder; treats `*.json` as the Lottie, bundles the rest. |
59
- | `template: './lottie.json'` | Reads and parses the single file. |
60
- | `template: object` | Uses the parsed Lottie (deep-cloned, not mutated). |
67
+ ### `inspectWAS(buffer)`
61
68
 
62
- The return value:
69
+ Higher-level summary intended for CLI / debugging output:
63
70
 
64
71
  ```ts
65
- { buffer: Buffer, output?: string, mime: 'application/was' }
72
+ {
73
+ jsonPath: string,
74
+ animation: { nm, version, width, height, fps, durationFrames, layers, assets },
75
+ metadata: object | null,
76
+ trustToken: { kid, alg, stickerFileType, trustedOrigin, claimedSha } | null,
77
+ sha256: string,
78
+ shaMatches: boolean, // <-- this is the only field that ultimately matters
79
+ size: number,
80
+ fileNames: string[],
81
+ }
66
82
  ```
67
83
 
68
- ## CLI
84
+ If `shaMatches` is `false`, the WhatsApp client will reject the sticker.
69
85
 
70
- ```bash
71
- was-sticker --image face.png --template ./templates/heart --out heart.was
72
- was-sticker -i face.png -t lottie.json -s image_0
86
+ ### `customizeMetadata(buffer, patch, { merge = true })`
87
+
88
+ Returns a new `.was` buffer with `overridden_metadata` rewritten. Allowed `patch` fields:
89
+
90
+ | Camel-case input | Underlying WhatsApp field |
91
+ | ------------------- | --------------------------------- |
92
+ | `packId` | `sticker-pack-id` |
93
+ | `packName` | `sticker-pack-name` |
94
+ | `publisher` | `sticker-pack-publisher` (newlines OK — the second line typically holds a group link) |
95
+ | `accessibilityText` | `accessibility-text` |
96
+ | `emojis` | `emojis` (array of glyphs) |
97
+ | `isFromUserCreatedPack` | `is-from-user-created-pack` (default `1`) |
98
+
99
+ `merge: false` rewrites the metadata from scratch instead of merging with what's already there.
100
+
101
+ The animation JSON and the trust_token are not touched.
102
+
103
+ ### `sendLottieSticker(sock, jid, buffer, opts?)`
104
+
105
+ Uploads `buffer` via Baileys' `prepareWAMessageMedia`, then relays it as a `lottieStickerMessage`.
106
+
107
+ ```ts
108
+ opts?: {
109
+ width?: number,
110
+ height?: number,
111
+ accessibilityLabel?: string,
112
+ messageId?: string,
113
+ quoted?: WAMessage,
114
+ }
73
115
  ```
74
116
 
75
- ## Baileys integration
117
+ Returns `{ messageId, fileLength }`.
76
118
 
77
- ```js
78
- import { buildLottieSticker } from 'was-sticker';
119
+ ### `captureNextLottieSticker(sock, opts?)`
79
120
 
80
- const { buffer } = await buildLottieSticker({
81
- image: incomingPhotoBuffer, // a Buffer you already have in memory
82
- template: './templates/heart',
83
- });
121
+ Resolves with the next incoming Lottie sticker, downloaded and decrypted.
84
122
 
85
- await sock.sendMessage(jid, {
86
- sticker: buffer,
87
- mimetype: 'application/was',
88
- });
123
+ ```ts
124
+ opts?: {
125
+ timeoutMs?: number, // default 60_000; 0 = no timeout
126
+ from?: string, // restrict to one JID
127
+ filter?: (stk, key) => boolean,
128
+ includeNonLottie?: boolean, // default false
129
+ }
89
130
  ```
90
131
 
91
- ## Bring your own template
132
+ Returns `{ buffer, stickerMessage, key, mimetype, isLottie, isAnimated, width, height }`.
133
+
134
+ ## CLI
135
+
136
+ ```bash
137
+ was-sticker inspect <in.was>
138
+ was-sticker customize <in.was> -o <out.was> \
139
+ --pack-name "My Pack" \
140
+ --publisher "Me\nGrupo: https://example.com" \
141
+ --emoji 🎃 --emoji 💎
142
+ ```
92
143
 
93
- A `.was` is just a ZIP archive containing a Lottie JSON (and any sibling assets the animation references). To use a custom template:
144
+ `inspect` prints the JSON summary above. `customize` writes the rebranded archive.
94
145
 
95
- 1. Find or design a Lottie animation that embeds the image you want to replace as a base64 `data:` URI inside its `assets[]` entry.
96
- 2. Drop it into a folder, e.g. `./templates/heart/animation/animation.json`.
97
- 3. Pass that folder as `template`.
146
+ Sending and capturing are intentionally not in the CLI both require a connected Baileys socket; do them from a script (see `examples/`).
98
147
 
99
- If your Lottie file references external assets (sibling PNGs, fonts, etc.), keep them in the same folder — they are bundled into the `.was` automatically.
148
+ ## Examples
100
149
 
101
- ## Roadmap / suggested improvements
150
+ - [`examples/capture.js`](./examples/capture.js) wait for a forwarded sticker and save it.
151
+ - [`examples/customize-and-send.js`](./examples/customize-and-send.js) — rebrand and send.
102
152
 
103
- These are deliberately *not* in v0.1 to keep the surface area small, but each one would be a natural follow-up:
153
+ ## Notes / gotchas
104
154
 
105
- - **`sharp` preprocessing hook** auto-resize to 512×512, convert to WebP, enforce WhatsApp's ~500 KB sticker budget. Keep it as an optional peer dep.
106
- - **Template registry** ship a few permissively-licensed Lottie templates (heart, fire, sparkles) so users don't have to source one.
107
- - **Multi-frame stickers** accept an array of images, write each into a separate animated layer.
108
- - **Validation pass** — warn when the produced `.was` exceeds WhatsApp's recommended size, or when frame rate × duration looks off.
109
- - **TypeScript declarations** — emit `.d.ts` alongside the JSDoc.
110
- - **Streaming output** — for very large bundles, stream the ZIP to disk instead of buffering.
155
+ - **Mobile vs Web parity.** Mobile WhatsApp will silently drop Lottie payloads that arrive as plain `stickerMessage`. Web will render them. Use `sendLottieSticker`, not `sock.sendMessage({ sticker, mimetype: 'application/was' })`.
156
+ - **The pack info shown in Web** comes from WhatsApp's official catalog keyed by the animation's identity, not from your `overridden_metadata`. Mobile reads your metadata. This is a WhatsApp quirk, not a bug.
157
+ - **Use at your own risk.** Sending modified `.was` files through unofficial clients (Baileys is one) violates WhatsApp's Terms of Service and can result in your number being banned. Test on a throwaway account.
111
158
 
112
159
  ## License
113
160
 
114
- MIT.
161
+ MIT — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "was-sticker",
3
- "version": "0.1.1",
4
- "description": "Build WhatsApp animated stickers (.was) from images and Lottie templates. Pure JS, no shell dependencies.",
3
+ "version": "2.0.0",
4
+ "description": "Re-brand WhatsApp Lottie stickers (.was) and send them via Baileys as a real lottieStickerMessage.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -12,7 +12,6 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
- "templates",
16
15
  "README.md",
17
16
  "LICENSE"
18
17
  ],
@@ -20,7 +19,6 @@
20
19
  "node": ">=18"
21
20
  },
22
21
  "scripts": {
23
- "example": "node examples/build.js",
24
22
  "test": "node --test test/smoke.test.js"
25
23
  },
26
24
  "keywords": [
@@ -29,7 +27,8 @@
29
27
  "lottie",
30
28
  "was",
31
29
  "animated-sticker",
32
- "baileys"
30
+ "baileys",
31
+ "lottieStickerMessage"
33
32
  ],
34
33
  "author": "thxmxx",
35
34
  "license": "MIT",
@@ -43,5 +42,13 @@
43
42
  },
44
43
  "dependencies": {
45
44
  "jszip": "^3.10.1"
45
+ },
46
+ "peerDependencies": {
47
+ "@whiskeysockets/baileys": ">=6.7.0"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@whiskeysockets/baileys": {
51
+ "optional": true
52
+ }
46
53
  }
47
54
  }
package/src/capture.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Listen for the next incoming Lottie sticker and return its decrypted buffer.
3
+ * Useful for sourcing genuine `.was` files (with Meta-signed trust_tokens) that
4
+ * you can then customize with `customizeMetadata`.
5
+ *
6
+ * WhatsApp routes Lottie stickers via `lottieStickerMessage` (FutureProofMessage
7
+ * at field 74) — Baileys' default `downloadMediaMessage` doesn't handle that
8
+ * variant. This helper walks the message tree, finds the inner stickerMessage,
9
+ * and uses the lower-level `downloadContentFromMessage` to decrypt.
10
+ */
11
+
12
+ async function loadBaileys() {
13
+ try {
14
+ return await import('@whiskeysockets/baileys');
15
+ } catch {
16
+ throw new Error(
17
+ 'captureNextLottieSticker requires "@whiskeysockets/baileys" — install it as a peer dependency.',
18
+ );
19
+ }
20
+ }
21
+
22
+ function findStickerMessage(msg) {
23
+ if (!msg || typeof msg !== 'object') return null;
24
+ if (msg.stickerMessage) return msg.stickerMessage;
25
+ for (const v of Object.values(msg)) {
26
+ if (v && typeof v === 'object') {
27
+ const inner = findStickerMessage(v);
28
+ if (inner) return inner;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Resolve when the next sticker (Lottie or otherwise) matching `filter` arrives.
36
+ *
37
+ * @param {object} sock — connected Baileys socket
38
+ * @param {{
39
+ * timeoutMs?: number, // default 60_000; 0 = no timeout
40
+ * from?: string, // restrict to a specific JID
41
+ * filter?: (stickerMessage, key) => boolean // custom predicate
42
+ * includeNonLottie?: boolean, // default false — only Lottie/.was
43
+ * }} [opts]
44
+ * @returns {Promise<{
45
+ * buffer: Buffer,
46
+ * stickerMessage: object,
47
+ * key: { remoteJid: string, id: string, fromMe: boolean },
48
+ * mimetype: string,
49
+ * isLottie: boolean,
50
+ * isAnimated: boolean,
51
+ * width: number,
52
+ * height: number,
53
+ * }>}
54
+ */
55
+ export function captureNextLottieSticker(sock, opts = {}) {
56
+ if (!sock || typeof sock.ev?.on !== 'function') {
57
+ throw new Error('captureNextLottieSticker: first argument must be a Baileys socket.');
58
+ }
59
+ const { timeoutMs = 60_000, from, filter, includeNonLottie = false } = opts;
60
+
61
+ return new Promise((resolve, reject) => {
62
+ let settled = false;
63
+ let timer = null;
64
+
65
+ const onUpsert = async ({ messages }) => {
66
+ if (settled) return;
67
+ for (const m of messages) {
68
+ if (settled) break;
69
+ if (!m.message) continue;
70
+ if (from && m.key.remoteJid !== from) continue;
71
+
72
+ const stk = findStickerMessage(m.message);
73
+ if (!stk) continue;
74
+ if (!includeNonLottie && !stk.isLottie) continue;
75
+ if (filter && !filter(stk, m.key)) continue;
76
+
77
+ try {
78
+ const { downloadContentFromMessage } = await loadBaileys();
79
+ const stream = await downloadContentFromMessage(stk, 'sticker');
80
+ const chunks = [];
81
+ for await (const c of stream) chunks.push(c);
82
+ const buffer = Buffer.concat(chunks);
83
+
84
+ settled = true;
85
+ if (timer) clearTimeout(timer);
86
+ sock.ev.off('messages.upsert', onUpsert);
87
+ resolve({
88
+ buffer,
89
+ stickerMessage: stk,
90
+ key: m.key,
91
+ mimetype: stk.mimetype ?? null,
92
+ isLottie: !!stk.isLottie,
93
+ isAnimated: !!stk.isAnimated,
94
+ width: stk.width ?? 0,
95
+ height: stk.height ?? 0,
96
+ });
97
+ } catch (err) {
98
+ settled = true;
99
+ if (timer) clearTimeout(timer);
100
+ sock.ev.off('messages.upsert', onUpsert);
101
+ reject(err);
102
+ }
103
+ return;
104
+ }
105
+ };
106
+
107
+ sock.ev.on('messages.upsert', onUpsert);
108
+
109
+ if (timeoutMs > 0) {
110
+ timer = setTimeout(() => {
111
+ if (settled) return;
112
+ settled = true;
113
+ sock.ev.off('messages.upsert', onUpsert);
114
+ reject(new Error(`captureNextLottieSticker: timed out after ${timeoutMs}ms.`));
115
+ }, timeoutMs);
116
+ }
117
+ });
118
+ }
package/src/cli.js CHANGED
@@ -1,26 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
- import { resolve } from 'node:path';
3
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
+ import { dirname, resolve } from 'node:path';
4
5
 
5
- import { buildLottieSticker } from './index.js';
6
+ import { inspectWAS } from './extract.js';
7
+ import { customizeMetadata } from './customize.js';
6
8
 
7
- const USAGE = `was-sticker — build WhatsApp animated stickers from a Lottie template.
9
+ const USAGE = `was-sticker — inspect and re-brand WhatsApp Lottie stickers (.was).
8
10
 
9
11
  Usage:
10
- was-sticker --image <path> --template <path> [--out sticker.was]
11
- [--selector <id|index>] [--json-entry <path-in-archive>]
12
-
13
- Options:
14
- -i, --image Image file (PNG, JPG, WEBP). [required]
15
- -t, --template Lottie folder, JSON file, or pre-parsed JSON. [required]
16
- -o, --out Output .was path. Default: ./sticker.was
17
- -s, --selector Asset id (string) or 0-based index.
18
- --json-entry Path of the Lottie JSON inside the .was.
19
- -h, --help Show this help.
20
-
21
- Examples:
22
- was-sticker -i face.png -t ./templates/heart -o heart.was
23
- was-sticker -i face.png -t lottie.json -s image_0
12
+ was-sticker inspect <in.was>
13
+ was-sticker customize <in.was> -o <out.was>
14
+ [--pack-id ID] [--pack-name NAME]
15
+ [--publisher PUBLISHER] [--accessibility-text TEXT]
16
+ [--emoji EMOJI ... | --emojis "🎃,🎉,💎"]
17
+ [--no-merge]
18
+
19
+ Subcommands:
20
+ inspect Show the Lottie metadata, trust-token claims, and SHA match.
21
+ customize Rewrite only the overridden_metadata; emits a new .was.
22
+
23
+ For sending and capturing .was files, use the JS API:
24
+ import { sendLottieSticker, captureNextLottieSticker } from 'was-sticker';
24
25
  `;
25
26
 
26
27
  function fail(msg, code = 1) {
@@ -28,46 +29,74 @@ function fail(msg, code = 1) {
28
29
  process.exit(code);
29
30
  }
30
31
 
31
- const { values } = parseArgs({
32
- options: {
33
- image: { type: 'string', short: 'i' },
34
- template: { type: 'string', short: 't' },
35
- out: { type: 'string', short: 'o' },
36
- selector: { type: 'string', short: 's' },
37
- 'json-entry': { type: 'string' },
38
- help: { type: 'boolean', short: 'h' },
39
- },
40
- allowPositionals: false,
41
- });
42
-
43
- if (values.help) {
32
+ const [, , sub, ...rest] = process.argv;
33
+
34
+ if (!sub || sub === '-h' || sub === '--help') {
44
35
  process.stdout.write(USAGE);
45
- process.exit(0);
36
+ process.exit(sub ? 0 : 1);
37
+ }
38
+
39
+ async function readInput(positional) {
40
+ if (!positional) fail('Missing <in.was>.');
41
+ return readFile(resolve(positional));
42
+ }
43
+
44
+ async function writeOutput(path, buffer) {
45
+ const abs = resolve(path);
46
+ await mkdir(dirname(abs), { recursive: true });
47
+ await writeFile(abs, buffer);
48
+ return abs;
46
49
  }
47
50
 
48
- if (!values.image || !values.template) {
49
- process.stderr.write(USAGE);
50
- fail('\nMissing required --image or --template.');
51
+ if (sub === 'inspect') {
52
+ const buffer = await readInput(rest[0]);
53
+ const info = await inspectWAS(buffer);
54
+ process.stdout.write(JSON.stringify(info, null, 2) + '\n');
55
+ process.exit(0);
51
56
  }
52
57
 
53
- const output = resolve(values.out ?? './sticker.was');
54
-
55
- const selector =
56
- values.selector == null
57
- ? undefined
58
- : /^\d+$/.test(values.selector)
59
- ? Number(values.selector)
60
- : values.selector;
61
-
62
- try {
63
- const { output: written, buffer } = await buildLottieSticker({
64
- image: values.image,
65
- template: values.template,
66
- output,
67
- assetSelector: selector,
68
- jsonEntryName: values['json-entry'],
58
+ if (sub === 'customize') {
59
+ const { values, positionals } = parseArgs({
60
+ args: rest,
61
+ allowPositionals: true,
62
+ options: {
63
+ out: { type: 'string', short: 'o' },
64
+ 'pack-id': { type: 'string' },
65
+ 'pack-name': { type: 'string' },
66
+ publisher: { type: 'string' },
67
+ 'accessibility-text': { type: 'string' },
68
+ emoji: { type: 'string', multiple: true },
69
+ emojis: { type: 'string' },
70
+ 'no-merge': { type: 'boolean' },
71
+ help: { type: 'boolean', short: 'h' },
72
+ },
69
73
  });
70
- process.stdout.write(`${written} (${buffer.length.toLocaleString()} bytes)\n`);
71
- } catch (err) {
72
- fail(`Error: ${err.message}`);
74
+
75
+ if (values.help) {
76
+ process.stdout.write(USAGE);
77
+ process.exit(0);
78
+ }
79
+ if (!positionals[0]) fail('Missing <in.was>.');
80
+ if (!values.out) fail('Missing --out / -o.');
81
+
82
+ const buffer = await readInput(positionals[0]);
83
+
84
+ const emojis = values.emoji?.length
85
+ ? values.emoji
86
+ : values.emojis?.split(',').map((s) => s.trim()).filter(Boolean);
87
+
88
+ const patch = {
89
+ ...(values['pack-id'] && { packId: values['pack-id'] }),
90
+ ...(values['pack-name'] && { packName: values['pack-name'] }),
91
+ ...(values.publisher && { publisher: values.publisher }),
92
+ ...(values['accessibility-text'] && { accessibilityText: values['accessibility-text'] }),
93
+ ...(emojis && { emojis }),
94
+ };
95
+
96
+ const out = await customizeMetadata(buffer, patch, { merge: !values['no-merge'] });
97
+ const path = await writeOutput(values.out, out);
98
+ process.stdout.write(`${path} (${out.length.toLocaleString()} bytes)\n`);
99
+ process.exit(0);
73
100
  }
101
+
102
+ fail(`Unknown subcommand "${sub}".\n\n${USAGE}`);
@@ -0,0 +1,72 @@
1
+ import JSZip from 'jszip';
2
+ import { extractFromWAS } from './extract.js';
3
+
4
+ const ALLOWED_FIELDS = Object.freeze({
5
+ packId: 'sticker-pack-id',
6
+ packName: 'sticker-pack-name',
7
+ publisher: 'sticker-pack-publisher',
8
+ accessibilityText: 'accessibility-text',
9
+ emojis: 'emojis',
10
+ isFromUserCreatedPack: 'is-from-user-created-pack',
11
+ });
12
+
13
+ function patchToWaMetadata(patch) {
14
+ if (patch == null || typeof patch !== 'object') {
15
+ throw new Error('customizeMetadata: patch must be an object.');
16
+ }
17
+ const out = {};
18
+ for (const [camelKey, waKey] of Object.entries(ALLOWED_FIELDS)) {
19
+ if (patch[camelKey] !== undefined) out[waKey] = patch[camelKey];
20
+ }
21
+ // Pass through any "wa-style" keys verbatim (escape hatch for advanced users).
22
+ for (const k of Object.keys(patch)) {
23
+ if (Object.values(ALLOWED_FIELDS).includes(k)) out[k] = patch[k];
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /**
29
+ * Returns a new `.was` buffer with the overridden_metadata replaced or merged.
30
+ *
31
+ * The animation JSON and its trust_token are preserved byte-for-byte so the
32
+ * client-side SHA check still passes.
33
+ *
34
+ * @param {Buffer|Uint8Array} buffer
35
+ * @param {{
36
+ * packId?: string,
37
+ * packName?: string,
38
+ * publisher?: string,
39
+ * accessibilityText?: string,
40
+ * emojis?: string[],
41
+ * isFromUserCreatedPack?: 0 | 1,
42
+ * }} patch
43
+ * @param {{ merge?: boolean }} [opts] — when true (default), unspecified fields
44
+ * are kept from the existing metadata; when false, the metadata is rewritten
45
+ * from scratch using only the patch.
46
+ * @returns {Promise<Buffer>}
47
+ */
48
+ export async function customizeMetadata(buffer, patch, opts = {}) {
49
+ const { merge = true } = opts;
50
+ const { files, jsonPath, metadata } = await extractFromWAS(buffer);
51
+
52
+ const waPatch = patchToWaMetadata(patch);
53
+ const next = merge ? { ...(metadata ?? {}), ...waPatch } : { ...waPatch };
54
+
55
+ // is-from-user-created-pack is expected by WhatsApp on custom packs — default to 1.
56
+ if (next['is-from-user-created-pack'] === undefined) {
57
+ next['is-from-user-created-pack'] = 1;
58
+ }
59
+
60
+ const metadataPath = `${jsonPath}.overridden_metadata`;
61
+ files[metadataPath] = Buffer.from(JSON.stringify(next), 'utf8');
62
+
63
+ const zip = new JSZip();
64
+ for (const [name, contents] of Object.entries(files)) {
65
+ zip.file(name, contents);
66
+ }
67
+ return zip.generateAsync({
68
+ type: 'nodebuffer',
69
+ compression: 'DEFLATE',
70
+ compressionOptions: { level: 6 },
71
+ });
72
+ }
package/src/extract.js ADDED
@@ -0,0 +1,117 @@
1
+ import { createHash } from 'node:crypto';
2
+ import JSZip from 'jszip';
3
+
4
+ /**
5
+ * Decode the payload of a JWT trust_token (no signature verification — that's
6
+ * the WhatsApp client's job; we only inspect the claims).
7
+ */
8
+ function decodeTrustToken(token) {
9
+ if (typeof token !== 'string') return null;
10
+ const [headerB64, payloadB64, sigB64] = token.split('.');
11
+ if (!headerB64 || !payloadB64 || !sigB64) return null;
12
+ const fromB64 = (s) => Buffer.from(s + '='.repeat((4 - (s.length % 4)) % 4), 'base64');
13
+ const header = JSON.parse(fromB64(headerB64).toString('utf8'));
14
+ const payload = JSON.parse(fromB64(payloadB64).toString('utf8'));
15
+ // payload.sticker_file_sha256 is base64url-encoded SHA-256
16
+ const claimedShaHex = payload.sticker_file_sha256
17
+ ? fromB64(payload.sticker_file_sha256).toString('hex')
18
+ : null;
19
+ return { header, payload, claimedShaHex, raw: token };
20
+ }
21
+
22
+ /**
23
+ * Parse a `.was` archive.
24
+ *
25
+ * @param {Buffer|Uint8Array} buffer
26
+ * @returns {Promise<{
27
+ * files: Record<string, Buffer>,
28
+ * jsonPath: string,
29
+ * animation: object,
30
+ * trustToken: ReturnType<typeof decodeTrustToken> | null,
31
+ * trustTokenPath: string | null,
32
+ * metadata: object | null,
33
+ * metadataPath: string | null,
34
+ * }>}
35
+ */
36
+ export async function extractFromWAS(buffer) {
37
+ if (!buffer || (!Buffer.isBuffer(buffer) && !(buffer instanceof Uint8Array))) {
38
+ throw new Error('extractFromWAS: buffer is required.');
39
+ }
40
+ const zip = await JSZip.loadAsync(buffer);
41
+
42
+ const files = {};
43
+ await Promise.all(
44
+ Object.values(zip.files)
45
+ .filter((f) => !f.dir)
46
+ .map(async (f) => { files[f.name] = await f.async('nodebuffer'); }),
47
+ );
48
+
49
+ // Pick the primary Lottie JSON: prefer `animation.json`, else first non-metadata JSON.
50
+ const jsonNames = Object.keys(files).filter(
51
+ (n) => n.toLowerCase().endsWith('.json') &&
52
+ !n.endsWith('.overridden_metadata') &&
53
+ !n.endsWith('.trust_token'),
54
+ );
55
+ if (jsonNames.length === 0) {
56
+ throw new Error('No Lottie JSON found in archive.');
57
+ }
58
+ const jsonPath = jsonNames.find((n) => n.endsWith('animation.json')) ?? jsonNames[0];
59
+
60
+ let animation;
61
+ try {
62
+ animation = JSON.parse(files[jsonPath].toString('utf8'));
63
+ } catch (err) {
64
+ throw new Error(`Could not parse "${jsonPath}" as JSON: ${err.message}`);
65
+ }
66
+
67
+ const trustTokenPath = `${jsonPath}.trust_token`;
68
+ const trustToken = files[trustTokenPath]
69
+ ? decodeTrustToken(files[trustTokenPath].toString('utf8').trim())
70
+ : null;
71
+
72
+ const metadataPath = `${jsonPath}.overridden_metadata`;
73
+ let metadata = null;
74
+ if (files[metadataPath]) {
75
+ try { metadata = JSON.parse(files[metadataPath].toString('utf8')); }
76
+ catch { /* leave metadata null if it's not parseable */ }
77
+ }
78
+
79
+ return { files, jsonPath, animation, trustToken, trustTokenPath, metadata, metadataPath };
80
+ }
81
+
82
+ /**
83
+ * Human-friendly summary of a `.was` — useful for CLI inspection.
84
+ */
85
+ export async function inspectWAS(buffer) {
86
+ const { jsonPath, animation, trustToken, metadata, files } = await extractFromWAS(buffer);
87
+
88
+ const jsonBytes = files[jsonPath];
89
+ const actualSha = createHash('sha256').update(jsonBytes).digest('hex');
90
+ const claimedSha = trustToken?.claimedShaHex ?? null;
91
+
92
+ return {
93
+ jsonPath,
94
+ animation: {
95
+ nm: animation.nm ?? null,
96
+ version: animation.v ?? null,
97
+ width: animation.w ?? null,
98
+ height: animation.h ?? null,
99
+ fps: animation.fr ?? null,
100
+ durationFrames: (animation.op ?? 0) - (animation.ip ?? 0),
101
+ layers: Array.isArray(animation.layers) ? animation.layers.length : 0,
102
+ assets: Array.isArray(animation.assets) ? animation.assets.length : 0,
103
+ },
104
+ metadata,
105
+ trustToken: trustToken && {
106
+ kid: trustToken.header?.kid ?? null,
107
+ alg: trustToken.header?.alg ?? null,
108
+ stickerFileType: trustToken.payload?.sticker_file_type ?? null,
109
+ trustedOrigin: trustToken.payload?.sticker_file_trusted_origin ?? null,
110
+ claimedSha,
111
+ },
112
+ sha256: actualSha,
113
+ shaMatches: claimedSha !== null && claimedSha === actualSha,
114
+ size: jsonBytes.length,
115
+ fileNames: Object.keys(files),
116
+ };
117
+ }
package/src/index.js CHANGED
@@ -1,235 +1,22 @@
1
- import { readFile, writeFile, mkdir, stat, readdir } from 'node:fs/promises';
2
- import { dirname, join, relative, resolve, sep } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import JSZip from 'jszip';
5
-
6
- import {
7
- SUPPORTED_MIMES,
8
- detectMimeFromBuffer,
9
- detectMimeFromExtension,
10
- } from './mime.js';
11
-
12
- const TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));
13
- const BUNDLED_TEMPLATE_NAMES = Object.freeze(['pulse']);
14
-
15
1
  /**
16
- * Resolve the absolute path of a Lottie template bundled with this package.
17
- * @param {'pulse'} [name='pulse']
18
- * @returns {string} absolute path to the template's JSON file
19
- */
20
- export function bundledTemplate(name = 'pulse') {
21
- if (!BUNDLED_TEMPLATE_NAMES.includes(name)) {
22
- throw new Error(
23
- `Unknown bundled template "${name}". Available: ${BUNDLED_TEMPLATE_NAMES.join(', ')}.`,
24
- );
25
- }
26
- return join(TEMPLATES_DIR, name, 'animation.json');
27
- }
28
-
29
- const DEFAULT_JSON_ENTRY = 'animation/animation.json';
30
-
31
- const toDataUri = (buffer, mime) =>
32
- `data:${mime};base64,${buffer.toString('base64')}`;
33
-
34
- async function resolveImage(image) {
35
- if (image == null) throw new Error('image is required.');
36
-
37
- let buffer;
38
- let mime;
39
- let path;
40
-
41
- if (typeof image === 'string') {
42
- path = image;
43
- } else if (Buffer.isBuffer(image)) {
44
- buffer = image;
45
- } else if (typeof image === 'object') {
46
- ({ buffer, mime, path } = image);
47
- } else {
48
- throw new Error('image must be a string path, Buffer, or { buffer | path, mime? }.');
49
- }
50
-
51
- if (!buffer) {
52
- if (!path) throw new Error('image must include `buffer` or `path`.');
53
- buffer = await readFile(path);
54
- }
55
-
56
- mime ??= detectMimeFromExtension(path) ?? detectMimeFromBuffer(buffer);
57
- if (!mime) {
58
- throw new Error(
59
- `Unable to detect image mime. Supported: ${SUPPORTED_MIMES.join(', ')}.`,
60
- );
61
- }
62
- if (!SUPPORTED_MIMES.includes(mime)) {
63
- throw new Error(`Unsupported mime "${mime}". Use one of: ${SUPPORTED_MIMES.join(', ')}.`);
64
- }
65
-
66
- return { buffer, mime };
67
- }
68
-
69
- async function walkFiles(root) {
70
- const out = [];
71
- async function visit(dir) {
72
- for (const entry of await readdir(dir, { withFileTypes: true })) {
73
- const full = join(dir, entry.name);
74
- if (entry.isDirectory()) await visit(full);
75
- else if (entry.isFile()) out.push(full);
76
- }
77
- }
78
- await visit(root);
79
- return out.map((abs) => ({ abs, rel: relative(root, abs).split(sep).join('/') }));
80
- }
81
-
82
- async function resolveTemplate(template, jsonEntryName) {
83
- if (template == null) {
84
- throw new Error('template is required (folder path, JSON file path, Buffer, or parsed object).');
85
- }
86
-
87
- if (typeof template === 'string') {
88
- const info = await stat(template);
89
- if (info.isDirectory()) {
90
- const files = await walkFiles(template);
91
- const candidate =
92
- files.find((f) => f.rel === jsonEntryName) ??
93
- files.find((f) => f.rel.toLowerCase().endsWith('.json'));
94
- if (!candidate) {
95
- throw new Error(`No JSON file found in template folder "${template}".`);
96
- }
97
- const lottie = JSON.parse(await readFile(candidate.abs, 'utf8'));
98
- const extras = await Promise.all(
99
- files
100
- .filter((f) => f.abs !== candidate.abs)
101
- .map(async (f) => [f.rel, await readFile(f.abs)]),
102
- );
103
- return {
104
- lottie,
105
- jsonEntryName: candidate.rel,
106
- extraFiles: Object.fromEntries(extras),
107
- };
108
- }
109
- return {
110
- lottie: JSON.parse(await readFile(template, 'utf8')),
111
- jsonEntryName: jsonEntryName ?? DEFAULT_JSON_ENTRY,
112
- extraFiles: {},
113
- };
114
- }
115
-
116
- if (Buffer.isBuffer(template)) {
117
- return {
118
- lottie: JSON.parse(template.toString('utf8')),
119
- jsonEntryName: jsonEntryName ?? DEFAULT_JSON_ENTRY,
120
- extraFiles: {},
121
- };
122
- }
123
-
124
- if (typeof template === 'object') {
125
- return {
126
- lottie: structuredClone(template),
127
- jsonEntryName: jsonEntryName ?? DEFAULT_JSON_ENTRY,
128
- extraFiles: {},
129
- };
130
- }
131
-
132
- throw new Error('template must be a path, Buffer, or parsed Lottie object.');
133
- }
134
-
135
- function findImageAsset(lottie, selector) {
136
- if (!lottie || !Array.isArray(lottie.assets) || lottie.assets.length === 0) {
137
- throw new Error('Lottie template has no `assets` array.');
138
- }
139
-
140
- const isImageAsset = (a) => typeof a?.p === 'string' && a.p.startsWith('data:image/');
141
-
142
- if (selector == null) {
143
- const found = lottie.assets.find(isImageAsset);
144
- if (!found) {
145
- throw new Error(
146
- 'No embedded base64 image asset found. Pass assetSelector to target a specific asset.',
147
- );
148
- }
149
- return found;
150
- }
151
-
152
- if (typeof selector === 'number') {
153
- const found = lottie.assets[selector];
154
- if (!found) throw new Error(`No asset at index ${selector}.`);
155
- return found;
156
- }
157
-
158
- if (typeof selector === 'string') {
159
- const found = lottie.assets.find((a) => a?.id === selector);
160
- if (!found) throw new Error(`No asset with id "${selector}".`);
161
- return found;
162
- }
163
-
164
- if (typeof selector === 'function') {
165
- const found = lottie.assets.find(selector);
166
- if (!found) throw new Error('assetSelector did not match any asset.');
167
- return found;
168
- }
169
-
170
- throw new Error('assetSelector must be a number, string, function, or undefined.');
171
- }
172
-
173
- /**
174
- * Build a WhatsApp animated sticker (`.was`) from an image and a Lottie template.
2
+ * was-sticker re-brand a Meta WhatsApp Lottie sticker (`.was`) and ship it
3
+ * through Baileys as a real `lottieStickerMessage` so it renders on mobile.
175
4
  *
176
- * @param {object} options
177
- * @param {string | Buffer | { buffer?: Buffer, path?: string, mime?: string }} options.image
178
- * Image source. Either a file path, a raw Buffer, or an object with `buffer`/`path` and optional `mime`.
179
- * @param {string | Buffer | object} options.template
180
- * Template source: a folder path, a JSON file path, a raw Buffer, or a parsed Lottie object.
181
- * @param {string} [options.output]
182
- * If provided, the built `.was` is written here. The directory is created if missing.
183
- * @param {number | string | ((asset: object) => boolean)} [options.assetSelector]
184
- * Which asset to swap. By default, the first asset with an embedded base64 image.
185
- * @param {string} [options.jsonEntryName]
186
- * Path of the Lottie JSON inside the resulting archive. Defaults to the template's own path,
187
- * or "animation/animation.json" when the template is a single JSON.
188
- * @param {Record<string, Buffer | string>} [options.extraFiles]
189
- * Additional files to include in the archive (merged on top of folder-template files).
190
- * @returns {Promise<{ buffer: Buffer, output?: string, mime: 'application/was' }>}
5
+ * Tech notes captured during the v2 rewrite:
6
+ *
7
+ * - The animation JSON inside a `.was` is signed by Meta. A JWT ES256
8
+ * `trust_token` (kid=196) carries the SHA-256 of the JSON. Any byte change
9
+ * to the JSON invalidates the token the WhatsApp client silently drops
10
+ * the sticker. We never touch the animation; only `overridden_metadata`
11
+ * (pack name, publisher, emojis, group link) is fair game.
12
+ * - Baileys' `sock.sendMessage({ sticker, mimetype: 'application/was' })`
13
+ * emits `stickerMessage` (proto field 26). WhatsApp Web renders that;
14
+ * mobile WhatsApp does NOT. Mobile requires `lottieStickerMessage`
15
+ * (FutureProofMessage at field 74) wrapping a `stickerMessage`. The
16
+ * `sendLottieSticker` helper does that for you.
191
17
  */
192
- export async function buildLottieSticker({
193
- image,
194
- template,
195
- output,
196
- assetSelector,
197
- jsonEntryName,
198
- extraFiles,
199
- } = {}) {
200
- const [{ buffer: imageBuffer, mime }, resolved] = await Promise.all([
201
- resolveImage(image),
202
- resolveTemplate(template, jsonEntryName),
203
- ]);
204
-
205
- const asset = findImageAsset(resolved.lottie, assetSelector);
206
- asset.p = toDataUri(imageBuffer, mime);
207
- if ('u' in asset) asset.u = '';
208
-
209
- const zip = new JSZip();
210
- for (const [name, contents] of Object.entries(resolved.extraFiles)) {
211
- zip.file(name, contents);
212
- }
213
- zip.file(resolved.jsonEntryName, JSON.stringify(resolved.lottie));
214
- if (extraFiles) {
215
- for (const [name, contents] of Object.entries(extraFiles)) {
216
- zip.file(name, contents);
217
- }
218
- }
219
-
220
- const wasBuffer = await zip.generateAsync({
221
- type: 'nodebuffer',
222
- compression: 'DEFLATE',
223
- compressionOptions: { level: 6 },
224
- });
225
-
226
- if (output) {
227
- await mkdir(dirname(resolve(output)), { recursive: true });
228
- await writeFile(output, wasBuffer);
229
- }
230
-
231
- return { buffer: wasBuffer, output, mime: 'application/was' };
232
- }
233
18
 
234
- export { SUPPORTED_MIMES, detectMimeFromBuffer, detectMimeFromExtension };
235
- export { BUNDLED_TEMPLATE_NAMES };
19
+ export { extractFromWAS, inspectWAS } from './extract.js';
20
+ export { customizeMetadata } from './customize.js';
21
+ export { sendLottieSticker } from './send.js';
22
+ export { captureNextLottieSticker } from './capture.js';
package/src/send.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Send a `.was` Lottie sticker as a real `lottieStickerMessage` (FutureProofMessage
3
+ * at field 74). Required for mobile WhatsApp clients to render it — they silently
4
+ * drop Lottie payloads sent inside a plain `stickerMessage` (field 26), which is
5
+ * what Baileys' `sock.sendMessage({ sticker, mimetype })` emits.
6
+ */
7
+
8
+ async function loadBaileys() {
9
+ // Imported lazily so the lib doesn't hard-require Baileys for users who only
10
+ // do extract/customize. Baileys is declared as an optional peer dependency.
11
+ try {
12
+ return await import('@whiskeysockets/baileys');
13
+ } catch (err) {
14
+ throw new Error(
15
+ 'sendLottieSticker requires "@whiskeysockets/baileys" — install it as a peer dependency.',
16
+ );
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Relay a `.was` buffer as a Lottie sticker on the given Baileys socket.
22
+ *
23
+ * @param {object} sock — a connected Baileys socket
24
+ * @param {string} jid — recipient JID (e.g. `5511…@s.whatsapp.net` or `…@g.us`)
25
+ * @param {Buffer|Uint8Array} buffer — `.was` archive bytes
26
+ * @param {{
27
+ * width?: number,
28
+ * height?: number,
29
+ * accessibilityLabel?: string,
30
+ * messageId?: string,
31
+ * quoted?: object,
32
+ * }} [opts]
33
+ * @returns {Promise<{ messageId: string, fileLength: number }>}
34
+ */
35
+ export async function sendLottieSticker(sock, jid, buffer, opts = {}) {
36
+ if (!sock || typeof sock.relayMessage !== 'function') {
37
+ throw new Error('sendLottieSticker: first argument must be a Baileys socket.');
38
+ }
39
+ if (typeof jid !== 'string' || !jid.includes('@')) {
40
+ throw new Error('sendLottieSticker: jid must be a JID string.');
41
+ }
42
+
43
+ const baileys = await loadBaileys();
44
+ const { prepareWAMessageMedia, generateWAMessageFromContent, generateMessageIDV2 } = baileys;
45
+
46
+ // 1. Upload — gives us url/directPath/mediaKey/fileSha256/etc.
47
+ const prepared = await prepareWAMessageMedia(
48
+ { sticker: Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), mimetype: 'application/was' },
49
+ { upload: sock.waUploadToServer, mediaTypeOverride: 'sticker' },
50
+ );
51
+ const inner = prepared.stickerMessage;
52
+ if (!inner) throw new Error('prepareWAMessageMedia did not return a stickerMessage.');
53
+
54
+ inner.mimetype = 'application/was';
55
+ inner.isAnimated = true;
56
+ inner.isLottie = true;
57
+ if (opts.width) inner.width = opts.width;
58
+ if (opts.height) inner.height = opts.height;
59
+ if (opts.accessibilityLabel) inner.accessibilityLabel = opts.accessibilityLabel;
60
+
61
+ // 2. Wrap in lottieStickerMessage (FutureProofMessage → Message → stickerMessage).
62
+ const content = {
63
+ lottieStickerMessage: { message: { stickerMessage: inner } },
64
+ };
65
+
66
+ // 3. Build the WAMessage and relay it.
67
+ const messageId = opts.messageId ?? generateMessageIDV2();
68
+ const waMsg = generateWAMessageFromContent(jid, content, {
69
+ userJid: sock.user?.id,
70
+ messageId,
71
+ quoted: opts.quoted,
72
+ });
73
+ await sock.relayMessage(jid, waMsg.message, { messageId });
74
+
75
+ return { messageId, fileLength: Number(inner.fileLength ?? buffer.length ?? 0) };
76
+ }
package/src/mime.js DELETED
@@ -1,33 +0,0 @@
1
- import { extname } from 'node:path';
2
-
3
- const MIME_BY_EXT = Object.freeze({
4
- '.png': 'image/png',
5
- '.jpg': 'image/jpeg',
6
- '.jpeg': 'image/jpeg',
7
- '.webp': 'image/webp',
8
- });
9
-
10
- const MAGIC = [
11
- { mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
12
- { mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
13
- { mime: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46], suffixAt: 8, suffix: [0x57, 0x45, 0x42, 0x50] },
14
- ];
15
-
16
- export const SUPPORTED_MIMES = Object.freeze([...new Set(Object.values(MIME_BY_EXT))]);
17
-
18
- export function detectMimeFromExtension(filePath) {
19
- if (!filePath) return null;
20
- return MIME_BY_EXT[extname(String(filePath)).toLowerCase()] ?? null;
21
- }
22
-
23
- export function detectMimeFromBuffer(buffer) {
24
- if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
25
-
26
- for (const { mime, bytes, suffixAt, suffix } of MAGIC) {
27
- const headOk = bytes.every((b, i) => buffer[i] === b);
28
- if (!headOk) continue;
29
- if (suffix && !suffix.every((b, i) => buffer[suffixAt + i] === b)) continue;
30
- return mime;
31
- }
32
- return null;
33
- }
@@ -1 +0,0 @@
1
- {"v":"5.7.1","fr":30,"ip":0,"op":30,"w":512,"h":512,"nm":"pulse","ddd":0,"assets":[{"id":"image_0","w":512,"h":512,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAfbLI3wAAAABJRU5ErkJggg=="}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"image","refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[256,256,0]},"s":{"a":1,"k":[{"i":{"x":[0.5,0.5,0.5],"y":[1,1,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.5,0.5,0.5],"y":[1,1,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":15,"s":[115,115,100]},{"t":30,"s":[100,100,100]}]}},"ao":0,"ip":0,"op":30,"st":0,"bm":0}],"markers":[]}