Skip to content

Commit 47b7135

Browse files
committed
feat(core): make screenshot lifecycle format-aware
1 parent b730930 commit 47b7135

12 files changed

Lines changed: 282 additions & 110 deletions

packages/core/src/agent/utils.ts

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ import {
2323
} from '@midscene/shared/env';
2424
import { generateElementByRect } from '@midscene/shared/extractor';
2525
import {
26-
convertImgBufferToJpeg,
27-
createImgBase64ByFormat,
26+
canonicalizeScreenshotBase64,
2827
imageInfoOfBase64,
29-
parseBase64,
28+
normalizeBase64Image,
3029
resizeImgBase64,
3130
} from '@midscene/shared/img';
3231
import { getDebug } from '@midscene/shared/logger';
@@ -37,28 +36,6 @@ import type { TaskCache } from './task-cache';
3736
import { debug as cacheDebug } from './task-cache';
3837

3938
const agentDebug = getDebug('agent');
40-
const screenshotDataUrlPattern = /^data:image\/[a-zA-Z0-9.+-]+;base64,/i;
41-
42-
const inferBase64ImageFormat = (base64Body: string) => {
43-
if (base64Body.startsWith('iVBORw0KGgo')) {
44-
return 'png';
45-
}
46-
return 'jpeg';
47-
};
48-
49-
const normalizeScreenshotBase64 = (screenshotBase64: string) => {
50-
const trimmedBase64 = screenshotBase64.trim();
51-
if (screenshotDataUrlPattern.test(trimmedBase64)) {
52-
return trimmedBase64;
53-
}
54-
55-
const base64Body = trimmedBase64.replace(/\s/g, '');
56-
assert(base64Body, 'screenshotBase64 must include image data');
57-
return createImgBase64ByFormat(
58-
inferBase64ImageFormat(base64Body),
59-
base64Body,
60-
);
61-
};
6239

6340
const legacyScrollTypeMap = {
6441
once: 'singleAction',
@@ -203,23 +180,13 @@ export async function commonContextParser(
203180
shrunkShotToLogicalRatio,
204181
};
205182
} else {
206-
// For screenshots that do not need shrinking, convert PNG to JPEG to reduce the image payload in model requests and reports. (Shrunk images are already JPEG.)
207-
// This mainly covers Android's default screenshot path, which produces PNG screenshots.
208-
// Compared with conversion on Android, centralizing it here means each platform does not need to handle screenshot formats itself, and allows future output formats such as WebP.
209-
// Built-in paths that already output JPEG are unaffected, and custom devices that output JPEG will not be compressed again.
210-
// The Web platform already outputs JPEG, so it does not enter this branch. Other built-in device platforms run in Node, where Sharp conversion is fast enough that its extra cost is negligible.
211-
let outputScreenshotBase64 = screenshotBase64;
212-
const { mimeType, body } = parseBase64(screenshotBase64);
213-
if (mimeType.toLowerCase() === 'image/png') {
214-
const jpegBuffer = await convertImgBufferToJpeg(
215-
Buffer.from(body, 'base64'),
216-
90,
217-
);
218-
outputScreenshotBase64 = createImgBase64ByFormat(
219-
'jpeg',
220-
jpegBuffer.toString('base64'),
221-
);
222-
}
183+
// PNG/raw producers are encoded once as WebP before the ScreenshotItem is
184+
// shared by model requests and reports. Native JPEG sources are preserved
185+
// to avoid a second lossy encode for MJPEG/HDC frames.
186+
const outputScreenshotBase64 = await canonicalizeScreenshotBase64(
187+
screenshotBase64,
188+
{ preserveJpeg: true },
189+
);
223190

224191
return {
225192
shotSize: {
@@ -242,10 +209,13 @@ export async function createScreenshotBoundUIContext(
242209
screenshotSize?: Size;
243210
},
244211
): Promise<UIContext> {
245-
const normalizedScreenshotBase64 =
246-
normalizeScreenshotBase64(screenshotBase64);
247-
const actualScreenshotSize = await imageInfoOfBase64(
212+
const normalizedScreenshotBase64 = normalizeBase64Image(screenshotBase64);
213+
const canonicalScreenshotBase64 = await canonicalizeScreenshotBase64(
248214
normalizedScreenshotBase64,
215+
{ preserveJpeg: true },
216+
);
217+
const actualScreenshotSize = await imageInfoOfBase64(
218+
canonicalScreenshotBase64,
249219
);
250220
if (
251221
opt.screenshotSize &&
@@ -262,7 +232,7 @@ export async function createScreenshotBoundUIContext(
262232
}
263233

264234
return {
265-
screenshot: ScreenshotItem.create(normalizedScreenshotBase64, Date.now()),
235+
screenshot: ScreenshotItem.create(canonicalScreenshotBase64, Date.now()),
266236
shotSize: actualScreenshotSize,
267237
shrunkShotToLogicalRatio: 1,
268238
_isFrozen: true,

packages/core/src/dump/report-action-dump.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import {
66
writeFileSync,
77
} from 'node:fs';
88
import { join } from 'node:path';
9+
import {
10+
screenshotImageExtension,
11+
screenshotImageFormatFromMimeType,
12+
} from '@midscene/shared/img/image-format';
913
import { ScreenshotItem } from '../screenshot-item';
1014
import type {
1115
ExecutionTask,
1216
IExecutionDump,
1317
IReportActionDump,
1418
} from '../types';
1519
import { restoreImageReferences } from './screenshot-restoration';
16-
import { ScreenshotStore } from './screenshot-store';
20+
import { type ScreenshotRef, ScreenshotStore } from './screenshot-store';
1721

1822
/**
1923
* Replacer function for JSON serialization that handles Page, Browser objects and ScreenshotItem
@@ -253,10 +257,10 @@ export class ReportActionDump implements IReportActionDump {
253257
}
254258

255259
/**
256-
* Serialize the dump to files with screenshots as separate PNG files.
260+
* Serialize the dump to files with screenshots as separate image files.
257261
* Creates:
258262
* - {basePath} - dump JSON with { $screenshot: id } references
259-
* - {basePath}.screenshots/ - PNG files
263+
* - {basePath}.screenshots/ - screenshot image files
260264
*
261265
* @param basePath - Base path for the dump file
262266
*/
@@ -296,14 +300,21 @@ export class ReportActionDump implements IReportActionDump {
296300
const dumpString = readFileSync(basePath, 'utf-8');
297301
const screenshotsDir = `${basePath}.screenshots`;
298302

299-
const loadFromExecutionScreenshotDir = (id: string, mimeType: string) => {
300-
const ext = mimeType === 'image/jpeg' ? 'jpeg' : 'png';
303+
const loadFromExecutionScreenshotDir = (
304+
id: string,
305+
mimeType: ScreenshotRef['mimeType'],
306+
) => {
307+
const format = screenshotImageFormatFromMimeType(mimeType);
308+
if (!format) {
309+
throw new Error(`Unsupported screenshot mime type: ${mimeType}`);
310+
}
311+
const ext = screenshotImageExtension(format);
301312
const filePath = join(screenshotsDir, `${id}.${ext}`);
302313
if (!existsSync(filePath)) {
303314
return '';
304315
}
305316
const data = readFileSync(filePath);
306-
return `data:image/${ext};base64,${data.toString('base64')}`;
317+
return `data:${mimeType};base64,${data.toString('base64')}`;
307318
};
308319

309320
// Restore image references

packages/core/src/dump/screenshot-store.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
22
import { writeFile as writeFileAsync } from 'node:fs/promises';
33
import { dirname, isAbsolute, join } from 'node:path';
4+
import {
5+
type ScreenshotImageMimeType,
6+
isScreenshotImageMimeType,
7+
screenshotImageExtension,
8+
screenshotImageFormatFromMimeType,
9+
} from '@midscene/shared/img/image-format';
410
import type { ScreenshotItem } from '../screenshot-item';
511
import { extractImageByIdSync } from './html-utils';
612

713
export interface ScreenshotRef {
814
type: 'midscene_screenshot_ref';
915
id: string;
1016
capturedAt: number;
11-
mimeType: 'image/png' | 'image/jpeg';
17+
mimeType: ScreenshotImageMimeType;
1218
storage: 'inline' | 'file';
1319
path?: string;
1420
}
@@ -22,7 +28,7 @@ export function normalizeScreenshotRef(value: unknown): ScreenshotRef | null {
2228
typeof record.id === 'string' &&
2329
typeof record.capturedAt === 'number' &&
2430
(record.storage === 'inline' || record.storage === 'file') &&
25-
(record.mimeType === 'image/png' || record.mimeType === 'image/jpeg')
31+
isScreenshotImageMimeType(record.mimeType)
2632
) {
2733
if (record.storage === 'file' && typeof record.path !== 'string') {
2834
return null;
@@ -48,7 +54,11 @@ type ResolvedScreenshotSource =
4854
};
4955

5056
function extensionByMimeType(mimeType: ScreenshotRef['mimeType']): string {
51-
return mimeType === 'image/jpeg' ? 'jpeg' : 'png';
57+
const format = screenshotImageFormatFromMimeType(mimeType);
58+
if (!format) {
59+
throw new Error(`Unsupported screenshot mime type: ${mimeType}`);
60+
}
61+
return screenshotImageExtension(format);
5262
}
5363

5464
export function resolveScreenshotSource(

packages/core/src/screenshot-item.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
import { readFileSync } from 'node:fs';
2+
import {
3+
type ScreenshotImageFormat,
4+
type ScreenshotImageMimeType,
5+
inferScreenshotImageFormatFromBase64,
6+
screenshotImageExtension,
7+
screenshotImageFormatFromMimeType,
8+
screenshotImageMimeType,
9+
} from '@midscene/shared/img/image-format';
210
import { uuid } from '@midscene/shared/utils';
311
import { extractImageByIdSync } from './dump/html-utils';
412
import {
@@ -13,13 +21,32 @@ import {
1321
*/
1422
export type ScreenshotSerializeFormat = ScreenshotRef;
1523

24+
const BASE64_SEPARATOR = ';base64,';
25+
1626
/**
17-
* Detect image format from base64 data URI prefix.
27+
* Detect image format from a data URI or raw base64 body.
1828
*/
19-
function detectFormat(base64: string): 'png' | 'jpeg' {
20-
if (base64.startsWith('data:image/jpeg')) return 'jpeg';
21-
if (base64.startsWith('data:image/jpg')) return 'jpeg';
22-
return 'png';
29+
function detectFormat(base64: string): ScreenshotImageFormat {
30+
const separatorIndex = base64.indexOf(BASE64_SEPARATOR);
31+
const mimeType =
32+
separatorIndex === -1 ? undefined : base64.slice(5, separatorIndex);
33+
const detectedFormat =
34+
separatorIndex === -1
35+
? inferScreenshotImageFormatFromBase64(base64)
36+
: screenshotImageFormatFromMimeType(mimeType);
37+
38+
// Before WebP support, every non-JPEG value used PNG metadata. Preserve that
39+
// behavior for temporary and test placeholders while detecting valid images.
40+
return detectedFormat ?? 'png';
41+
}
42+
43+
function rawBase64Body(base64: string): string {
44+
const separatorIndex = base64.indexOf(BASE64_SEPARATOR);
45+
const body =
46+
separatorIndex === -1
47+
? base64
48+
: base64.slice(separatorIndex + BASE64_SEPARATOR.length);
49+
return body.replace(/\s/g, '');
2350
}
2451

2552
/**
@@ -35,7 +62,7 @@ function detectFormat(base64: string): 'png' | 'jpeg' {
3562
export class ScreenshotItem {
3663
private _id: string;
3764
private _base64: string | null;
38-
private _format: 'png' | 'jpeg';
65+
private _format: ScreenshotImageFormat;
3966
private _capturedAt: number;
4067
private _serializedRef: ScreenshotRef | null = null;
4168
private _persistedPath: string | null = null;
@@ -57,14 +84,19 @@ export class ScreenshotItem {
5784
return this._id;
5885
}
5986

60-
/** Get the image format (png or jpeg) */
61-
get format(): 'png' | 'jpeg' {
87+
/** Get the image format (PNG, JPEG, or WebP). */
88+
get format(): ScreenshotImageFormat {
6289
return this._format;
6390
}
6491

6592
/** Get the file extension for this screenshot */
66-
get extension(): string {
67-
return this._format === 'jpeg' ? 'jpeg' : 'png';
93+
get extension(): ScreenshotImageFormat {
94+
return screenshotImageExtension(this._format);
95+
}
96+
97+
/** Get the MIME type for this screenshot. */
98+
get mimeType(): ScreenshotImageMimeType {
99+
return screenshotImageMimeType(this._format);
68100
}
69101

70102
/** Get screenshot capture timestamp in milliseconds */
@@ -83,7 +115,7 @@ export class ScreenshotItem {
83115
throw new Error(`Screenshot ${this._id}: file recovery path missing`);
84116
}
85117
const buffer = readFileSync(this._persistedPath);
86-
return `data:image/${this._format};base64,${buffer.toString('base64')}`;
118+
return `data:${this.mimeType};base64,${buffer.toString('base64')}`;
87119
};
88120

89121
const loadFromInline = (): string => {
@@ -176,7 +208,7 @@ export class ScreenshotItem {
176208
type: 'midscene_screenshot_ref',
177209
id: this._id,
178210
capturedAt: this._capturedAt,
179-
mimeType: this._format === 'jpeg' ? 'image/jpeg' : 'image/png',
211+
mimeType: this.mimeType,
180212
storage: 'inline',
181213
}
182214
);
@@ -195,7 +227,7 @@ export class ScreenshotItem {
195227
type: 'midscene_screenshot_ref',
196228
id: this._id,
197229
capturedAt: this._capturedAt,
198-
mimeType: this._format === 'jpeg' ? 'image/jpeg' : 'image/png',
230+
mimeType: this.mimeType,
199231
storage,
200232
};
201233
if (storage === 'file') {
@@ -213,6 +245,6 @@ export class ScreenshotItem {
213245
* Useful for writing raw binary data to files.
214246
*/
215247
get rawBase64(): string {
216-
return this.base64.replace(/^data:image\/(png|jpeg|jpg);base64,/, '');
248+
return rawBase64Body(this.base64);
217249
}
218250
}

packages/core/src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ export interface ExecutionRecorderItem {
489489

490490
export interface RecordToReportScreenshot {
491491
/**
492-
* PNG/JPEG data URI, or raw PNG base64 body.
492+
* PNG/JPEG/WebP data URI, or raw PNG/WebP base64 body.
493493
*/
494494
base64: string;
495495
description?: string;
@@ -740,7 +740,7 @@ export type ExecutionTaskPlanningLocate =
740740
/*
741741
How a report file stores screenshots:
742742
- `inline`: base64 image script tags embedded in the single HTML file
743-
- `directory`: external PNG files under a sibling `screenshots/` dir
743+
- `directory`: external image files under a sibling `screenshots/` dir
744744
*/
745745
export type ScreenshotMode = 'inline' | 'directory';
746746

@@ -916,7 +916,7 @@ export interface AgentOpt {
916916
* Use directory-based report format with separate image files.
917917
*
918918
* When enabled:
919-
* - Screenshots are saved as PNG files in a `screenshots/` subdirectory
919+
* - Screenshots retain their image format in a `screenshots/` subdirectory
920920
* - Report is generated as `index.html` with relative image paths
921921
* - Reduces memory usage and report file size
922922
*

packages/core/tests/unit-test/agent-describe-element.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ describe('element describer utils', () => {
424424

425425
const describeContext = describe.mock.calls[0][2]?.context;
426426
expect(describeContext?.screenshot.base64).toMatch(
427-
/^data:image\/png;base64,/,
427+
/^data:image\/webp;base64,UklGR/,
428428
);
429429
expect(describeContext?.shotSize).toEqual(fixtureScreenshotSize);
430430

packages/core/tests/unit-test/agent-dump-update.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ describe('Agent dump update screenshot serialization', () => {
255255
screenshots: [{ base64: 'data:image/svg+xml;base64,custom' }],
256256
}),
257257
).rejects.toThrow(
258-
'recordToReport: screenshot #1 base64 must be a PNG/JPEG data URI or raw PNG base64 string',
258+
'recordToReport: screenshot #1 base64 must be a PNG/JPEG/WebP data URI or raw PNG/WebP base64 string',
259259
);
260260

261261
expect(screenshotBase64).not.toHaveBeenCalled();

packages/core/tests/unit-test/common-context-parser-orientation.test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
44

55
// Mock imageInfoOfBase64 to control screenshot dimensions
66
vi.mock('@midscene/shared/img', () => ({
7-
convertImgBufferToJpeg: vi.fn(),
8-
createImgBase64ByFormat: vi.fn(),
7+
canonicalizeScreenshotBase64: vi.fn().mockResolvedValue('mock-base64-data'),
98
imageInfoOfBase64: vi.fn(),
10-
parseBase64: vi.fn(() => ({
11-
mimeType: 'image/jpeg',
12-
body: 'mock-base64-data',
13-
})),
149
resizeImgBase64: vi.fn().mockResolvedValue('mock-resized-base64-data'),
1510
}));
1611

0 commit comments

Comments
 (0)