whalibmob 5.10.8 → 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 +31 -7
- package/cli.js +1 -1
- package/lib/Client.js +42 -7
- 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
|
|
|
@@ -2180,8 +2182,25 @@ rather than refused — so an unprepared file fails as a timeout that looks like
|
|
|
2180
2182
|
network fault. The image is cropped square, scaled and written out as a fresh
|
|
2181
2183
|
JPEG, which also strips EXIF and any progressive encoding the file was carrying.
|
|
2182
2184
|
|
|
2183
|
-
|
|
2184
|
-
|
|
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`.
|
|
2185
2204
|
|
|
2186
2205
|
```js
|
|
2187
2206
|
// skip the re-encode if you have prepared the image yourself
|
|
@@ -3133,11 +3152,16 @@ about updated
|
|
|
3133
3152
|
|
|
3134
3153
|
#### CLI Change Profile Picture
|
|
3135
3154
|
|
|
3136
|
-
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`.
|
|
3137
3158
|
|
|
3138
3159
|
```sh
|
|
3139
3160
|
wa> /photo ./avatar.jpg
|
|
3140
|
-
profile picture updated
|
|
3161
|
+
profile picture updated id=1753912045
|
|
3162
|
+
|
|
3163
|
+
wa> /photo remove
|
|
3164
|
+
profile picture removed
|
|
3141
3165
|
```
|
|
3142
3166
|
|
|
3143
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;
|
|
@@ -4365,15 +4397,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4365
4397
|
'The server drops a picture it will not take rather than refusing it, so ' +
|
|
4366
4398
|
'this usually means the image was not in the form it wants' +
|
|
4367
4399
|
(opts.raw ? ' — this call passed { raw: true }, so nothing was re-encoded'
|
|
4368
|
-
: '
|
|
4369
|
-
're-encoded before sending') + '.');
|
|
4400
|
+
: '') + '.');
|
|
4370
4401
|
}
|
|
4371
4402
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
4372
4403
|
const errNode = findChild(resp, 'error');
|
|
4373
4404
|
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
4405
|
+
if (String(code) === '406') {
|
|
4406
|
+
throw new Error(_pictureRejected('changeProfilePicture', picture, buf, opts));
|
|
4407
|
+
}
|
|
4374
4408
|
throw new Error('changeProfilePicture: ' +
|
|
4375
|
-
(String(code) === '
|
|
4376
|
-
: String(code) === '401' ? 'not authorised to change this picture'
|
|
4409
|
+
(String(code) === '401' ? 'not authorised to change this picture'
|
|
4377
4410
|
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
4378
4411
|
}
|
|
4379
4412
|
|
|
@@ -5960,9 +5993,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5960
5993
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5961
5994
|
const errNode = findChild(resp, 'error');
|
|
5962
5995
|
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5996
|
+
if (String(code) === '406') {
|
|
5997
|
+
throw new Error(_pictureRejected('changeGroupPicture', picture, buf, opts));
|
|
5998
|
+
}
|
|
5963
5999
|
throw new Error('changeGroupPicture: ' +
|
|
5964
|
-
(String(code) === '
|
|
5965
|
-
: 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'
|
|
5966
6001
|
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
5967
6002
|
}
|
|
5968
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 };
|
package/lib/image/Gif.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// GIF → RGBA, first frame only.
|
|
4
|
+
//
|
|
5
|
+
// A profile picture is a still, so an animation is read down to the image it
|
|
6
|
+
// opens on. The pixels are LZW-coded with a code width that grows as the
|
|
7
|
+
// dictionary fills — about forty lines of it, and the rest is walking the block
|
|
8
|
+
// structure to find where the frame starts.
|
|
9
|
+
|
|
10
|
+
function isGif(buf) {
|
|
11
|
+
return Buffer.isBuffer(buf) && buf.length > 13 &&
|
|
12
|
+
buf.slice(0, 3).toString('latin1') === 'GIF';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function decodeGif(buf) {
|
|
16
|
+
if (!isGif(buf)) return null;
|
|
17
|
+
|
|
18
|
+
const screenWidth = buf.readUInt16LE(6);
|
|
19
|
+
const screenHeight = buf.readUInt16LE(8);
|
|
20
|
+
const packed = buf[10];
|
|
21
|
+
const hasGlobal = !!(packed & 0x80);
|
|
22
|
+
const globalSize = 2 << (packed & 7);
|
|
23
|
+
|
|
24
|
+
let pos = 13;
|
|
25
|
+
let globalPalette = null;
|
|
26
|
+
if (hasGlobal) {
|
|
27
|
+
globalPalette = buf.slice(pos, pos + globalSize * 3);
|
|
28
|
+
pos += globalSize * 3;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let transparentIndex = -1;
|
|
32
|
+
|
|
33
|
+
while (pos < buf.length) {
|
|
34
|
+
const block = buf[pos];
|
|
35
|
+
|
|
36
|
+
if (block === 0x21) { // extension
|
|
37
|
+
const label = buf[pos + 1];
|
|
38
|
+
pos += 2;
|
|
39
|
+
if (label === 0xf9 && buf[pos] >= 4) { // graphic control
|
|
40
|
+
if (buf[pos + 1] & 1) transparentIndex = buf[pos + 4];
|
|
41
|
+
}
|
|
42
|
+
pos = skipBlocks(buf, pos);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (block === 0x2c) { // image descriptor
|
|
47
|
+
const left = buf.readUInt16LE(pos + 1);
|
|
48
|
+
const top = buf.readUInt16LE(pos + 3);
|
|
49
|
+
const width = buf.readUInt16LE(pos + 5);
|
|
50
|
+
const height = buf.readUInt16LE(pos + 7);
|
|
51
|
+
const flags = buf[pos + 9];
|
|
52
|
+
pos += 10;
|
|
53
|
+
|
|
54
|
+
let palette = globalPalette;
|
|
55
|
+
if (flags & 0x80) {
|
|
56
|
+
const localSize = 2 << (flags & 7);
|
|
57
|
+
palette = buf.slice(pos, pos + localSize * 3);
|
|
58
|
+
pos += localSize * 3;
|
|
59
|
+
}
|
|
60
|
+
if (!palette) return null;
|
|
61
|
+
const interlaced = !!(flags & 0x40);
|
|
62
|
+
|
|
63
|
+
const minCodeSize = buf[pos];
|
|
64
|
+
pos++;
|
|
65
|
+
const data = gatherBlocks(buf, pos);
|
|
66
|
+
if (!data) return null;
|
|
67
|
+
|
|
68
|
+
const indices = lzw(data, minCodeSize, width * height);
|
|
69
|
+
if (!indices) return null;
|
|
70
|
+
|
|
71
|
+
return paint(indices, width, height, left, top,
|
|
72
|
+
screenWidth || width, screenHeight || height,
|
|
73
|
+
palette, transparentIndex, interlaced);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (block === 0x3b) break; // trailer
|
|
77
|
+
pos++; // anything unexpected: step on
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Sub-blocks are length-prefixed and end with a zero length.
|
|
84
|
+
function skipBlocks(buf, pos) {
|
|
85
|
+
while (pos < buf.length && buf[pos] !== 0) pos += buf[pos] + 1;
|
|
86
|
+
return pos + 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function gatherBlocks(buf, pos) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
while (pos < buf.length && buf[pos] !== 0) {
|
|
92
|
+
const len = buf[pos];
|
|
93
|
+
if (pos + 1 + len > buf.length) return null;
|
|
94
|
+
parts.push(buf.slice(pos + 1, pos + 1 + len));
|
|
95
|
+
pos += len + 1;
|
|
96
|
+
}
|
|
97
|
+
return Buffer.concat(parts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Variable-width LZW. The dictionary starts with one entry per colour plus a
|
|
101
|
+
// clear code and an end code, and every new entry is a previous one with one
|
|
102
|
+
// more pixel on the end.
|
|
103
|
+
function lzw(data, minCodeSize, expected) {
|
|
104
|
+
if (minCodeSize < 2 || minCodeSize > 8) return null;
|
|
105
|
+
|
|
106
|
+
const clearCode = 1 << minCodeSize;
|
|
107
|
+
const endCode = clearCode + 1;
|
|
108
|
+
const out = new Uint8Array(expected);
|
|
109
|
+
|
|
110
|
+
let dict = [], codeSize = 0, next = 0;
|
|
111
|
+
const reset = () => {
|
|
112
|
+
dict = new Array(clearCode + 2);
|
|
113
|
+
for (let i = 0; i < clearCode; i++) dict[i] = [i];
|
|
114
|
+
codeSize = minCodeSize + 1;
|
|
115
|
+
next = clearCode + 2;
|
|
116
|
+
};
|
|
117
|
+
reset();
|
|
118
|
+
|
|
119
|
+
let bitPos = 0, written = 0, prev = null;
|
|
120
|
+
const totalBits = data.length * 8;
|
|
121
|
+
|
|
122
|
+
while (written < expected && bitPos + codeSize <= totalBits) {
|
|
123
|
+
let code = 0;
|
|
124
|
+
for (let i = 0; i < codeSize; i++) {
|
|
125
|
+
const b = bitPos + i;
|
|
126
|
+
code |= ((data[b >> 3] >> (b & 7)) & 1) << i; // codes are little-endian
|
|
127
|
+
}
|
|
128
|
+
bitPos += codeSize;
|
|
129
|
+
|
|
130
|
+
if (code === clearCode) { reset(); prev = null; continue; }
|
|
131
|
+
if (code === endCode) break;
|
|
132
|
+
|
|
133
|
+
let entry;
|
|
134
|
+
if (code < next && dict[code]) entry = dict[code];
|
|
135
|
+
else if (prev) entry = prev.concat(prev[0]); // the KwKwK case
|
|
136
|
+
else break;
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < entry.length && written < expected; i++) out[written++] = entry[i];
|
|
139
|
+
|
|
140
|
+
if (prev) {
|
|
141
|
+
dict[next++] = prev.concat(entry[0]);
|
|
142
|
+
if (next === (1 << codeSize) && codeSize < 12) codeSize++;
|
|
143
|
+
}
|
|
144
|
+
prev = entry;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function paint(indices, width, height, left, top, canvasW, canvasH,
|
|
151
|
+
palette, transparentIndex, interlaced) {
|
|
152
|
+
const out = Buffer.alloc(canvasW * canvasH * 4, 0);
|
|
153
|
+
|
|
154
|
+
// An interlaced frame arrives in four passes over every eighth, fourth and
|
|
155
|
+
// second row in turn.
|
|
156
|
+
const rowOf = interlaced ? interlacedRows(height) : null;
|
|
157
|
+
|
|
158
|
+
for (let y = 0; y < height; y++) {
|
|
159
|
+
const dy = (rowOf ? rowOf[y] : y) + top;
|
|
160
|
+
if (dy < 0 || dy >= canvasH) continue;
|
|
161
|
+
for (let x = 0; x < width; x++) {
|
|
162
|
+
const dx = x + left;
|
|
163
|
+
if (dx < 0 || dx >= canvasW) continue;
|
|
164
|
+
const idx = indices[y * width + x];
|
|
165
|
+
const o = (dy * canvasW + dx) * 4;
|
|
166
|
+
if (idx === transparentIndex) { out[o + 3] = 0; continue; }
|
|
167
|
+
const p = idx * 3;
|
|
168
|
+
out[o] = palette[p] || 0;
|
|
169
|
+
out[o + 1] = palette[p + 1] || 0;
|
|
170
|
+
out[o + 2] = palette[p + 2] || 0;
|
|
171
|
+
out[o + 3] = 255;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { width: canvasW, height: canvasH, data: out };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function interlacedRows(height) {
|
|
179
|
+
const rows = [];
|
|
180
|
+
for (const [start, step] of [[0, 8], [4, 8], [2, 4], [1, 2]]) {
|
|
181
|
+
for (let y = start; y < height; y += step) rows.push(y);
|
|
182
|
+
}
|
|
183
|
+
return rows;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { isGif, decodeGif };
|