@skrillex1224/android-toolkit 0.1.7 → 0.1.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/src/device.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import zlib from 'node:zlib';
2
3
  import {execFile} from 'node:child_process';
3
4
  import {promisify} from 'node:util';
4
5
 
@@ -95,6 +96,26 @@ export async function screenSize(ctx) {
95
96
  };
96
97
  }
97
98
 
99
+ export async function screenDensity(ctx) {
100
+ const out = await adbShell(ctx, ['wm', 'density']).catch(() => '');
101
+ const match = /(\d+)/.exec(String(out || ''));
102
+ return match ? Number(match[1]) : 0;
103
+ }
104
+
105
+ export async function overrideScreenSize(ctx, width, height) {
106
+ const safeWidth = Math.max(1, Math.round(Number(width) || 0));
107
+ const safeHeight = Math.max(1, Math.round(Number(height) || 0));
108
+ await adbShell(ctx, ['wm', 'size', `${safeWidth}x${safeHeight}`], {timeoutMs: 15000});
109
+ }
110
+
111
+ export async function resetScreenSize(ctx) {
112
+ await adbShell(ctx, ['wm', 'size', 'reset'], {timeoutMs: 15000});
113
+ }
114
+
115
+ export async function resetScreenDensity(ctx) {
116
+ await adbShell(ctx, ['wm', 'density', 'reset'], {timeoutMs: 15000});
117
+ }
118
+
98
119
  export async function tapAbsolute(ctx, x, y) {
99
120
  // 点击绝对像素坐标,适合已经从截图或 DOM rect 算出的精确点。
100
121
  await adbInput(ctx, ['tap', String(Math.round(Number(x))), String(Math.round(Number(y)))]);
@@ -218,25 +239,71 @@ export async function waitForActivity(ctx, predicate, timeoutMs = 15000, interva
218
239
 
219
240
  export async function screenshotBase64(ctx) {
220
241
  // 获取当前屏幕 PNG,并转成 base64 写入 dataset artifact。
221
- const stdout = await adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
222
- timeoutMs: 15000,
223
- encoding: 'buffer',
224
- maxBuffer: 8 * 1024 * 1024
225
- });
242
+ const stdout = await screenshotPng(ctx);
226
243
  return Buffer.from(stdout).toString('base64');
227
244
  }
228
245
 
246
+ export async function longScreenshotBase64(ctx, options = {}) {
247
+ const startedAt = Date.now();
248
+ const size = await screenSize(ctx).catch(() => ({width: 720, height: 1280}));
249
+ const captureCount = Math.max(1, Math.min(8, Number(options.captureCount || 4)));
250
+ const topCrop = clampInt(Number(options.topCrop ?? Math.floor(size.height * 0.12)), 0, size.height - 1);
251
+ const bottomCrop = clampInt(Number(options.bottomCrop ?? Math.floor(size.height * 0.18)), 0, size.height - topCrop - 1);
252
+ const swipeFromY = Number(options.swipeFromY || 0.78);
253
+ const swipeToY = Number(options.swipeToY || 0.28);
254
+ const swipeDurationMs = Number(options.swipeDurationMs || 650);
255
+ const settleMs = Number(options.settleMs || 900);
256
+ const scrollToTopFirst = Boolean(options.scrollToTopFirst);
257
+ const scrollToTopSwipes = Math.max(0, Math.min(8, Number(options.scrollToTopSwipes || 4)));
258
+ const pngs = [];
259
+
260
+ Logger.info('long screenshot start', {
261
+ serial: ctx.serial,
262
+ captureCount,
263
+ topCrop,
264
+ bottomCrop,
265
+ width: size.width,
266
+ height: size.height
267
+ });
268
+
269
+ if (scrollToTopFirst) {
270
+ for (let i = 0; i < scrollToTopSwipes; i += 1) {
271
+ await swipeScreenRatio(ctx, 0.50, swipeToY, 0.50, swipeFromY, swipeDurationMs).catch(() => { });
272
+ await sleep(Math.max(350, Math.floor(settleMs * 0.6)));
273
+ }
274
+ }
275
+
276
+ for (let i = 0; i < captureCount; i += 1) {
277
+ const png = await screenshotPng(ctx);
278
+ pngs.push(png);
279
+ if (i < captureCount - 1) {
280
+ await swipeScreenRatio(ctx, 0.50, swipeFromY, 0.50, swipeToY, swipeDurationMs).catch(() => { });
281
+ await sleep(settleMs);
282
+ }
283
+ }
284
+
285
+ const stitched = stitchVerticalPngs(pngs, {
286
+ topCrop,
287
+ bottomCrop,
288
+ maxOutputHeight: Math.max(size.height, Number(options.maxOutputHeight || size.height * captureCount)),
289
+ dedupeAdjacent: options.dedupeAdjacent !== false
290
+ });
291
+ Logger.info('long screenshot done', {
292
+ serial: ctx.serial,
293
+ duration: Logger.duration(startedAt),
294
+ bytes: stitched.length,
295
+ captureCount: pngs.length
296
+ });
297
+ return stitched.toString('base64');
298
+ }
299
+
229
300
  export async function saveDebugScreenshot(ctx, name) {
230
301
  // 保存调试截图到 /tmp,主要用于本地定位点击点和页面状态。
231
302
  if (!ctx.serial) return '';
232
303
  const safeRunId = safeFilePart(ctx.runId || 'manual');
233
304
  const safeName = safeFilePart(name || 'screenshot');
234
305
  const out = `/tmp/android-toolkit-${safeRunId}-${safeName}.png`;
235
- const stdout = await adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
236
- timeoutMs: 15000,
237
- encoding: 'buffer',
238
- maxBuffer: 8 * 1024 * 1024
239
- });
306
+ const stdout = await screenshotPng(ctx);
240
307
  fs.writeFileSync(out, stdout);
241
308
  Logger.info(`debug screenshot saved ${out}`);
242
309
  return out;
@@ -282,6 +349,308 @@ async function adbInput(ctx, args, options = {}) {
282
349
  await adbShell(ctx, ['input', ...args], options);
283
350
  }
284
351
 
352
+ export async function screenshotPng(ctx) {
353
+ return adbExec(ctx, ['-s', ctx.serial, 'exec-out', 'screencap', '-p'], {
354
+ timeoutMs: 15000,
355
+ encoding: 'buffer',
356
+ maxBuffer: 24 * 1024 * 1024
357
+ });
358
+ }
359
+
360
+ function stitchVerticalPngs(buffers, options = {}) {
361
+ const frames = buffers.map((buffer) => decodePng(buffer));
362
+ if (frames.length === 0) throw new Error('longScreenshotBase64 requires at least one screenshot');
363
+ const width = frames[0].width;
364
+ const colorType = frames[0].colorType;
365
+ const bitDepth = frames[0].bitDepth;
366
+ const bytesPerPixel = pngBytesPerPixel(colorType, bitDepth);
367
+ if (bytesPerPixel !== 4) {
368
+ throw new Error(`longScreenshotBase64 only supports RGBA PNG screenshots, got colorType=${colorType} bitDepth=${bitDepth}`);
369
+ }
370
+ for (const frame of frames) {
371
+ if (frame.width !== width || frame.colorType !== colorType || frame.bitDepth !== bitDepth) {
372
+ throw new Error('longScreenshotBase64 screenshots have inconsistent PNG formats');
373
+ }
374
+ }
375
+
376
+ const uniqueFrames = [];
377
+ for (const frame of frames) {
378
+ const previous = uniqueFrames[uniqueFrames.length - 1];
379
+ if (previous && isNearDuplicateFrame(previous, frame, width, bytesPerPixel)) {
380
+ break;
381
+ }
382
+ uniqueFrames.push(frame);
383
+ }
384
+
385
+ const parts = [];
386
+ let totalHeight = 0;
387
+ const topCrop = Math.max(0, Number(options.topCrop || 0));
388
+ const bottomCrop = Math.max(0, Number(options.bottomCrop || 0));
389
+ const maxOutputHeight = Math.max(1, Number(options.maxOutputHeight || Number.MAX_SAFE_INTEGER));
390
+ for (let i = 0; i < uniqueFrames.length; i += 1) {
391
+ const frame = uniqueFrames[i];
392
+ const cropTop = i === 0 ? 0 : Math.min(topCrop, frame.height - 1);
393
+ const cropBottom = i === uniqueFrames.length - 1 ? 0 : Math.min(bottomCrop, frame.height - cropTop - 1);
394
+ let slice = cropFrame(frame, cropTop, cropBottom, bytesPerPixel);
395
+ if (options.dedupeAdjacent !== false && parts.length > 0) {
396
+ slice = removeDuplicateOverlap(parts[parts.length - 1], slice, width, bytesPerPixel);
397
+ }
398
+ if (slice.height <= 0) continue;
399
+ if (totalHeight + slice.height > maxOutputHeight) {
400
+ slice = cropSliceHeight(slice, maxOutputHeight - totalHeight, width, bytesPerPixel);
401
+ }
402
+ if (slice.height <= 0) break;
403
+ parts.push(slice);
404
+ totalHeight += slice.height;
405
+ if (totalHeight >= maxOutputHeight) break;
406
+ }
407
+
408
+ const rgba = Buffer.alloc(width * totalHeight * bytesPerPixel);
409
+ let y = 0;
410
+ for (const part of parts) {
411
+ part.rgba.copy(rgba, y * width * bytesPerPixel);
412
+ y += part.height;
413
+ }
414
+ return encodePng({width, height: totalHeight, rgba});
415
+ }
416
+
417
+ function isNearDuplicateFrame(previous, current, width, bytesPerPixel) {
418
+ if (previous.height !== current.height || previous.width !== current.width) return false;
419
+ const rowBytes = width * bytesPerPixel;
420
+ const top = Math.floor(current.height * 0.18);
421
+ const bottom = Math.floor(current.height * 0.82);
422
+ const start = top * rowBytes;
423
+ const end = Math.max(start, bottom * rowBytes);
424
+ const previousSlice = previous.rgba.subarray(start, end);
425
+ const currentSlice = current.rgba.subarray(start, end);
426
+ return meanAbsoluteRowDifference(previousSlice, currentSlice) < 1.5;
427
+ }
428
+
429
+ function cropFrame(frame, cropTop, cropBottom, bytesPerPixel) {
430
+ const widthBytes = frame.width * bytesPerPixel;
431
+ const height = Math.max(0, frame.height - cropTop - cropBottom);
432
+ const start = cropTop * widthBytes;
433
+ const end = start + height * widthBytes;
434
+ return {
435
+ width: frame.width,
436
+ height,
437
+ rgba: Buffer.from(frame.rgba.subarray(start, end))
438
+ };
439
+ }
440
+
441
+ function cropSliceHeight(slice, height, width, bytesPerPixel) {
442
+ const cleanHeight = Math.max(0, Math.min(slice.height, Number(height || 0)));
443
+ return {
444
+ width: slice.width,
445
+ height: cleanHeight,
446
+ rgba: Buffer.from(slice.rgba.subarray(0, cleanHeight * width * bytesPerPixel))
447
+ };
448
+ }
449
+
450
+ function removeDuplicateOverlap(previous, current, width, bytesPerPixel) {
451
+ if (!previous?.rgba?.length || !current?.rgba?.length) return current;
452
+ const rowBytes = width * bytesPerPixel;
453
+ const maxOverlap = Math.min(previous.height, current.height, Math.floor(current.height * 0.82));
454
+ let overlapRows = 0;
455
+ for (let rows = maxOverlap; rows >= 48; rows -= 8) {
456
+ if (sampledOverlapDifference(previous, current, rows, width, bytesPerPixel) < 5.0) {
457
+ overlapRows = rows;
458
+ break;
459
+ }
460
+ }
461
+ if (overlapRows <= 0) return current;
462
+ return {
463
+ width: current.width,
464
+ height: current.height - overlapRows,
465
+ rgba: Buffer.from(current.rgba.subarray(overlapRows * rowBytes))
466
+ };
467
+ }
468
+
469
+ function sampledOverlapDifference(previous, current, rows, width, bytesPerPixel) {
470
+ const rowBytes = width * bytesPerPixel;
471
+ const sampleRows = Math.min(28, rows);
472
+ const rowStep = Math.max(1, Math.floor(rows / sampleRows));
473
+ const xStart = Math.floor(width * 0.06);
474
+ const xEnd = Math.max(xStart + 1, Math.floor(width * 0.94));
475
+ const xStep = Math.max(1, Math.floor((xEnd - xStart) / 180));
476
+ let diff = 0;
477
+ let count = 0;
478
+ for (let y = 0; y < rows; y += rowStep) {
479
+ const previousRow = previous.height - rows + y;
480
+ const currentRow = y;
481
+ const previousBase = previousRow * rowBytes;
482
+ const currentBase = currentRow * rowBytes;
483
+ for (let x = xStart; x < xEnd; x += xStep) {
484
+ const offset = x * bytesPerPixel;
485
+ diff += Math.abs(previous.rgba[previousBase + offset] - current.rgba[currentBase + offset]);
486
+ diff += Math.abs(previous.rgba[previousBase + offset + 1] - current.rgba[currentBase + offset + 1]);
487
+ diff += Math.abs(previous.rgba[previousBase + offset + 2] - current.rgba[currentBase + offset + 2]);
488
+ count += 3;
489
+ }
490
+ }
491
+ return diff / Math.max(1, count);
492
+ }
493
+
494
+ function meanAbsoluteRowDifference(left, right) {
495
+ const len = Math.min(left.length, right.length);
496
+ if (len <= 0) return Number.POSITIVE_INFINITY;
497
+ let diff = 0;
498
+ const stride = Math.max(1, Math.floor(len / 20000));
499
+ let count = 0;
500
+ for (let i = 0; i < len; i += stride) {
501
+ diff += Math.abs(left[i] - right[i]);
502
+ count += 1;
503
+ }
504
+ return diff / Math.max(1, count);
505
+ }
506
+
507
+ function decodePng(buffer) {
508
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
509
+ if (!Buffer.from(buffer).subarray(0, 8).equals(signature)) throw new Error('invalid PNG signature');
510
+ let offset = 8;
511
+ let width = 0;
512
+ let height = 0;
513
+ let bitDepth = 0;
514
+ let colorType = 0;
515
+ const idat = [];
516
+
517
+ while (offset + 12 <= buffer.length) {
518
+ const length = buffer.readUInt32BE(offset);
519
+ const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
520
+ const data = buffer.subarray(offset + 8, offset + 8 + length);
521
+ offset += 12 + length;
522
+ if (type === 'IHDR') {
523
+ width = data.readUInt32BE(0);
524
+ height = data.readUInt32BE(4);
525
+ bitDepth = data[8];
526
+ colorType = data[9];
527
+ } else if (type === 'IDAT') {
528
+ idat.push(data);
529
+ } else if (type === 'IEND') {
530
+ break;
531
+ }
532
+ }
533
+ if (!width || !height) throw new Error('invalid PNG IHDR');
534
+ const bytesPerPixel = pngBytesPerPixel(colorType, bitDepth);
535
+ const rowBytes = width * bytesPerPixel;
536
+ const inflated = zlib.inflateSync(Buffer.concat(idat));
537
+ const rgba = Buffer.alloc(rowBytes * height);
538
+ let src = 0;
539
+ let dst = 0;
540
+ let previous = Buffer.alloc(rowBytes);
541
+ for (let y = 0; y < height; y += 1) {
542
+ const filter = inflated[src++];
543
+ const row = Buffer.from(inflated.subarray(src, src + rowBytes));
544
+ src += rowBytes;
545
+ unfilterPngRow(row, previous, filter, bytesPerPixel);
546
+ row.copy(rgba, dst);
547
+ dst += rowBytes;
548
+ previous = row;
549
+ }
550
+ return {width, height, bitDepth, colorType, rgba};
551
+ }
552
+
553
+ function encodePng({width, height, rgba}) {
554
+ const rowBytes = width * 4;
555
+ const raw = Buffer.alloc((rowBytes + 1) * height);
556
+ for (let y = 0; y < height; y += 1) {
557
+ const rawOffset = y * (rowBytes + 1);
558
+ raw[rawOffset] = 0;
559
+ rgba.copy(raw, rawOffset + 1, y * rowBytes, (y + 1) * rowBytes);
560
+ }
561
+ const chunks = [
562
+ makePngChunk('IHDR', makeIhdr(width, height)),
563
+ makePngChunk('IDAT', zlib.deflateSync(raw, {level: 6})),
564
+ makePngChunk('IEND', Buffer.alloc(0))
565
+ ];
566
+ return Buffer.concat([
567
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
568
+ ...chunks
569
+ ]);
570
+ }
571
+
572
+ function makeIhdr(width, height) {
573
+ const data = Buffer.alloc(13);
574
+ data.writeUInt32BE(width, 0);
575
+ data.writeUInt32BE(height, 4);
576
+ data[8] = 8;
577
+ data[9] = 6;
578
+ data[10] = 0;
579
+ data[11] = 0;
580
+ data[12] = 0;
581
+ return data;
582
+ }
583
+
584
+ function makePngChunk(type, data) {
585
+ const typeBuffer = Buffer.from(type, 'ascii');
586
+ const length = Buffer.alloc(4);
587
+ length.writeUInt32BE(data.length, 0);
588
+ const crcBuffer = Buffer.alloc(4);
589
+ crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
590
+ return Buffer.concat([length, typeBuffer, data, crcBuffer]);
591
+ }
592
+
593
+ function unfilterPngRow(row, previous, filter, bytesPerPixel) {
594
+ if (filter === 0) return;
595
+ for (let i = 0; i < row.length; i += 1) {
596
+ const left = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0;
597
+ const up = previous[i] || 0;
598
+ const upLeft = i >= bytesPerPixel ? previous[i - bytesPerPixel] || 0 : 0;
599
+ let value = row[i];
600
+ if (filter === 1) value += left;
601
+ else if (filter === 2) value += up;
602
+ else if (filter === 3) value += Math.floor((left + up) / 2);
603
+ else if (filter === 4) value += paethPredictor(left, up, upLeft);
604
+ else throw new Error(`unsupported PNG filter ${filter}`);
605
+ row[i] = value & 0xff;
606
+ }
607
+ }
608
+
609
+ function pngBytesPerPixel(colorType, bitDepth) {
610
+ if (bitDepth !== 8) throw new Error(`unsupported PNG bitDepth ${bitDepth}`);
611
+ if (colorType === 6) return 4;
612
+ if (colorType === 2) return 3;
613
+ if (colorType === 0) return 1;
614
+ throw new Error(`unsupported PNG colorType ${colorType}`);
615
+ }
616
+
617
+ function paethPredictor(a, b, c) {
618
+ const p = a + b - c;
619
+ const pa = Math.abs(p - a);
620
+ const pb = Math.abs(p - b);
621
+ const pc = Math.abs(p - c);
622
+ if (pa <= pb && pa <= pc) return a;
623
+ if (pb <= pc) return b;
624
+ return c;
625
+ }
626
+
627
+ const CRC_TABLE = makeCrcTable();
628
+
629
+ function crc32(buffer) {
630
+ let crc = 0xffffffff;
631
+ for (const byte of buffer) {
632
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
633
+ }
634
+ return (crc ^ 0xffffffff) >>> 0;
635
+ }
636
+
637
+ function makeCrcTable() {
638
+ const table = new Uint32Array(256);
639
+ for (let n = 0; n < 256; n += 1) {
640
+ let c = n;
641
+ for (let k = 0; k < 8; k += 1) {
642
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
643
+ }
644
+ table[n] = c >>> 0;
645
+ }
646
+ return table;
647
+ }
648
+
649
+ function clampInt(value, min, max) {
650
+ const clean = Number.isFinite(value) ? Math.trunc(value) : min;
651
+ return Math.max(min, Math.min(max, clean));
652
+ }
653
+
285
654
  function isAdbOfflineError(error) {
286
655
  const message = String(error?.message || error || '').toLowerCase();
287
656
  return message.includes('device offline') || message.includes('device unauthorized');
@@ -368,6 +737,10 @@ export const Device = {
368
737
  startLauncher,
369
738
  waitForForeground,
370
739
  screenSize,
740
+ screenDensity,
741
+ overrideScreenSize,
742
+ resetScreenSize,
743
+ resetScreenDensity,
371
744
  tapAbsolute,
372
745
  click,
373
746
  move,
@@ -377,7 +750,9 @@ export const Device = {
377
750
  focusedActivity,
378
751
  activityIncludes,
379
752
  waitForActivity,
753
+ screenshotPng,
380
754
  screenshotBase64,
755
+ longScreenshotBase64,
381
756
  saveDebugScreenshot,
382
757
  clearInputByDelete,
383
758
  typeText,
@@ -93,7 +93,12 @@ export async function runScript(ctx, source, options = {}) {
93
93
  maxLines: Number(options.maxLines || 1200),
94
94
  outputPath: options.outputPath || ''
95
95
  });
96
- await recorder.waitForExit(Number(options.timeoutMs || 10000));
96
+ let waitError = null;
97
+ try {
98
+ await recorder.waitForEvent((event) => event?.type !== 'parse_error', Number(options.timeoutMs || 10000));
99
+ } catch (error) {
100
+ waitError = error;
101
+ }
97
102
  const result = await recorder.stop();
98
103
  const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
99
104
  if (scriptEvents.length === 0) {
@@ -102,6 +107,7 @@ export async function runScript(ctx, source, options = {}) {
102
107
  duration: Logger.duration(startedAt),
103
108
  lineCount: result.lines.length
104
109
  });
110
+ if (waitError) throw waitError;
105
111
  throw new CrawlerError({
106
112
  message: `frida_script_failed: ${label} did not emit ${marker.trim()}`,
107
113
  code: Code.FridaUnavailable,
@@ -0,0 +1,188 @@
1
+ import {Jimp, JimpMime, ResizeStrategy} from 'jimp';
2
+
3
+ import {Logger} from '../logger.js';
4
+
5
+ const DEFAULT_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
6
+ const DEFAULT_SCREENSHOT_OUTPUT_TYPE = 'jpeg';
7
+ const DEFAULT_SCREENSHOT_QUALITY = 0.72;
8
+ const DEFAULT_SCREENSHOT_MIN_QUALITY = 0.38;
9
+ const DEFAULT_SCREENSHOT_MIN_SCALE = 0.25;
10
+ const SUPPORTED_SCREENSHOT_OUTPUT_TYPES = new Set(['jpeg']);
11
+
12
+ const toPositiveInteger = (value, fallback = 0) => {
13
+ const number = Math.floor(Number(value) || 0);
14
+ return number > 0 ? number : fallback;
15
+ };
16
+
17
+ const normalizeQuality = (value, fallback) => {
18
+ const number = Number(value);
19
+ if (!Number.isFinite(number) || number <= 0) return fallback;
20
+ const normalized = number > 1 ? number / 100 : number;
21
+ return Math.min(1, Math.max(0.01, normalized));
22
+ };
23
+
24
+ const normalizeScale = (value, fallback) => {
25
+ const number = Number(value);
26
+ if (!Number.isFinite(number) || number <= 0) return fallback;
27
+ return Math.min(1, Math.max(0.05, number));
28
+ };
29
+
30
+ const normalizeScreenshotOutputType = (value) => {
31
+ const raw = String(value || DEFAULT_SCREENSHOT_OUTPUT_TYPE).trim().toLowerCase();
32
+ const normalized = raw === 'jpg' ? 'jpeg' : raw;
33
+ return SUPPORTED_SCREENSHOT_OUTPUT_TYPES.has(normalized)
34
+ ? normalized
35
+ : DEFAULT_SCREENSHOT_OUTPUT_TYPE;
36
+ };
37
+
38
+ const getBase64BytesFromBuffer = (buffer) => Math.ceil(buffer.length / 3) * 4;
39
+
40
+ const toJpegQuality = (value) => Math.round(normalizeQuality(value, DEFAULT_SCREENSHOT_QUALITY) * 100);
41
+
42
+ export const resolveImageCompression = (options = {}) => {
43
+ const explicit = options.compression;
44
+ const source = explicit && typeof explicit === 'object' && !Array.isArray(explicit)
45
+ ? explicit
46
+ : {};
47
+
48
+ const enabled = explicit !== false
49
+ && source.enabled !== false
50
+ && options.compress !== false;
51
+
52
+ const quality = normalizeQuality(
53
+ source.quality ?? options.quality,
54
+ DEFAULT_SCREENSHOT_QUALITY
55
+ );
56
+ const minQuality = Math.min(
57
+ quality,
58
+ normalizeQuality(source.minQuality ?? options.minQuality, DEFAULT_SCREENSHOT_MIN_QUALITY)
59
+ );
60
+
61
+ return {
62
+ enabled,
63
+ maxBytes: toPositiveInteger(
64
+ source.maxBytes
65
+ ?? source.maxBase64Bytes
66
+ ?? options.maxBytes
67
+ ?? options.maxBase64Bytes,
68
+ DEFAULT_SCREENSHOT_MAX_BYTES
69
+ ),
70
+ outputType: normalizeScreenshotOutputType(
71
+ source.type
72
+ ?? source.outputType
73
+ ?? options.type
74
+ ?? options.outputType
75
+ ),
76
+ quality,
77
+ minQuality,
78
+ minScale: normalizeScale(
79
+ source.minScale ?? options.minScale,
80
+ DEFAULT_SCREENSHOT_MIN_SCALE
81
+ ),
82
+ };
83
+ };
84
+
85
+ const encodeJpeg = async (sourceImage, compression, scale, quality) => {
86
+ const width = Math.max(1, Math.round(sourceImage.bitmap.width * scale));
87
+ const height = Math.max(1, Math.round(sourceImage.bitmap.height * scale));
88
+ const image = sourceImage.clone();
89
+
90
+ if (scale < 0.999) {
91
+ image.resize({
92
+ w: width,
93
+ h: height,
94
+ mode: ResizeStrategy.BILINEAR,
95
+ });
96
+ }
97
+
98
+ const buffer = await image.getBuffer(JimpMime.jpeg, {quality});
99
+ return {
100
+ buffer,
101
+ bytes: getBase64BytesFromBuffer(buffer),
102
+ width,
103
+ height,
104
+ quality,
105
+ scale: Number(scale.toFixed(3)),
106
+ format: compression.outputType,
107
+ };
108
+ };
109
+
110
+ const compressImageBuffer = async (buffer, compression) => {
111
+ const sourceImage = await Jimp.read(buffer);
112
+ const maxQuality = toJpegQuality(compression.quality);
113
+ const minQuality = Math.min(maxQuality, toJpegQuality(compression.minQuality));
114
+ let quality = maxQuality;
115
+ let scale = 1;
116
+ let smallest = null;
117
+
118
+ for (let attempt = 0; attempt < 12; attempt += 1) {
119
+ const candidate = await encodeJpeg(sourceImage, compression, scale, quality);
120
+ if (!smallest || candidate.bytes < smallest.bytes) {
121
+ smallest = candidate;
122
+ }
123
+ if (candidate.bytes <= compression.maxBytes) {
124
+ return {...candidate, withinLimit: true};
125
+ }
126
+
127
+ if (quality > minQuality) {
128
+ quality = Math.max(minQuality, Math.floor(quality * 0.75));
129
+ continue;
130
+ }
131
+
132
+ const ratio = Math.sqrt(compression.maxBytes / Math.max(1, candidate.bytes));
133
+ const nextScale = Math.max(
134
+ compression.minScale,
135
+ Math.min(scale * 0.85, scale * ratio * 0.94)
136
+ );
137
+
138
+ if (nextScale >= scale * 0.99 || scale <= compression.minScale) {
139
+ break;
140
+ }
141
+
142
+ scale = nextScale;
143
+ }
144
+
145
+ const finalCandidate = await encodeJpeg(sourceImage, compression, compression.minScale, minQuality);
146
+ const fallback = !smallest || finalCandidate.bytes < smallest.bytes
147
+ ? finalCandidate
148
+ : smallest;
149
+ return {...fallback, withinLimit: fallback.bytes <= compression.maxBytes};
150
+ };
151
+
152
+ export const compressImageBufferToBase64 = async (buffer, compression) => {
153
+ const originalBytes = getBase64BytesFromBuffer(buffer);
154
+ if (!compression.enabled || originalBytes <= compression.maxBytes) {
155
+ return buffer.toString('base64');
156
+ }
157
+
158
+ const result = await compressImageBuffer(buffer, compression).catch((error) => {
159
+ Logger.warn('captureScreen 压缩失败,返回原图', {message: error?.message || String(error)});
160
+ return null;
161
+ });
162
+
163
+ if (!result?.buffer) {
164
+ return buffer.toString('base64');
165
+ }
166
+
167
+ if (result.withinLimit) {
168
+ Logger.info('captureScreen 已压缩', {
169
+ originalBytes,
170
+ outputBytes: result.bytes,
171
+ format: result.format,
172
+ quality: result.quality,
173
+ scale: result.scale,
174
+ size: `${result.width}x${result.height}`,
175
+ });
176
+ } else {
177
+ Logger.warn('captureScreen 压缩后仍超过目标', {
178
+ originalBytes,
179
+ outputBytes: result.bytes,
180
+ maxBytes: compression.maxBytes,
181
+ format: result.format,
182
+ quality: result.quality,
183
+ scale: result.scale,
184
+ });
185
+ }
186
+
187
+ return result.buffer.toString('base64');
188
+ };
package/src/launch.js CHANGED
@@ -5,6 +5,7 @@ import {createAndroidContext} from './context.js';
5
5
  import {Device} from './device.js';
6
6
  import {Frida} from './frida-client.js';
7
7
  import {Logger} from './logger.js';
8
+ import {Share} from './share.js';
8
9
 
9
10
  const DEFAULT_INPUT_PATH = '/apify_storage/input.json';
10
11
  const DEFAULT_OUTPUT_PATH = '/apify_storage/output.json';
@@ -38,6 +39,7 @@ export const Launch = {
38
39
  ApifyKit: apifyKit,
39
40
  Device,
40
41
  Frida,
42
+ Share,
41
43
  Logger
42
44
  };
43
45