@mega-yfue/eufy-sdk 0.2.0-beta.14 → 0.2.0-beta.15

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/dist/index.js CHANGED
@@ -217,18 +217,33 @@ BLOCKED_ADDRESSES.addAddress("::1", "ipv6");
217
217
  BLOCKED_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
218
218
  BLOCKED_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
219
219
  var MediaDownloadError = class extends Error {
220
+ mediaFailure;
221
+ status;
222
+ constructor(message, mediaFailure, status) {
223
+ super(message);
224
+ this.name = "MediaDownloadError";
225
+ this.mediaFailure = mediaFailure;
226
+ if (status !== void 0)
227
+ this.status = status;
228
+ }
220
229
  };
230
+ function mediaFailureError(message, reason, cause) {
231
+ const error = new MediaDownloadError(message, reason);
232
+ if (cause !== void 0)
233
+ error.cause = cause;
234
+ return error;
235
+ }
221
236
  var MediaDownloadAuthenticationError = class extends Error {
222
237
  };
223
- function allowedMediaUrl(value, hostPattern) {
238
+ function allowedMediaUrl(value, hostPattern, reason) {
224
239
  let url;
225
240
  try {
226
241
  url = new URL(value);
227
242
  } catch {
228
- throw new MediaDownloadError("Media download rejected");
243
+ throw new MediaDownloadError("Media download rejected", reason);
229
244
  }
230
245
  if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "" || isIP(url.hostname) !== 0 || !hostPattern.test(url.hostname)) {
231
- throw new MediaDownloadError("Media download rejected");
246
+ throw new MediaDownloadError("Media download rejected", reason);
232
247
  }
233
248
  return url;
234
249
  }
@@ -243,7 +258,7 @@ async function assertPublicResolution(url, resolveHost2, signal) {
243
258
  try {
244
259
  addresses = await Promise.race([resolveHost2(url.hostname), aborted]);
245
260
  } catch {
246
- throw new MediaDownloadError("Media download rejected");
261
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
247
262
  } finally {
248
263
  signal.removeEventListener("abort", onAbort);
249
264
  }
@@ -251,14 +266,14 @@ async function assertPublicResolution(url, resolveHost2, signal) {
251
266
  const type = family === 6 ? "ipv6" : family === 4 ? "ipv4" : void 0;
252
267
  return type === void 0 || BLOCKED_ADDRESSES.check(address, type);
253
268
  })) {
254
- throw new MediaDownloadError("Media download rejected");
269
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
255
270
  }
256
271
  }
257
272
  var resolveHost = (hostname) => lookup(hostname, { all: true, verbatim: true });
258
273
  async function readMediaBody(response) {
259
274
  const contentLength = response.headers.get("content-length");
260
275
  if (contentLength && /^\d+$/.test(contentLength) && BigInt(contentLength) > BigInt(MEDIA_DOWNLOAD_MAX_BYTES)) {
261
- throw new MediaDownloadError("Media download rejected");
276
+ throw new MediaDownloadError("Media download rejected", "too-large");
262
277
  }
263
278
  if (!response.body)
264
279
  return Buffer.alloc(0);
@@ -272,14 +287,14 @@ async function readMediaBody(response) {
272
287
  bytes2 += value.byteLength;
273
288
  if (bytes2 > MEDIA_DOWNLOAD_MAX_BYTES) {
274
289
  void reader2.cancel().catch(() => void 0);
275
- throw new MediaDownloadError("Media download rejected");
290
+ throw new MediaDownloadError("Media download rejected", "too-large");
276
291
  }
277
292
  chunks.push(value);
278
293
  }
279
294
  return Buffer.concat(chunks, bytes2);
280
295
  }
281
296
  async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetch, resolver = resolveHost) {
282
- const original = allowedMediaUrl(url, EUFY_MEDIA_HOST);
297
+ const original = allowedMediaUrl(url, EUFY_MEDIA_HOST, "url-not-allowed");
283
298
  const controller = new AbortController();
284
299
  const timeout = setTimeout(() => controller.abort(), MEDIA_DOWNLOAD_TIMEOUT_MS);
285
300
  try {
@@ -292,31 +307,33 @@ async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetc
292
307
  if (originalResponse.status >= 300 && originalResponse.status < 400) {
293
308
  const location = originalResponse.headers.get("location");
294
309
  if (!location)
295
- throw new MediaDownloadError("Media download rejected");
296
- const target = allowedMediaUrl(location, EUFY_OBJECT_HOST);
310
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
311
+ const target = allowedMediaUrl(location, EUFY_OBJECT_HOST, "redirect-not-allowed");
297
312
  await assertPublicResolution(target, resolver, controller.signal);
298
313
  const redirectedResponse = await fetchImpl(target, { redirect: "manual", signal: controller.signal });
299
314
  if (redirectedResponse.status >= 300 && redirectedResponse.status < 400) {
300
- throw new MediaDownloadError("Media download rejected");
315
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
316
+ }
317
+ if (redirectedResponse.status !== 200) {
318
+ throw new MediaDownloadError("Media download failed", "http-status", redirectedResponse.status);
301
319
  }
302
- if (redirectedResponse.status !== 200)
303
- throw new MediaDownloadError("Media download failed");
304
320
  return await readMediaBody(redirectedResponse);
305
321
  }
306
322
  if (originalResponse.status === 401 || originalResponse.status === 403) {
307
323
  throw new MediaDownloadAuthenticationError("Media authentication failed");
308
324
  }
309
- if (originalResponse.status !== 200)
310
- throw new MediaDownloadError("Media download failed");
325
+ if (originalResponse.status !== 200) {
326
+ throw new MediaDownloadError("Media download failed", "http-status", originalResponse.status);
327
+ }
311
328
  return await readMediaBody(originalResponse);
312
329
  } catch (error) {
313
330
  if (controller.signal.aborted)
314
- throw new MediaDownloadError("Media download timed out");
331
+ throw new MediaDownloadError("Media download timed out", "timeout");
315
332
  if (error instanceof MediaDownloadAuthenticationError)
316
333
  throw error;
317
334
  if (error instanceof MediaDownloadError)
318
335
  throw error;
319
- throw new MediaDownloadError("Media download failed");
336
+ throw new MediaDownloadError("Media download failed", "network");
320
337
  } finally {
321
338
  clearTimeout(timeout);
322
339
  }
@@ -375,8 +392,228 @@ function randomUserAgent(seed, model) {
375
392
  // dist/transport/http/decodeImageV1.js
376
393
  import { createDecipheriv as createDecipheriv2, createHash as createHash2 } from "node:crypto";
377
394
 
395
+ // dist/transport/http/jpeg-scan.js
396
+ var DHT = 196;
397
+ var SOS = 218;
398
+ var DRI = 221;
399
+ var RST_FIRST = 208;
400
+ var RST_LAST = 215;
401
+ var LUMA_SAMPLING = { 0: [1, 1], 1: [2, 1], 2: [2, 2] };
402
+ var ScanEnd = class extends Error {
403
+ };
404
+ function readHuffmanTables(segment, into) {
405
+ const end = 2 + (segment[2] << 8 | segment[3]);
406
+ let at = 4;
407
+ while (at < end) {
408
+ const id = segment[at];
409
+ const counts = segment.subarray(at + 1, at + 17);
410
+ let total = 0;
411
+ for (const count2 of counts)
412
+ total += count2;
413
+ const values = segment.slice(at + 17, at + 17 + total);
414
+ const minCode = new Int32Array(17);
415
+ const maxCode = new Int32Array(17).fill(-1);
416
+ const valPtr = new Int32Array(17);
417
+ let code = 0;
418
+ let index = 0;
419
+ for (let length = 1; length <= 16; length++) {
420
+ const count2 = counts[length - 1];
421
+ if (count2 > 0) {
422
+ valPtr[length] = index;
423
+ minCode[length] = code;
424
+ code += count2;
425
+ index += count2;
426
+ maxCode[length] = code - 1;
427
+ }
428
+ code <<= 1;
429
+ }
430
+ into.set(id, { minCode, maxCode, valPtr, values });
431
+ at += 17 + total;
432
+ }
433
+ }
434
+ var BitReader = class {
435
+ /** Byte offset of the next byte to read. */
436
+ position;
437
+ bits = 0;
438
+ count = 0;
439
+ /** Set when the reader stepped over a restart marker — the caller resets its DC predictors. */
440
+ restarted = false;
441
+ data;
442
+ constructor(data, start) {
443
+ this.data = data;
444
+ this.position = start;
445
+ }
446
+ /** The offset the last whole byte ended at — what "how much of the scan did this consume" reads. */
447
+ get consumed() {
448
+ return this.position;
449
+ }
450
+ readBit() {
451
+ if (this.count === 0) {
452
+ if (this.position >= this.data.length)
453
+ throw new ScanEnd("out of data");
454
+ let byte = this.data[this.position++];
455
+ if (byte === 255) {
456
+ const next = this.data[this.position];
457
+ if (next === 0) {
458
+ this.position++;
459
+ } else if (next !== void 0 && next >= RST_FIRST && next <= RST_LAST) {
460
+ this.position++;
461
+ this.restarted = true;
462
+ if (this.position >= this.data.length)
463
+ throw new ScanEnd("out of data");
464
+ byte = this.data[this.position++];
465
+ if (byte === 255)
466
+ throw new ScanEnd("marker after restart");
467
+ } else {
468
+ this.position--;
469
+ throw new ScanEnd("marker");
470
+ }
471
+ }
472
+ this.bits = byte;
473
+ this.count = 8;
474
+ }
475
+ this.count--;
476
+ return this.bits >> this.count & 1;
477
+ }
478
+ /** Read `length` bits, most significant first. */
479
+ readBits(length) {
480
+ let value = 0;
481
+ for (let i = 0; i < length; i++)
482
+ value = value << 1 | this.readBit();
483
+ return value;
484
+ }
485
+ /** Drop the current byte's remaining bits — what a restart interval boundary does. */
486
+ align() {
487
+ this.count = 0;
488
+ }
489
+ };
490
+ function decodeHuffman(reader2, table) {
491
+ let code = reader2.readBit();
492
+ for (let length = 1; length <= 16; length++) {
493
+ if (table.maxCode[length] >= 0 && code <= table.maxCode[length]) {
494
+ return table.values[table.valPtr[length] + code - table.minCode[length]];
495
+ }
496
+ code = code << 1 | reader2.readBit();
497
+ }
498
+ throw new ScanEnd("no huffman code of any length");
499
+ }
500
+ function extend(value, length) {
501
+ return value < 1 << length - 1 ? value - (1 << length) + 1 : value;
502
+ }
503
+ function decodeBlock(reader2, component) {
504
+ const dcLength = decodeHuffman(reader2, component.dc);
505
+ if (dcLength > 16)
506
+ throw new ScanEnd("dc magnitude out of range");
507
+ component.pred += dcLength === 0 ? 0 : extend(reader2.readBits(dcLength), dcLength);
508
+ let k = 1;
509
+ while (k < 64) {
510
+ const rs = decodeHuffman(reader2, component.ac);
511
+ const size = rs & 15;
512
+ const run = rs >> 4;
513
+ if (size === 0) {
514
+ if (run !== 15)
515
+ break;
516
+ k += 16;
517
+ } else {
518
+ k += run;
519
+ if (k > 63)
520
+ throw new ScanEnd("ac coefficient index out of range");
521
+ reader2.readBits(size);
522
+ k++;
523
+ }
524
+ }
525
+ return component.pred;
526
+ }
527
+ function scanEntropy(tail, subsampling, extraTables) {
528
+ const tables = /* @__PURE__ */ new Map();
529
+ for (const segment of extraTables)
530
+ readHuffmanTables(segment, tables);
531
+ let at = 0;
532
+ let restartInterval = 0;
533
+ let scanStart = -1;
534
+ let assignments = [];
535
+ while (at + 3 < tail.length) {
536
+ if (tail[at] !== 255)
537
+ return null;
538
+ const marker = tail[at + 1];
539
+ const length = tail[at + 2] << 8 | tail[at + 3];
540
+ if (marker === DHT) {
541
+ readHuffmanTables(tail.subarray(at, at + 2 + length), tables);
542
+ } else if (marker === DRI) {
543
+ restartInterval = tail[at + 4] << 8 | tail[at + 5];
544
+ } else if (marker === SOS) {
545
+ const count2 = tail[at + 4];
546
+ assignments = [];
547
+ for (let i = 0; i < count2; i++) {
548
+ const id = tail[at + 5 + i * 2];
549
+ const tableByte = tail[at + 6 + i * 2];
550
+ assignments.push({ id, dc: tableByte >> 4, ac: tableByte & 15 });
551
+ }
552
+ scanStart = at + 2 + length;
553
+ break;
554
+ }
555
+ at += 2 + length;
556
+ }
557
+ if (scanStart < 0 || assignments.length !== 3)
558
+ return null;
559
+ const [lumaH, lumaV] = LUMA_SAMPLING[subsampling] ?? LUMA_SAMPLING[0];
560
+ const components = [];
561
+ for (const [index, assignment] of assignments.entries()) {
562
+ const dc = tables.get(assignment.dc);
563
+ const ac = tables.get(16 | assignment.ac);
564
+ if (!dc || !ac)
565
+ return null;
566
+ components.push({ h: index === 0 ? lumaH : 1, v: index === 0 ? lumaV : 1, dc, ac, pred: 0 });
567
+ }
568
+ const reader2 = new BitReader(tail, scanStart);
569
+ let luma = new Int32Array(1024);
570
+ const lumaBlocks = lumaH * lumaV;
571
+ let mcus = 0;
572
+ let consumed = reader2.consumed;
573
+ let complete = false;
574
+ try {
575
+ for (; ; ) {
576
+ if (restartInterval > 0 && mcus > 0 && mcus % restartInterval === 0) {
577
+ reader2.align();
578
+ for (const component of components)
579
+ component.pred = 0;
580
+ }
581
+ if (reader2.restarted) {
582
+ reader2.restarted = false;
583
+ for (const component of components)
584
+ component.pred = 0;
585
+ }
586
+ let lumaSum = 0;
587
+ for (const component of components) {
588
+ for (let block = 0; block < component.h * component.v; block++) {
589
+ const dc = decodeBlock(reader2, component);
590
+ if (component === components[0])
591
+ lumaSum += dc;
592
+ }
593
+ }
594
+ if (mcus === luma.length) {
595
+ const grown = new Int32Array(luma.length * 2);
596
+ grown.set(luma);
597
+ luma = grown;
598
+ }
599
+ luma[mcus] = Math.round(lumaSum / lumaBlocks);
600
+ mcus++;
601
+ consumed = reader2.consumed;
602
+ if (consumed >= tail.length) {
603
+ complete = true;
604
+ break;
605
+ }
606
+ }
607
+ } catch (error) {
608
+ if (!(error instanceof ScanEnd))
609
+ throw error;
610
+ complete = tail.length - consumed <= 2;
611
+ }
612
+ return { mcus, luma: luma.subarray(0, mcus), complete };
613
+ }
614
+
378
615
  // dist/transport/http/decodeImageV2.js
379
- import { decode as jpegDecode, encode as jpegEncode } from "jpeg-js";
616
+ import { decode as jpegDecode } from "jpeg-js";
380
617
  var V2_PREFIX = "v2_eufysecurity:";
381
618
  var DC_CHROMA = Buffer.from([255, 196, 0, 31, 1]);
382
619
  var ZIGZAG = [
@@ -582,35 +819,6 @@ var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d0102030
582
819
  var SOI = Buffer.from([255, 216]);
583
820
  var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
584
821
  var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
585
- var LADDER = [
586
- [160, 90],
587
- [240, 135],
588
- [256, 144],
589
- [320, 180],
590
- [384, 216],
591
- [400, 225],
592
- [480, 270],
593
- [512, 288],
594
- [576, 324],
595
- [640, 360],
596
- [704, 396],
597
- [768, 432],
598
- [848, 480],
599
- [960, 540],
600
- [1024, 576],
601
- [1280, 720],
602
- [1600, 900],
603
- [1920, 1080],
604
- [176, 144],
605
- [320, 240],
606
- [352, 288],
607
- [480, 360],
608
- [640, 480],
609
- [800, 600],
610
- [1024, 768],
611
- [256, 480],
612
- [320, 384]
613
- ];
614
822
  function scaleQuant(base, quality) {
615
823
  const factor = quality < 50 ? Math.floor(5e3 / quality) : 200 - quality * 2;
616
824
  return base.map((v) => Math.min(255, Math.max(1, Math.floor((v * factor + 50) / 100))));
@@ -647,7 +855,7 @@ function sofSegment(width, height, subsampling) {
647
855
  1
648
856
  ]);
649
857
  }
650
- function buildHeader(width, height, subsampling, quality = 85) {
858
+ function buildHeader(width, height, subsampling, quality) {
651
859
  return Buffer.concat([
652
860
  SOI,
653
861
  APP0,
@@ -658,99 +866,107 @@ function buildHeader(width, height, subsampling, quality = 85) {
658
866
  DHT_AC_LUMA
659
867
  ]);
660
868
  }
661
- function decodeCandidate(tail, width, height, subsampling) {
869
+ var PROBE_MEMORY_MB = 24;
870
+ function decodeSpliced(header, tail) {
662
871
  try {
663
- const jpeg = Buffer.concat([buildHeader(width, height, subsampling), tail]);
664
- return jpegDecode(jpeg, { useTArray: true, maxMemoryUsageInMB: 128 });
872
+ return jpegDecode(Buffer.concat([header, tail]), { useTArray: true, maxMemoryUsageInMB: PROBE_MEMORY_MB });
665
873
  } catch {
666
874
  return null;
667
875
  }
668
876
  }
669
- function colorSpread(img) {
670
- const { width, height, data } = img;
671
- let sum = 0;
672
- let count2 = 0;
673
- for (let i = 0; i < width * height; i += 37) {
674
- const p = i * 4;
675
- const r = data[p];
676
- const g = data[p + 1];
677
- const b = data[p + 2];
678
- sum += Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
679
- count2++;
680
- }
681
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
682
- }
683
- function rowShear(img, rows) {
684
- const { width, data } = img;
685
- let sum = 0;
686
- let count2 = 0;
687
- for (let y = 1; y < rows; y++) {
688
- for (let x = 0; x < width; x += 4) {
689
- sum += Math.abs(data[(y * width + x) * 4] - data[((y - 1) * width + x) * 4]);
690
- count2++;
691
- }
877
+ var REFERENCE_QUALITY = 85;
878
+ var MAX_STRETCH = 8;
879
+ var CUTOFF_PERCENT = 0.5;
880
+ function trimmedRange(data, channel, total, cutoff) {
881
+ const hist = new Array(256).fill(0);
882
+ for (let i = 0; i < total; i++)
883
+ hist[data[i * 4 + channel]]++;
884
+ let remaining = Math.floor(total * cutoff / 100);
885
+ let lo = 0;
886
+ while (lo < 255 && remaining > 0) {
887
+ if (remaining < hist[lo])
888
+ break;
889
+ remaining -= hist[lo];
890
+ lo++;
891
+ }
892
+ remaining = Math.floor(total * cutoff / 100);
893
+ let hi = 255;
894
+ while (hi > 0 && remaining > 0) {
895
+ if (remaining < hist[hi])
896
+ break;
897
+ remaining -= hist[hi];
898
+ hi--;
692
899
  }
693
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
900
+ return { lo, hi };
694
901
  }
695
- function autoContrast(data, width, height, cutoff = 0.5) {
696
- const total = width * height;
902
+ function contrastScale(img, cutoff = CUTOFF_PERCENT) {
903
+ const total = img.width * img.height;
904
+ if (total === 0)
905
+ return 1;
906
+ let stretch = Number.POSITIVE_INFINITY;
697
907
  for (let channel = 0; channel < 3; channel++) {
698
- const hist = new Array(256).fill(0);
699
- for (let i = 0; i < total; i++)
700
- hist[data[i * 4 + channel]]++;
701
- let remaining = Math.floor(total * cutoff / 100);
702
- for (let bin = 0; bin < 256 && remaining > 0; bin++) {
703
- if (remaining > hist[bin]) {
704
- remaining -= hist[bin];
705
- hist[bin] = 0;
706
- } else {
707
- hist[bin] -= remaining;
708
- remaining = 0;
709
- }
710
- }
711
- remaining = Math.floor(total * cutoff / 100);
712
- for (let bin = 255; bin >= 0 && remaining > 0; bin--) {
713
- if (remaining > hist[bin]) {
714
- remaining -= hist[bin];
715
- hist[bin] = 0;
716
- } else {
717
- hist[bin] -= remaining;
718
- remaining = 0;
719
- }
720
- }
721
- let lo = 0;
722
- while (lo < 256 && hist[lo] === 0)
723
- lo++;
724
- let hi = 255;
725
- while (hi >= 0 && hist[hi] === 0)
726
- hi--;
908
+ const { lo, hi } = trimmedRange(img.data, channel, total, cutoff);
727
909
  if (hi <= lo)
728
910
  continue;
729
- const scale = 255 / (hi - lo);
730
- const offset = -lo * scale;
731
- const lut = new Uint8Array(256);
732
- for (let v = 0; v < 256; v++)
733
- lut[v] = Math.min(255, Math.max(0, Math.trunc(v * scale + offset)));
734
- for (let i = 0; i < total; i++)
735
- data[i * 4 + channel] = lut[data[i * 4 + channel]];
736
- }
737
- }
738
- function maxHeight(tail, width, subsampling, seed) {
739
- const mcuHeight = MCU[subsampling][1];
740
- let height = Math.max(mcuHeight, Math.round(seed / mcuHeight) * mcuHeight);
741
- if (!decodeCandidate(tail, width, height, subsampling)) {
742
- while (height > mcuHeight && !decodeCandidate(tail, width, height, subsampling))
743
- height -= mcuHeight;
744
- } else {
745
- while (height < 4096 && decodeCandidate(tail, width, height + mcuHeight, subsampling))
746
- height += mcuHeight;
747
- }
748
- return height;
749
- }
750
- function ladderByMcuCount(subsampling) {
751
- const [mcuWidth, mcuHeight] = MCU[subsampling];
752
- const mcus = ([width, height]) => width / mcuWidth * (height / mcuHeight);
753
- return [...LADDER].sort((a, b) => mcus(a) - mcus(b));
911
+ if (lo < 128)
912
+ stretch = Math.min(stretch, 128 / (128 - lo));
913
+ if (hi > 128)
914
+ stretch = Math.min(stretch, 127 / (hi - 128));
915
+ }
916
+ if (!Number.isFinite(stretch))
917
+ return 1;
918
+ return Math.min(MAX_STRETCH, Math.max(1, stretch));
919
+ }
920
+ function qualityForStretch(stretch) {
921
+ const referenceFactor = REFERENCE_QUALITY < 50 ? Math.floor(5e3 / REFERENCE_QUALITY) : 200 - REFERENCE_QUALITY * 2;
922
+ const factor = referenceFactor * stretch;
923
+ const quality = factor <= 100 ? (200 - factor) / 2 : 5e3 / factor;
924
+ return Math.min(99, Math.max(1, Math.round(quality)));
925
+ }
926
+ var MAX_EDGE = 4096;
927
+ var MIN_ASPECT = 0.4;
928
+ var MAX_ASPECT = 4;
929
+ function mcuRowShear(luma, mcuWidth, mcuHeight) {
930
+ if (mcuHeight < 2)
931
+ return Number.POSITIVE_INFINITY;
932
+ let sum = 0;
933
+ for (let y = 1; y < mcuHeight; y++) {
934
+ for (let x = 0; x < mcuWidth; x++)
935
+ sum += Math.abs(luma[y * mcuWidth + x] - luma[(y - 1) * mcuWidth + x]);
936
+ }
937
+ return sum / (mcuWidth * (mcuHeight - 1));
938
+ }
939
+ function bestGeometry(luma, mcus, subsampling) {
940
+ const [mcuPixelWidth, mcuPixelHeight] = MCU[subsampling];
941
+ let best = null;
942
+ for (let mcuWidth = 1; mcuWidth <= mcus; mcuWidth++) {
943
+ if (mcus % mcuWidth !== 0)
944
+ continue;
945
+ const mcuHeight = mcus / mcuWidth;
946
+ const width = mcuWidth * mcuPixelWidth;
947
+ const height = mcuHeight * mcuPixelHeight;
948
+ if (width > MAX_EDGE || height > MAX_EDGE)
949
+ continue;
950
+ const aspect = width / height;
951
+ if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
952
+ continue;
953
+ const shear = mcuRowShear(luma, mcuWidth, mcuHeight);
954
+ if (!best || shear < best.shear)
955
+ best = { subsampling, width, height, shear };
956
+ }
957
+ return best;
958
+ }
959
+ function findGeometry(tail) {
960
+ let best = null;
961
+ for (const subsampling of [0, 1, 2]) {
962
+ const scan = scanEntropy(tail, subsampling, [DHT_DC_LUMA, DHT_AC_LUMA]);
963
+ if (!scan?.complete || scan.mcus < 1)
964
+ continue;
965
+ const geometry = bestGeometry(scan.luma, scan.mcus, subsampling);
966
+ if (geometry && (!best || geometry.shear < best.shear))
967
+ best = geometry;
968
+ }
969
+ return best;
754
970
  }
755
971
  function isV2Image(data) {
756
972
  return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
@@ -762,53 +978,17 @@ function decodeImageV2(data) {
762
978
  if (cut < 0)
763
979
  return null;
764
980
  const tail = data.subarray(cut);
765
- let best = null;
766
- for (const subsampling2 of [2, 0, 1]) {
767
- let filled = null;
768
- for (const [width2, height2] of ladderByMcuCount(subsampling2)) {
769
- const img2 = decodeCandidate(tail, width2, height2, subsampling2);
770
- if (!img2)
771
- break;
772
- if (!filled || width2 * height2 > filled.width * filled.height)
773
- filled = { width: width2, height: height2, img: img2 };
774
- }
775
- if (!filled)
776
- continue;
777
- const spread = colorSpread(filled.img);
778
- if (!best || spread < best.spread)
779
- best = { spread, subsampling: subsampling2, width: filled.width, height: filled.height };
780
- }
781
- if (!best)
981
+ const geometry = findGeometry(tail);
982
+ if (!geometry)
782
983
  return null;
783
- const { subsampling, width: coarseWidth } = best;
784
- const [mcuWidth, mcuHeight] = MCU[subsampling];
785
- const coarseHeight = maxHeight(tail, coarseWidth, subsampling, best.height);
786
- const totalMcus = coarseWidth / mcuWidth * (coarseHeight / mcuHeight);
787
- let width = coarseWidth;
788
- let height = coarseHeight;
789
- let bestShear = Number.POSITIVE_INFINITY;
790
- const lowWidth = Math.max(mcuWidth * 4, Math.round(coarseWidth * 0.75 / mcuWidth) * mcuWidth);
791
- const highWidth = Math.round(coarseWidth * 1.25 / mcuWidth) * mcuWidth;
792
- for (let candidateWidth = lowWidth; candidateWidth <= highWidth; candidateWidth += mcuWidth) {
793
- const candidateHeight = Math.floor(totalMcus / (candidateWidth / mcuWidth)) * mcuHeight;
794
- if (candidateHeight < mcuHeight)
795
- continue;
796
- const img2 = decodeCandidate(tail, candidateWidth, candidateHeight, subsampling);
797
- if (!img2)
798
- continue;
799
- const shear = rowShear(img2, candidateHeight);
800
- if (shear < bestShear) {
801
- bestShear = shear;
802
- width = candidateWidth;
803
- height = candidateHeight;
804
- }
805
- }
806
- height = maxHeight(tail, width, subsampling, height);
807
- const img = decodeCandidate(tail, width, height, subsampling);
808
- if (!img)
984
+ const { width, height, subsampling } = geometry;
985
+ const reference = buildHeader(width, height, subsampling, REFERENCE_QUALITY);
986
+ const probe = decodeSpliced(reference, tail);
987
+ if (!probe)
809
988
  return null;
810
- autoContrast(img.data, img.width, img.height);
811
- return Buffer.from(jpegEncode({ data: img.data, width: img.width, height: img.height }, 90).data);
989
+ const stretch = contrastScale(probe);
990
+ const header = stretch > 1.01 ? buildHeader(width, height, subsampling, qualityForStretch(stretch)) : reference;
991
+ return Buffer.concat([header, tail]);
812
992
  }
813
993
 
814
994
  // dist/transport/http/decodeImageV1.js
@@ -1420,9 +1600,20 @@ var MegaHttpClient = class {
1420
1600
  throw error;
1421
1601
  }
1422
1602
  }
1423
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
1603
+ /**
1604
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
1605
+ *
1606
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
1607
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
1608
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
1609
+ */
1424
1610
  async downloadImage(url, p2pDid) {
1425
- return normalizePushImage(await this.downloadMedia(url), p2pDid);
1611
+ const data = await this.downloadMedia(url);
1612
+ try {
1613
+ return normalizePushImage(data, p2pDid);
1614
+ } catch (error) {
1615
+ throw mediaFailureError("Push image could not be decoded", "decode-failed", error);
1616
+ }
1426
1617
  }
1427
1618
  /** The security-app data host for this region (face recognition, media, etc.). */
1428
1619
  securityAppHost() {
@@ -12880,7 +13071,7 @@ function lz4BlockDecompress(block, size) {
12880
13071
  const end = block.length;
12881
13072
  let pos = 0;
12882
13073
  let written = 0;
12883
- const extend = (base) => {
13074
+ const extend2 = (base) => {
12884
13075
  let len = base;
12885
13076
  if (base !== 15)
12886
13077
  return len;
@@ -12895,7 +13086,7 @@ function lz4BlockDecompress(block, size) {
12895
13086
  };
12896
13087
  while (pos < end) {
12897
13088
  const token = block[pos++];
12898
- const literals = extend(token >> 4);
13089
+ const literals = extend2(token >> 4);
12899
13090
  if (literals === void 0)
12900
13091
  return void 0;
12901
13092
  if (pos + literals > end || written + literals > size)
@@ -12910,7 +13101,7 @@ function lz4BlockDecompress(block, size) {
12910
13101
  pos += 2;
12911
13102
  if (offset === 0 || offset > written)
12912
13103
  return void 0;
12913
- const match = extend(token & 15);
13104
+ const match = extend2(token & 15);
12914
13105
  if (match === void 0)
12915
13106
  return void 0;
12916
13107
  const length = match + 4;
@@ -23367,8 +23558,21 @@ var FileFcmStore = class {
23367
23558
  }
23368
23559
  };
23369
23560
 
23561
+ // dist/transport/media-failure.js
23562
+ var MEDIA_FAILURE_REASONS = [
23563
+ "url-not-allowed",
23564
+ "address-not-public",
23565
+ "redirect-not-allowed",
23566
+ "http-status",
23567
+ "too-large",
23568
+ "timeout",
23569
+ "network",
23570
+ "decode-failed"
23571
+ ];
23572
+
23370
23573
  // dist/transport/stored-image-cache.js
23371
23574
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
23575
+ var MAX_REMEMBERED_URLS = 64;
23372
23576
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
23373
23577
  var StoredImageCache = class {
23374
23578
  downloader;
@@ -23385,7 +23589,13 @@ var StoredImageCache = class {
23385
23589
  this.clock = clock;
23386
23590
  this.isLifecycleError = isLifecycleError;
23387
23591
  }
23388
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
23592
+ /**
23593
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
23594
+ *
23595
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
23596
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
23597
+ * so the entry evicted once it is full is the oldest attempt.
23598
+ */
23389
23599
  observe(deviceKey, url) {
23390
23600
  let state = this.devices.get(deviceKey);
23391
23601
  if (!state) {
@@ -23400,6 +23610,12 @@ var StoredImageCache = class {
23400
23610
  if (state.seenUrls.has(url))
23401
23611
  return;
23402
23612
  state.seenUrls.add(url);
23613
+ while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
23614
+ const oldest = state.seenUrls.values().next();
23615
+ if (oldest.done)
23616
+ break;
23617
+ state.seenUrls.delete(oldest.value);
23618
+ }
23403
23619
  state.queued = {
23404
23620
  deviceKey,
23405
23621
  url,
@@ -23449,7 +23665,7 @@ var StoredImageCache = class {
23449
23665
  } else if (image === void 0) {
23450
23666
  state.lifecycleError = void 0;
23451
23667
  state.reason = "download-failed";
23452
- this.diagnose(state, candidate, "download-failed");
23668
+ this.diagnose(state, candidate, "download-failed", error);
23453
23669
  } else if (!this.isValidJpeg(image)) {
23454
23670
  state.lifecycleError = void 0;
23455
23671
  state.reason = "invalid-image";
@@ -23465,7 +23681,7 @@ var StoredImageCache = class {
23465
23681
  isValidJpeg(image) {
23466
23682
  return Buffer.isBuffer(image) && image.length > 0 && image.length <= MAX_JPEG_BYTES && image.length >= 5 && image[0] === 255 && image[1] === 216 && image[2] === 255 && image[image.length - 2] === 255 && image[image.length - 1] === 217;
23467
23683
  }
23468
- diagnose(state, candidate, failure) {
23684
+ diagnose(state, candidate, failure, error) {
23469
23685
  const now2 = this.clock();
23470
23686
  const last = state.loggedFailures.get(failure);
23471
23687
  if (last !== void 0 && now2 - last < DIAGNOSTIC_INTERVAL_MS)
@@ -23473,11 +23689,23 @@ var StoredImageCache = class {
23473
23689
  state.loggedFailures.set(failure, now2);
23474
23690
  this.logger.warn("[stored-snapshot-cache] candidate failed", {
23475
23691
  class: failure,
23692
+ ...mediaFailureTag(error),
23476
23693
  observedAt: candidate.observedAt,
23477
23694
  retained: state.retained !== void 0
23478
23695
  });
23479
23696
  }
23480
23697
  };
23698
+ function mediaFailureTag(error) {
23699
+ if (typeof error !== "object" || error === null)
23700
+ return {};
23701
+ const { mediaFailure, status } = error;
23702
+ if (typeof mediaFailure !== "string")
23703
+ return {};
23704
+ const cause = MEDIA_FAILURE_REASONS.find((reason) => reason === mediaFailure);
23705
+ if (!cause)
23706
+ return {};
23707
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? { cause, status } : { cause };
23708
+ }
23481
23709
 
23482
23710
  // dist/client/device-registry.js
23483
23711
  function resolvedStationSn(raw, sn) {
@@ -26747,7 +26975,6 @@ export {
26747
26975
  aesKey,
26748
26976
  asBool,
26749
26977
  assertNever,
26750
- autoContrast,
26751
26978
  bizChannelName,
26752
26979
  buildActions,
26753
26980
  buildAppShapedClientId,