was-sticker 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thxmxx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # was-sticker
2
+
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
+ [![npm](https://img.shields.io/npm/v/was-sticker.svg)](https://www.npmjs.com/package/was-sticker)
5
+
6
+ Build WhatsApp animated stickers (`.was`) from an image plus a Lottie template — pure JS, no shell `zip` binary required.
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`.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install was-sticker
20
+ ```
21
+
22
+ Requires Node.js ≥ 18.
23
+
24
+ ## Quick start
25
+
26
+ ```js
27
+ import { buildLottieSticker, bundledTemplate } from 'was-sticker';
28
+
29
+ const { buffer } = await buildLottieSticker({
30
+ image: 'face.png',
31
+ template: bundledTemplate('pulse'),
32
+ });
33
+ // → buffer is a ready-to-send `.was` sticker
34
+ ```
35
+
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.
37
+
38
+ ## API
39
+
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
+ ```
52
+
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). |
61
+
62
+ The return value:
63
+
64
+ ```ts
65
+ { buffer: Buffer, output?: string, mime: 'application/was' }
66
+ ```
67
+
68
+ ## CLI
69
+
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
73
+ ```
74
+
75
+ ## Baileys integration
76
+
77
+ ```js
78
+ import { buildLottieSticker } from 'was-sticker';
79
+
80
+ const { buffer } = await buildLottieSticker({
81
+ image: incomingPhotoBuffer, // a Buffer you already have in memory
82
+ template: './templates/heart',
83
+ });
84
+
85
+ await sock.sendMessage(jid, {
86
+ sticker: buffer,
87
+ mimetype: 'application/was',
88
+ });
89
+ ```
90
+
91
+ ## Bring your own template
92
+
93
+ A `.was` is just a ZIP archive containing a Lottie JSON (and any sibling assets the animation references). To use a custom template:
94
+
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`.
98
+
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.
100
+
101
+ ## Roadmap / suggested improvements
102
+
103
+ These are deliberately *not* in v0.1 to keep the surface area small, but each one would be a natural follow-up:
104
+
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.
111
+
112
+ ## License
113
+
114
+ MIT.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "was-sticker",
3
+ "version": "0.1.0",
4
+ "description": "Build WhatsApp animated stickers (.was) from images and Lottie templates. Pure JS, no shell dependencies.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "was-sticker": "./src/cli.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "templates",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "example": "node examples/build.js",
24
+ "test": "node --test test/smoke.test.js"
25
+ },
26
+ "keywords": ["whatsapp", "sticker", "lottie", "was", "animated-sticker", "baileys"],
27
+ "author": "thxmxx",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/thxmxx/was-sticker.git"
32
+ },
33
+ "homepage": "https://github.com/thxmxx/was-sticker#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/thxmxx/was-sticker/issues"
36
+ },
37
+ "dependencies": {
38
+ "jszip": "^3.10.1"
39
+ }
40
+ }
package/src/cli.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { resolve } from 'node:path';
4
+
5
+ import { buildLottieSticker } from './index.js';
6
+
7
+ const USAGE = `was-sticker — build WhatsApp animated stickers from a Lottie template.
8
+
9
+ 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
24
+ `;
25
+
26
+ function fail(msg, code = 1) {
27
+ process.stderr.write(`${msg}\n`);
28
+ process.exit(code);
29
+ }
30
+
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) {
44
+ process.stdout.write(USAGE);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (!values.image || !values.template) {
49
+ process.stderr.write(USAGE);
50
+ fail('\nMissing required --image or --template.');
51
+ }
52
+
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'],
69
+ });
70
+ process.stdout.write(`${written} (${buffer.length.toLocaleString()} bytes)\n`);
71
+ } catch (err) {
72
+ fail(`Error: ${err.message}`);
73
+ }
package/src/index.js ADDED
@@ -0,0 +1,235 @@
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
+ /**
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.
175
+ *
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' }>}
191
+ */
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
+
234
+ export { SUPPORTED_MIMES, detectMimeFromBuffer, detectMimeFromExtension };
235
+ export { BUNDLED_TEMPLATE_NAMES };
package/src/mime.js ADDED
@@ -0,0 +1,33 @@
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
+ }
@@ -0,0 +1 @@
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":[]}