whalibmob 5.10.7 → 5.10.9
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 +37 -7
- package/cli.js +1 -1
- package/lib/Client.js +54 -21
- package/lib/MediaThumbnail.js +163 -18
- package/lib/image/Bmp.js +94 -0
- package/lib/image/Gif.js +186 -0
- package/lib/image/Jpeg.js +406 -0
- package/lib/image/JpegEncoder.js +332 -0
- package/lib/image/Png.js +205 -0
- package/lib/image/index.js +256 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1791,9 +1791,11 @@ Photos and videos are sent with an inline preview so they show up in the chat
|
|
|
1791
1791
|
directly, rather than as a placeholder the recipient has to tap. The dimensions
|
|
1792
1792
|
are read out of the file header with no dependency at all. The preview itself
|
|
1793
1793
|
uses `jimp` (installed automatically as an optional dependency) or `sharp` if
|
|
1794
|
-
you have it; failing both, a photo that carries an EXIF thumbnail
|
|
1795
|
-
|
|
1796
|
-
|
|
1794
|
+
you have it; failing both, a photo that carries an EXIF thumbnail gets one for
|
|
1795
|
+
free, and anything else is scaled in process — JPEG, PNG, GIF and BMP all work
|
|
1796
|
+
with nothing installed. Video previews need `ffmpeg` on PATH — on Termux,
|
|
1797
|
+
`pkg install ffmpeg`. Without any of these the media still sends, just without
|
|
1798
|
+
the preview.
|
|
1797
1799
|
|
|
1798
1800
|
### Image Message
|
|
1799
1801
|
|
|
@@ -2168,14 +2170,37 @@ await client.changeGroupPicture('120363000000000000@g.us', null)
|
|
|
2168
2170
|
`changeGroupPicture` throws when the server refuses — `406` for an image it will
|
|
2169
2171
|
not take, `403` when you are not an admin of that group.
|
|
2170
2172
|
|
|
2173
|
+
> [!NOTE]
|
|
2174
|
+
> The two are addressed differently, and it matters. A group picture names the
|
|
2175
|
+
> group in the IQ's `target`; your own picture names nobody — the server takes
|
|
2176
|
+
> that from the session. Your own JID in `target` is not an error the server
|
|
2177
|
+
> reports, it is a stanza it drops without answering.
|
|
2178
|
+
|
|
2171
2179
|
**Both calls re-encode the image before sending it.** WhatsApp wants a square
|
|
2172
2180
|
640×640 JPEG, and a picture that is not one is *dropped without an answer*
|
|
2173
2181
|
rather than refused — so an unprepared file fails as a timeout that looks like a
|
|
2174
2182
|
network fault. The image is cropped square, scaled and written out as a fresh
|
|
2175
2183
|
JPEG, which also strips EXIF and any progressive encoding the file was carrying.
|
|
2176
2184
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2185
|
+
**No image library is required.** JPEG, PNG, GIF and BMP are decoded, cropped
|
|
2186
|
+
and re-encoded in process, so a bare `npm install` on a phone or a container
|
|
2187
|
+
built without a compiler converts a picture just as well as a full desktop.
|
|
2188
|
+
Whatever is installed is preferred, and the fallbacks run in this order:
|
|
2189
|
+
|
|
2190
|
+
| | Handles | Needs |
|
|
2191
|
+
|---|---|---|
|
|
2192
|
+
| `sharp` or `jimp` | everything, best quality | either installed |
|
|
2193
|
+
| built in | JPEG, PNG, GIF, BMP | nothing |
|
|
2194
|
+
| `ffmpeg` | WebP, HEIC, progressive JPEG | `ffmpeg` on PATH |
|
|
2195
|
+
| metadata strip | an already-square JPEG | nothing |
|
|
2196
|
+
|
|
2197
|
+
The built-in converter also reads the EXIF orientation tag, so a photo taken in
|
|
2198
|
+
portrait is turned upright instead of arriving on its side, and flattens
|
|
2199
|
+
transparency onto white, since a JPEG has no alpha channel.
|
|
2200
|
+
|
|
2201
|
+
Only an exotic format with nothing installed — a WebP or a HEIC on a machine
|
|
2202
|
+
with no `ffmpeg` — still fails, and the error says so rather than reporting a
|
|
2203
|
+
bare `406`.
|
|
2179
2204
|
|
|
2180
2205
|
```js
|
|
2181
2206
|
// skip the re-encode if you have prepared the image yourself
|
|
@@ -3127,11 +3152,16 @@ about updated
|
|
|
3127
3152
|
|
|
3128
3153
|
#### CLI Change Profile Picture
|
|
3129
3154
|
|
|
3130
|
-
Reads the image from disk
|
|
3155
|
+
Reads the image from disk, crops it square, scales it to 640×640 and uploads it
|
|
3156
|
+
as your profile picture. JPEG, PNG, GIF and BMP need nothing installed; WebP and
|
|
3157
|
+
HEIC need `ffmpeg` or `jimp`.
|
|
3131
3158
|
|
|
3132
3159
|
```sh
|
|
3133
3160
|
wa> /photo ./avatar.jpg
|
|
3134
|
-
profile picture updated
|
|
3161
|
+
profile picture updated id=1753912045
|
|
3162
|
+
|
|
3163
|
+
wa> /photo remove
|
|
3164
|
+
profile picture removed
|
|
3135
3165
|
```
|
|
3136
3166
|
|
|
3137
3167
|
#### CLI Change Privacy Settings
|
package/cli.js
CHANGED
|
@@ -447,7 +447,7 @@ const HELP = `
|
|
|
447
447
|
Profile
|
|
448
448
|
/name <text> change display name
|
|
449
449
|
/about <text> change own bio / about text
|
|
450
|
-
/photo <file>|remove change or remove own profile picture (
|
|
450
|
+
/photo <file>|remove change or remove own profile picture (any image)
|
|
451
451
|
/privacy [<type> <value>] show or change privacy settings
|
|
452
452
|
types: last_seen profile_picture status
|
|
453
453
|
online read_receipts groups_add
|
package/lib/Client.js
CHANGED
|
@@ -10,7 +10,7 @@ const { NoiseSocket } = require('./noise');
|
|
|
10
10
|
const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
|
|
11
11
|
const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
|
|
12
12
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
13
|
-
const { prepareProfilePicture } = require('./MediaThumbnail');
|
|
13
|
+
const { prepareProfilePicture, probeImageSize, canDecodeImage } = require('./MediaThumbnail');
|
|
14
14
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
|
|
15
15
|
const { BinaryNode } = require('./BinaryNode');
|
|
16
16
|
const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
|
|
@@ -252,6 +252,38 @@ function describeNodeBriefly(node, depth) {
|
|
|
252
252
|
return out + '/>';
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// Say why a picture was turned down, from what can be read off the file.
|
|
256
|
+
//
|
|
257
|
+
// "not acceptable" is all the server offers, so the useful part is what we can
|
|
258
|
+
// work out ourselves: what the image is, whether it is square, and whether
|
|
259
|
+
// anything was available to fix it before it went out.
|
|
260
|
+
function _pictureRejected(what, picture, original, opts) {
|
|
261
|
+
const size = picture ? probeImageSize(picture) : null;
|
|
262
|
+
const bits = ['the server would not take this image (406 not-acceptable)'];
|
|
263
|
+
|
|
264
|
+
if (size && size.width && size.height) {
|
|
265
|
+
bits.push('it went out as ' + size.width + 'x' + size.height +
|
|
266
|
+
(size.width === size.height ? '' : ', which is not square'));
|
|
267
|
+
}
|
|
268
|
+
const isJpeg = picture && picture[0] === 0xff && picture[1] === 0xd8;
|
|
269
|
+
if (picture && !isJpeg) bits.push('and it is not a JPEG');
|
|
270
|
+
|
|
271
|
+
if (opts && opts.raw) {
|
|
272
|
+
bits.push('this call passed { raw: true }, so the file was sent exactly as given');
|
|
273
|
+
} else if (!isJpeg && original && !canDecodeImage(original)) {
|
|
274
|
+
// WebP, HEIC and the like need an outside converter; everything ordinary
|
|
275
|
+
// is handled without one.
|
|
276
|
+
bits.push('this format could not be converted here — install ffmpeg, or ' +
|
|
277
|
+
'save the picture as a JPEG or PNG first');
|
|
278
|
+
} else {
|
|
279
|
+
bits.push('the picture was cropped square and re-encoded before sending, ' +
|
|
280
|
+
'so the file itself is unlikely to be the problem — the account may not ' +
|
|
281
|
+
'be allowed to set one');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return what + ': ' + bits.join('. ') + '.';
|
|
285
|
+
}
|
|
286
|
+
|
|
255
287
|
function getNodeContent(node) {
|
|
256
288
|
if (!node) return null;
|
|
257
289
|
if (Buffer.isBuffer(node.content)) return node.content;
|
|
@@ -4339,26 +4371,24 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4339
4371
|
// naturally tries and what the server silently drops — pass { raw: true } to
|
|
4340
4372
|
// skip that if you have already prepared it yourself.
|
|
4341
4373
|
//
|
|
4342
|
-
//
|
|
4343
|
-
//
|
|
4344
|
-
//
|
|
4345
|
-
//
|
|
4346
|
-
//
|
|
4374
|
+
// `target` names *whose* picture is being set, and is left off entirely when
|
|
4375
|
+
// it is your own — the server infers that from the session. A group picture
|
|
4376
|
+
// carries a target and works; the same IQ with your own JID in target does
|
|
4377
|
+
// not, and the server's answer to that is silence rather than a refusal.
|
|
4378
|
+
//
|
|
4379
|
+
// This has now been wrong in both directions: first addressed *to* the
|
|
4380
|
+
// account, then addressed about it. It is neither — it is addressed to the
|
|
4381
|
+
// server with nothing named at all.
|
|
4347
4382
|
async changeProfilePicture(buf, opts) {
|
|
4348
4383
|
if (!this._store) throw new Error('Not connected');
|
|
4349
4384
|
opts = opts || {};
|
|
4350
|
-
const selfJid = this._ownDeviceJid
|
|
4351
|
-
? toNonAdJid(this._ownDeviceJid())
|
|
4352
|
-
: `${this._store.phoneNumber}@s.whatsapp.net`;
|
|
4353
|
-
|
|
4354
4385
|
const picture = (buf && !opts.raw) ? await prepareProfilePicture(buf, opts) : buf;
|
|
4355
4386
|
|
|
4356
4387
|
const node = new BinaryNode('iq', {
|
|
4357
|
-
id:
|
|
4358
|
-
xmlns:
|
|
4359
|
-
to:
|
|
4360
|
-
|
|
4361
|
-
type: 'set'
|
|
4388
|
+
id: this._genMsgId(),
|
|
4389
|
+
xmlns: 'w:profile:picture',
|
|
4390
|
+
to: 's.whatsapp.net',
|
|
4391
|
+
type: 'set'
|
|
4362
4392
|
}, picture ? [new BinaryNode('picture', { type: 'image' }, picture)] : null);
|
|
4363
4393
|
|
|
4364
4394
|
const resp = await this._sendIq(node);
|
|
@@ -4367,15 +4397,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4367
4397
|
'The server drops a picture it will not take rather than refusing it, so ' +
|
|
4368
4398
|
'this usually means the image was not in the form it wants' +
|
|
4369
4399
|
(opts.raw ? ' — this call passed { raw: true }, so nothing was re-encoded'
|
|
4370
|
-
: '
|
|
4371
|
-
're-encoded before sending') + '.');
|
|
4400
|
+
: '') + '.');
|
|
4372
4401
|
}
|
|
4373
4402
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
4374
4403
|
const errNode = findChild(resp, 'error');
|
|
4375
4404
|
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
4405
|
+
if (String(code) === '406') {
|
|
4406
|
+
throw new Error(_pictureRejected('changeProfilePicture', picture, buf, opts));
|
|
4407
|
+
}
|
|
4376
4408
|
throw new Error('changeProfilePicture: ' +
|
|
4377
|
-
(String(code) === '
|
|
4378
|
-
: String(code) === '401' ? 'not authorised to change this picture'
|
|
4409
|
+
(String(code) === '401' ? 'not authorised to change this picture'
|
|
4379
4410
|
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
4380
4411
|
}
|
|
4381
4412
|
|
|
@@ -5962,9 +5993,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5962
5993
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5963
5994
|
const errNode = findChild(resp, 'error');
|
|
5964
5995
|
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5996
|
+
if (String(code) === '406') {
|
|
5997
|
+
throw new Error(_pictureRejected('changeGroupPicture', picture, buf, opts));
|
|
5998
|
+
}
|
|
5965
5999
|
throw new Error('changeGroupPicture: ' +
|
|
5966
|
-
(String(code) === '
|
|
5967
|
-
: String(code) === '403' ? 'you are not allowed to change this group picture'
|
|
6000
|
+
(String(code) === '403' ? 'you are not allowed to change this group picture'
|
|
5968
6001
|
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
5969
6002
|
}
|
|
5970
6003
|
if (!buf) return 'remove';
|
package/lib/MediaThumbnail.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// GIF and BMP — with no library at all.
|
|
16
16
|
// • The thumbnail is made with sharp or jimp when either is installed. When
|
|
17
17
|
// neither is, a JPEG that carries an EXIF thumbnail (which is to say, most
|
|
18
|
-
// photos taken on a phone)
|
|
18
|
+
// photos taken on a phone) yields one for free, and anything else goes
|
|
19
|
+
// through the decoder in lib/image — still with nothing installed.
|
|
19
20
|
// • Video frames need ffmpeg. Without it the video still sends, just without
|
|
20
21
|
// an inline preview.
|
|
21
22
|
//
|
|
@@ -30,6 +31,8 @@ const path = require('path');
|
|
|
30
31
|
const crypto = require('crypto');
|
|
31
32
|
const { execFile } = require('child_process');
|
|
32
33
|
|
|
34
|
+
const { toSquareJpeg, toScaledJpeg, canDecode: canDecodeImage } = require('./image');
|
|
35
|
+
|
|
33
36
|
// The preview is deliberately tiny; it is only ever shown blurred behind the
|
|
34
37
|
// real image while that downloads.
|
|
35
38
|
const THUMB_WIDTH = 32;
|
|
@@ -220,8 +223,8 @@ async function loadImageLib() {
|
|
|
220
223
|
}
|
|
221
224
|
} catch (_) {}
|
|
222
225
|
|
|
223
|
-
_whaDbg('[DBG] THUMB no image library —
|
|
224
|
-
'
|
|
226
|
+
_whaDbg('[DBG] THUMB no image library — falling back to the built-in ' +
|
|
227
|
+
'decoder, which reads JPEG, PNG, GIF and BMP');
|
|
225
228
|
return _imageLib;
|
|
226
229
|
}
|
|
227
230
|
|
|
@@ -277,16 +280,32 @@ async function imageThumbnail(buf, opts) {
|
|
|
277
280
|
_whaDbg('[DBG] THUMB image library failed: ' + (err && err.message));
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
// No library, or it could not read this file
|
|
281
|
-
//
|
|
283
|
+
// No library, or it could not read this file. The camera's own embedded
|
|
284
|
+
// preview is the cheapest thing left, and on a phone photo it is usually
|
|
285
|
+
// there — a few kilobytes already sized about right.
|
|
282
286
|
const exif = extractExifThumbnail(buf);
|
|
283
287
|
if (exif) {
|
|
284
288
|
out.thumbnail = exif;
|
|
285
289
|
_whaDbg('[DBG] THUMB using embedded EXIF thumbnail (' + exif.length + ' bytes)');
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Otherwise scale it here. Slower than a library, but a preview is 32 pixels
|
|
294
|
+
// wide and this is the difference between the picture showing in the chat and
|
|
295
|
+
// a grey download placeholder.
|
|
296
|
+
try {
|
|
297
|
+
const built = toScaledJpeg(buf, width, quality);
|
|
298
|
+
if (built && built.length) {
|
|
299
|
+
out.thumbnail = built;
|
|
300
|
+
_whaDbg('[DBG] THUMB made in-process (' + built.length + ' bytes)');
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
_whaDbg('[DBG] THUMB built-in scaler failed: ' + (err && err.message));
|
|
289
305
|
}
|
|
306
|
+
|
|
307
|
+
_whaDbg('[DBG] THUMB none available — the image will show as a download ' +
|
|
308
|
+
'placeholder; install jimp to fix');
|
|
290
309
|
return out;
|
|
291
310
|
}
|
|
292
311
|
|
|
@@ -301,13 +320,22 @@ const AVATAR_QUALITY = 50;
|
|
|
301
320
|
/**
|
|
302
321
|
* Re-encode an image into the form a profile picture has to be in.
|
|
303
322
|
*
|
|
304
|
-
* Cropped square
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
323
|
+
* Cropped square, scaled to 640x640, and written out as a fresh JPEG.
|
|
324
|
+
* Re-encoding is the point rather than a side effect: it strips EXIF,
|
|
325
|
+
* progressive scans and unusual chroma subsampling — anything the file happened
|
|
326
|
+
* to carry — and produces a plain baseline JPEG.
|
|
308
327
|
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
328
|
+
* Four converters are tried in turn, so a picture goes through whatever the
|
|
329
|
+
* machine happens to have:
|
|
330
|
+
*
|
|
331
|
+
* 1. sharp or jimp, when one is installed — the best quality and the widest
|
|
332
|
+
* range of formats.
|
|
333
|
+
* 2. the built-in converter, which reads JPEG, PNG, GIF and BMP with no
|
|
334
|
+
* dependency at all. This is the one that runs on a bare install.
|
|
335
|
+
* 3. ffmpeg, which covers what the built-in one does not: WebP, HEIC,
|
|
336
|
+
* progressive JPEG.
|
|
337
|
+
* 4. failing all of that, the file with its metadata segments stripped, which
|
|
338
|
+
* at least gives an already-square JPEG a chance.
|
|
311
339
|
*/
|
|
312
340
|
async function prepareProfilePicture(buf, opts) {
|
|
313
341
|
opts = opts || {};
|
|
@@ -345,15 +373,130 @@ async function prepareProfilePicture(buf, opts) {
|
|
|
345
373
|
return await img.getBufferAsync(Jimp.MIME_JPEG);
|
|
346
374
|
}
|
|
347
375
|
} catch (err) {
|
|
348
|
-
_whaDbg('[DBG] AVATAR
|
|
349
|
-
|
|
376
|
+
_whaDbg('[DBG] AVATAR image library failed: ' + (err && err.message));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// No image library, or it could not read this file. The built-in converter
|
|
380
|
+
// needs nothing installed and handles the formats a picture is usually in.
|
|
381
|
+
try {
|
|
382
|
+
const built = toSquareJpeg(buf, size, quality);
|
|
383
|
+
if (built && built.length) {
|
|
384
|
+
_whaDbg('[DBG] AVATAR converted in-process to ' + size + 'x' + size +
|
|
385
|
+
' (' + buf.length + ' → ' + built.length + ' bytes)');
|
|
386
|
+
return built;
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
_whaDbg('[DBG] AVATAR built-in converter failed: ' + (err && err.message));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A format the built-in decoder does not read — WebP, HEIC, a progressive
|
|
393
|
+
// JPEG. ffmpeg can do the same job and is already used by the video path, so
|
|
394
|
+
// try it before giving up.
|
|
395
|
+
const viaFfmpeg = await avatarViaFfmpeg(buf, size, quality);
|
|
396
|
+
if (viaFfmpeg) return viaFfmpeg;
|
|
397
|
+
|
|
398
|
+
// Nothing that can re-encode. Strip the metadata segments at least: EXIF and
|
|
399
|
+
// the other APPn blocks are the part of an ordinary camera JPEG the server
|
|
400
|
+
// most often will not take, and removing them needs no decoder — the image
|
|
401
|
+
// data is left exactly as it was.
|
|
402
|
+
const stripped = stripJpegMetadata(buf);
|
|
403
|
+
if (stripped !== buf) {
|
|
404
|
+
_whaDbg('[DBG] AVATAR no converter — sent with metadata stripped (' +
|
|
405
|
+
buf.length + ' → ' + stripped.length + ' bytes). Install jimp or ffmpeg ' +
|
|
406
|
+
'to have the image resized properly.');
|
|
407
|
+
return stripped;
|
|
350
408
|
}
|
|
351
409
|
|
|
352
|
-
_whaDbg('[DBG] AVATAR
|
|
353
|
-
'WhatsApp wants a square JPEG;
|
|
410
|
+
_whaDbg('[DBG] AVATAR nothing could read this file — sending it as it is. ' +
|
|
411
|
+
'WhatsApp wants a square JPEG; JPEG, PNG, GIF and BMP are converted here ' +
|
|
412
|
+
'on their own, and ffmpeg or jimp covers the rest.');
|
|
354
413
|
return buf;
|
|
355
414
|
}
|
|
356
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Crop square and resize with ffmpeg, which needs no native npm module.
|
|
418
|
+
*
|
|
419
|
+
* Returns null when ffmpeg is not installed or cannot read the file, so the
|
|
420
|
+
* caller can carry on to the next fallback.
|
|
421
|
+
*/
|
|
422
|
+
async function avatarViaFfmpeg(buf, size, quality) {
|
|
423
|
+
if (!(await hasFfmpeg())) return null;
|
|
424
|
+
|
|
425
|
+
const src = tmpFile('.img');
|
|
426
|
+
const dst = tmpFile('.jpg');
|
|
427
|
+
try {
|
|
428
|
+
fs.writeFileSync(src, buf);
|
|
429
|
+
// Crop to the shorter side, scale, and write baseline JPEG. -q:v runs 2..31
|
|
430
|
+
// worst-to-best in ffmpeg, the opposite way round to a percentage quality.
|
|
431
|
+
const q = Math.max(2, Math.min(31, Math.round(31 - (quality / 100) * 29)));
|
|
432
|
+
const ok = await run('ffmpeg', [
|
|
433
|
+
'-y', '-i', src,
|
|
434
|
+
'-vf', "crop='min(iw,ih)':'min(iw,ih)',scale=" + size + ':' + size,
|
|
435
|
+
'-q:v', String(q),
|
|
436
|
+
'-f', 'mjpeg',
|
|
437
|
+
dst
|
|
438
|
+
]);
|
|
439
|
+
if (ok === null) return null;
|
|
440
|
+
const out = fs.readFileSync(dst);
|
|
441
|
+
if (!out.length || out[0] !== 0xff || out[1] !== 0xd8) return null;
|
|
442
|
+
_whaDbg('[DBG] AVATAR re-encoded with ffmpeg (' + out.length + ' bytes)');
|
|
443
|
+
return out;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
_whaDbg('[DBG] AVATAR ffmpeg failed: ' + (err && err.message));
|
|
446
|
+
return null;
|
|
447
|
+
} finally {
|
|
448
|
+
try { fs.unlinkSync(src); } catch (_) {}
|
|
449
|
+
try { fs.unlinkSync(dst); } catch (_) {}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Drop every APPn segment from a JPEG, keeping the image itself byte for byte.
|
|
455
|
+
*
|
|
456
|
+
* EXIF, ICC profiles, thumbnails and camera maker notes all live in these, and
|
|
457
|
+
* a picture that carries them is the ordinary case a phone produces. Removing
|
|
458
|
+
* them takes no decoder — it is a walk over the segment headers — so it works
|
|
459
|
+
* where nothing else is installed.
|
|
460
|
+
*
|
|
461
|
+
* Returns the input unchanged when it is not a JPEG or has nothing to drop.
|
|
462
|
+
*/
|
|
463
|
+
function stripJpegMetadata(buf) {
|
|
464
|
+
if (!Buffer.isBuffer(buf) || buf.length < 4) return buf;
|
|
465
|
+
if (buf[0] !== 0xff || buf[1] !== 0xd8) return buf; // not a JPEG
|
|
466
|
+
|
|
467
|
+
const keep = [buf.slice(0, 2)];
|
|
468
|
+
let i = 2, dropped = 0;
|
|
469
|
+
|
|
470
|
+
while (i + 4 <= buf.length) {
|
|
471
|
+
if (buf[i] !== 0xff) break; // out of step; leave the rest be
|
|
472
|
+
const marker = buf[i + 1];
|
|
473
|
+
|
|
474
|
+
// Start of scan — everything from here is entropy-coded image data.
|
|
475
|
+
if (marker === 0xda) { keep.push(buf.slice(i)); i = buf.length; break; }
|
|
476
|
+
// Standalone markers carry no length.
|
|
477
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
478
|
+
keep.push(buf.slice(i, i + 2)); i += 2; continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const len = buf.readUInt16BE(i + 2);
|
|
482
|
+
if (len < 2 || i + 2 + len > buf.length) break; // malformed; stop here
|
|
483
|
+
const seg = buf.slice(i, i + 2 + len);
|
|
484
|
+
|
|
485
|
+
// APP0 is the JFIF header, which is the one to keep; APP1..APPF and the
|
|
486
|
+
// comment segment are metadata.
|
|
487
|
+
const isAppN = marker >= 0xe1 && marker <= 0xef;
|
|
488
|
+
const isComment = marker === 0xfe;
|
|
489
|
+
if (isAppN || isComment) dropped += seg.length;
|
|
490
|
+
else keep.push(seg);
|
|
491
|
+
|
|
492
|
+
i += 2 + len;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!dropped) return buf;
|
|
496
|
+
if (i < buf.length) keep.push(buf.slice(i));
|
|
497
|
+
return Buffer.concat(keep);
|
|
498
|
+
}
|
|
499
|
+
|
|
357
500
|
// ─── ffmpeg ──────────────────────────────────────────────────────────────────
|
|
358
501
|
|
|
359
502
|
function run(cmd, args) {
|
|
@@ -450,6 +593,8 @@ module.exports = {
|
|
|
450
593
|
extractExifThumbnail,
|
|
451
594
|
imageThumbnail,
|
|
452
595
|
prepareProfilePicture,
|
|
596
|
+
stripJpegMetadata,
|
|
597
|
+
canDecodeImage,
|
|
453
598
|
videoThumbnail,
|
|
454
599
|
hasFfmpeg,
|
|
455
600
|
THUMB_WIDTH,
|
package/lib/image/Bmp.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// BMP → RGBA.
|
|
4
|
+
//
|
|
5
|
+
// Uncompressed only, which is all but every BMP in the wild: 1/4/8-bit through
|
|
6
|
+
// the palette, and 16/24/32-bit direct. Rows are padded to four bytes and are
|
|
7
|
+
// stored bottom-up unless the height is negative.
|
|
8
|
+
|
|
9
|
+
function isBmp(buf) {
|
|
10
|
+
return Buffer.isBuffer(buf) && buf.length > 26 && buf[0] === 0x42 && buf[1] === 0x4d;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function decodeBmp(buf) {
|
|
14
|
+
if (!isBmp(buf)) return null;
|
|
15
|
+
|
|
16
|
+
const dataOffset = buf.readUInt32LE(10);
|
|
17
|
+
const headerSize = buf.readUInt32LE(14);
|
|
18
|
+
if (headerSize < 12) return null;
|
|
19
|
+
|
|
20
|
+
let width, height, bpp, compression = 0, paletteCount = 0;
|
|
21
|
+
if (headerSize === 12) { // BITMAPCOREHEADER
|
|
22
|
+
width = buf.readInt16LE(18);
|
|
23
|
+
height = buf.readInt16LE(20);
|
|
24
|
+
bpp = buf.readUInt16LE(24);
|
|
25
|
+
} else {
|
|
26
|
+
width = buf.readInt32LE(18);
|
|
27
|
+
height = buf.readInt32LE(22);
|
|
28
|
+
bpp = buf.readUInt16LE(28);
|
|
29
|
+
compression = buf.readUInt32LE(30);
|
|
30
|
+
paletteCount = buf.readUInt32LE(46);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const bottomUp = height > 0;
|
|
34
|
+
height = Math.abs(height);
|
|
35
|
+
if (!width || !height || width < 0) return null;
|
|
36
|
+
if (compression !== 0 && compression !== 3) return null; // RLE, JPEG, PNG
|
|
37
|
+
if (![1, 4, 8, 16, 24, 32].includes(bpp)) return null;
|
|
38
|
+
|
|
39
|
+
// The palette sits between the header and the pixel data.
|
|
40
|
+
const paletteStart = 14 + headerSize;
|
|
41
|
+
const entrySize = headerSize === 12 ? 3 : 4;
|
|
42
|
+
if (!paletteCount && bpp <= 8) paletteCount = 1 << bpp;
|
|
43
|
+
|
|
44
|
+
const rowBytes = Math.floor((width * bpp + 31) / 32) * 4;
|
|
45
|
+
if (dataOffset + rowBytes * height > buf.length) return null;
|
|
46
|
+
|
|
47
|
+
const out = Buffer.alloc(width * height * 4, 0xff);
|
|
48
|
+
|
|
49
|
+
for (let y = 0; y < height; y++) {
|
|
50
|
+
const srcRow = dataOffset + (bottomUp ? (height - 1 - y) : y) * rowBytes;
|
|
51
|
+
for (let x = 0; x < width; x++) {
|
|
52
|
+
let r, g, b, a = 255;
|
|
53
|
+
|
|
54
|
+
if (bpp <= 8) {
|
|
55
|
+
const perByte = 8 / bpp;
|
|
56
|
+
const byte = buf[srcRow + Math.floor(x / perByte)];
|
|
57
|
+
const shift = 8 - bpp * ((x % perByte) + 1);
|
|
58
|
+
const idx = (byte >> shift) & ((1 << bpp) - 1);
|
|
59
|
+
const p = paletteStart + idx * entrySize;
|
|
60
|
+
if (idx >= paletteCount || p + 2 >= buf.length) { r = g = b = 0; }
|
|
61
|
+
else { b = buf[p]; g = buf[p + 1]; r = buf[p + 2]; }
|
|
62
|
+
} else if (bpp === 16) {
|
|
63
|
+
// 5-5-5 with the top bit unused, widened back out to eight bits each.
|
|
64
|
+
const v = buf.readUInt16LE(srcRow + x * 2);
|
|
65
|
+
r = ((v >> 10) & 31) * 255 / 31;
|
|
66
|
+
g = ((v >> 5) & 31) * 255 / 31;
|
|
67
|
+
b = (v & 31) * 255 / 31;
|
|
68
|
+
r = Math.round(r); g = Math.round(g); b = Math.round(b);
|
|
69
|
+
} else {
|
|
70
|
+
const p = srcRow + x * (bpp / 8);
|
|
71
|
+
b = buf[p]; g = buf[p + 1]; r = buf[p + 2];
|
|
72
|
+
if (bpp === 32) {
|
|
73
|
+
const alpha = buf[p + 3];
|
|
74
|
+
// A 32-bit BMP often leaves the fourth byte at zero rather than
|
|
75
|
+
// meaning fully transparent; treat an all-zero channel as opaque.
|
|
76
|
+
a = alpha;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const o = (y * width + x) * 4;
|
|
81
|
+
out[o] = r; out[o + 1] = g; out[o + 2] = b; out[o + 3] = a;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (bpp === 32) {
|
|
86
|
+
let anyAlpha = false;
|
|
87
|
+
for (let i = 3; i < out.length; i += 4) if (out[i] !== 0) { anyAlpha = true; break; }
|
|
88
|
+
if (!anyAlpha) for (let i = 3; i < out.length; i += 4) out[i] = 255;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { width, height, data: out };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { isBmp, decodeBmp };
|