@midscene/shared 1.10.5 → 1.10.6-beta-20260717061640.0
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/es/env/constants.mjs +9 -5
- package/dist/es/env/parse-model-config.mjs +7 -1
- package/dist/es/env/types.mjs +7 -1
- package/dist/es/img/box-select.mjs +46 -1
- package/dist/es/img/get-photon.mjs +3 -6
- package/dist/es/img/info.mjs +19 -1
- package/dist/es/img/transform.mjs +56 -6
- package/dist/es/logger.mjs +23 -2
- package/dist/es/zod-schema-utils.mjs +0 -1
- package/dist/lib/env/constants.js +8 -4
- package/dist/lib/env/parse-model-config.js +7 -1
- package/dist/lib/env/types.js +15 -0
- package/dist/lib/img/box-select.js +46 -0
- package/dist/lib/img/get-photon.js +2 -5
- package/dist/lib/img/info.js +20 -1
- package/dist/lib/img/transform.js +56 -6
- package/dist/lib/logger.js +23 -2
- package/dist/lib/zod-schema-utils.js +0 -1
- package/dist/types/env/constants.d.ts +1 -0
- package/dist/types/env/types.d.ts +14 -2
- package/dist/types/img/get-photon.d.ts +13 -13
- package/dist/types/img/transform.d.ts +1 -1
- package/dist/types/recorder.d.ts +14 -0
- package/dist/types/zod-schema-utils.d.ts +1 -3
- package/package.json +1 -2
- package/src/env/constants.ts +8 -0
- package/src/env/parse-model-config.ts +8 -0
- package/src/env/types.ts +18 -0
- package/src/img/box-select.ts +92 -2
- package/src/img/get-photon.ts +17 -25
- package/src/img/info.ts +21 -1
- package/src/img/transform.ts +112 -70
- package/src/logger.ts +38 -3
- package/src/recorder.ts +15 -0
- package/src/zod-schema-utils.ts +1 -8
package/src/img/box-select.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import type { PhotonImage as PhotonImageType } from '@silvia-odwyer/photon
|
|
2
|
+
import type { PhotonImage as PhotonImageType } from '@silvia-odwyer/photon';
|
|
3
3
|
import { NodeType } from '../constants';
|
|
4
4
|
import type { BaseElement, Rect } from '../types';
|
|
5
|
+
import { ifInNode } from '../utils';
|
|
5
6
|
import getPhoton from './get-photon';
|
|
6
|
-
import
|
|
7
|
+
import getSharp from './get-sharp';
|
|
8
|
+
import {
|
|
9
|
+
createImgBase64ByFormat,
|
|
10
|
+
parseBase64,
|
|
11
|
+
photonFromBase64,
|
|
12
|
+
photonToBase64,
|
|
13
|
+
} from './transform';
|
|
7
14
|
|
|
8
15
|
// Simple 5x7 bitmap font for digits 0-9
|
|
9
16
|
const DIGIT_FONT: Record<string, number[][]> = {
|
|
@@ -715,6 +722,47 @@ const createSvgOverlay = async (
|
|
|
715
722
|
return overlayPixels;
|
|
716
723
|
};
|
|
717
724
|
|
|
725
|
+
async function decodeImageWithSharp(
|
|
726
|
+
inputImgBase64: string,
|
|
727
|
+
size?: { width: number; height: number },
|
|
728
|
+
) {
|
|
729
|
+
const { body } = parseBase64(inputImgBase64);
|
|
730
|
+
const Sharp = await getSharp();
|
|
731
|
+
let pipeline = Sharp(Buffer.from(body, 'base64'));
|
|
732
|
+
if (size) {
|
|
733
|
+
pipeline = pipeline.resize(size.width, size.height, {
|
|
734
|
+
fit: 'fill',
|
|
735
|
+
kernel: 'nearest',
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
const { data, info } = await pipeline
|
|
739
|
+
.ensureAlpha()
|
|
740
|
+
.raw()
|
|
741
|
+
.toBuffer({ resolveWithObject: true });
|
|
742
|
+
if (!info.width || !info.height || info.channels !== 4) {
|
|
743
|
+
throw new Error('Image processing failed to produce RGBA pixels');
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
pixels: new Uint8Array(data),
|
|
747
|
+
width: info.width,
|
|
748
|
+
height: info.height,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function encodeRgbaWithSharp(
|
|
753
|
+
pixels: Uint8Array,
|
|
754
|
+
width: number,
|
|
755
|
+
height: number,
|
|
756
|
+
) {
|
|
757
|
+
const Sharp = await getSharp();
|
|
758
|
+
const output = await Sharp(Buffer.from(pixels), {
|
|
759
|
+
raw: { width, height, channels: 4 },
|
|
760
|
+
})
|
|
761
|
+
.jpeg({ quality: 90, chromaSubsampling: '4:4:4' })
|
|
762
|
+
.toBuffer();
|
|
763
|
+
return createImgBase64ByFormat('jpeg', output.toString('base64'));
|
|
764
|
+
}
|
|
765
|
+
|
|
718
766
|
export const compositeElementInfoImg = async (options: {
|
|
719
767
|
inputImgBase64: string;
|
|
720
768
|
elementsPositionInfo: Array<ElementForOverlay>;
|
|
@@ -725,6 +773,27 @@ export const compositeElementInfoImg = async (options: {
|
|
|
725
773
|
prompt?: string;
|
|
726
774
|
}) => {
|
|
727
775
|
assert(options.inputImgBase64, 'inputImgBase64 is required');
|
|
776
|
+
if (ifInNode) {
|
|
777
|
+
const { pixels, width, height } = await decodeImageWithSharp(
|
|
778
|
+
options.inputImgBase64,
|
|
779
|
+
options.size,
|
|
780
|
+
);
|
|
781
|
+
const overlayPixels = await createSvgOverlay(
|
|
782
|
+
options.elementsPositionInfo,
|
|
783
|
+
width,
|
|
784
|
+
height,
|
|
785
|
+
options.annotationPadding,
|
|
786
|
+
options.borderThickness,
|
|
787
|
+
options.prompt,
|
|
788
|
+
options.centerPoint,
|
|
789
|
+
);
|
|
790
|
+
return encodeRgbaWithSharp(
|
|
791
|
+
blendPixels(pixels, overlayPixels, width, height),
|
|
792
|
+
width,
|
|
793
|
+
height,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
728
797
|
const { PhotonImage, SamplingFilter, resize } = await getPhoton();
|
|
729
798
|
|
|
730
799
|
let width = 0;
|
|
@@ -800,6 +869,27 @@ export const compositePointMarkerImg = async (options: {
|
|
|
800
869
|
indexId?: number;
|
|
801
870
|
}) => {
|
|
802
871
|
assert(options.inputImgBase64, 'inputImgBase64 is required');
|
|
872
|
+
if (ifInNode) {
|
|
873
|
+
const { pixels, width, height } = await decodeImageWithSharp(
|
|
874
|
+
options.inputImgBase64,
|
|
875
|
+
options.size,
|
|
876
|
+
);
|
|
877
|
+
const overlayPixels = new Uint8Array(width * height * 4);
|
|
878
|
+
drawPointMarker(
|
|
879
|
+
overlayPixels,
|
|
880
|
+
width,
|
|
881
|
+
height,
|
|
882
|
+
options.point,
|
|
883
|
+
options.radius ?? 14,
|
|
884
|
+
options.indexId ?? 1,
|
|
885
|
+
);
|
|
886
|
+
return encodeRgbaWithSharp(
|
|
887
|
+
blendPixels(pixels, overlayPixels, width, height),
|
|
888
|
+
width,
|
|
889
|
+
height,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
803
893
|
const { PhotonImage, SamplingFilter, resize } = await getPhoton();
|
|
804
894
|
|
|
805
895
|
let width = 0;
|
package/src/img/get-photon.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDebug } from '../logger';
|
|
2
|
-
import { ifInBrowser,
|
|
2
|
+
import { ifInBrowser, ifInWorker } from '../utils';
|
|
3
3
|
|
|
4
4
|
const debug = getDebug('img');
|
|
5
5
|
|
|
@@ -8,31 +8,25 @@ let isInitialized = false;
|
|
|
8
8
|
let usingCanvasFallback = false;
|
|
9
9
|
|
|
10
10
|
export default async function getPhoton(): Promise<{
|
|
11
|
-
PhotonImage: typeof import('@silvia-odwyer/photon
|
|
12
|
-
SamplingFilter: typeof import('@silvia-odwyer/photon
|
|
13
|
-
resize: typeof import('@silvia-odwyer/photon
|
|
14
|
-
crop: typeof import('@silvia-odwyer/photon
|
|
15
|
-
open_image: typeof import('@silvia-odwyer/photon
|
|
16
|
-
base64_to_image: typeof import('@silvia-odwyer/photon
|
|
17
|
-
padding_uniform: typeof import('@silvia-odwyer/photon
|
|
18
|
-
padding_left: typeof import('@silvia-odwyer/photon
|
|
19
|
-
padding_right: typeof import('@silvia-odwyer/photon
|
|
20
|
-
padding_top: typeof import('@silvia-odwyer/photon
|
|
21
|
-
padding_bottom: typeof import('@silvia-odwyer/photon
|
|
22
|
-
watermark: typeof import('@silvia-odwyer/photon
|
|
23
|
-
Rgba: typeof import('@silvia-odwyer/photon
|
|
11
|
+
PhotonImage: typeof import('@silvia-odwyer/photon').PhotonImage;
|
|
12
|
+
SamplingFilter: typeof import('@silvia-odwyer/photon').SamplingFilter;
|
|
13
|
+
resize: typeof import('@silvia-odwyer/photon').resize;
|
|
14
|
+
crop: typeof import('@silvia-odwyer/photon').crop;
|
|
15
|
+
open_image: typeof import('@silvia-odwyer/photon').open_image;
|
|
16
|
+
base64_to_image: typeof import('@silvia-odwyer/photon').base64_to_image;
|
|
17
|
+
padding_uniform: typeof import('@silvia-odwyer/photon').padding_uniform;
|
|
18
|
+
padding_left: typeof import('@silvia-odwyer/photon').padding_left;
|
|
19
|
+
padding_right: typeof import('@silvia-odwyer/photon').padding_right;
|
|
20
|
+
padding_top: typeof import('@silvia-odwyer/photon').padding_top;
|
|
21
|
+
padding_bottom: typeof import('@silvia-odwyer/photon').padding_bottom;
|
|
22
|
+
watermark: typeof import('@silvia-odwyer/photon').watermark;
|
|
23
|
+
Rgba: typeof import('@silvia-odwyer/photon').Rgba;
|
|
24
24
|
}> {
|
|
25
25
|
if (photonModule && isInitialized) {
|
|
26
26
|
return photonModule;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const env = ifInBrowser
|
|
30
|
-
? 'browser'
|
|
31
|
-
: ifInWorker
|
|
32
|
-
? 'worker'
|
|
33
|
-
: ifInNode
|
|
34
|
-
? 'node'
|
|
35
|
-
: 'unknown';
|
|
29
|
+
const env = ifInBrowser ? 'browser' : ifInWorker ? 'worker' : 'unknown';
|
|
36
30
|
debug(`Loading photon module in ${env} environment`);
|
|
37
31
|
|
|
38
32
|
// Try to load Photon first
|
|
@@ -46,10 +40,8 @@ export default async function getPhoton(): Promise<{
|
|
|
46
40
|
}
|
|
47
41
|
debug('Photon loaded: @silvia-odwyer/photon (browser/worker)');
|
|
48
42
|
photonModule = photon;
|
|
49
|
-
} else
|
|
50
|
-
|
|
51
|
-
photonModule = await import('@silvia-odwyer/photon-node');
|
|
52
|
-
debug('Photon loaded: @silvia-odwyer/photon-node (node)');
|
|
43
|
+
} else {
|
|
44
|
+
throw new Error('Photon is only available in browser environments');
|
|
53
45
|
}
|
|
54
46
|
|
|
55
47
|
// verify that the critical functions exist (only for Photon, not Canvas fallback)
|
package/src/img/info.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
import type { Size } from '../types';
|
|
4
|
+
import { ifInNode } from '../utils';
|
|
4
5
|
import getPhoton from './get-photon';
|
|
6
|
+
import getSharp from './get-sharp';
|
|
5
7
|
|
|
6
8
|
export interface ImageInfo extends Size {}
|
|
7
9
|
|
|
@@ -15,7 +17,6 @@ export interface ImageInfo extends Size {}
|
|
|
15
17
|
export async function imageInfoOfBase64(
|
|
16
18
|
imageBase64: string,
|
|
17
19
|
): Promise<ImageInfo> {
|
|
18
|
-
const { PhotonImage } = await getPhoton();
|
|
19
20
|
const base64Data = imageBase64
|
|
20
21
|
.replace(/^data:image\/\w+;base64,/, '')
|
|
21
22
|
.replace(/\s/g, '');
|
|
@@ -26,6 +27,25 @@ export async function imageInfoOfBase64(
|
|
|
26
27
|
);
|
|
27
28
|
const imageBuffer = Buffer.from(base64Data, 'base64');
|
|
28
29
|
assert(isValidImageBuffer(imageBuffer), 'Invalid image: unsupported format');
|
|
30
|
+
if (ifInNode) {
|
|
31
|
+
let metadata;
|
|
32
|
+
try {
|
|
33
|
+
const Sharp = await getSharp();
|
|
34
|
+
metadata = await Sharp(imageBuffer).metadata();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid image: failed to decode base64 data (${error instanceof Error ? error.message : String(error)})`,
|
|
38
|
+
{ cause: error },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
assert(
|
|
42
|
+
metadata.width && metadata.height,
|
|
43
|
+
'Invalid image: cannot get width or height',
|
|
44
|
+
);
|
|
45
|
+
return { width: metadata.width, height: metadata.height };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { PhotonImage } = await getPhoton();
|
|
29
49
|
// Support both sync (Photon) and async (Canvas fallback) versions
|
|
30
50
|
let result: ReturnType<typeof PhotonImage.new_from_base64>;
|
|
31
51
|
try {
|
package/src/img/transform.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import type { PhotonImage as PhotonImageType } from '@silvia-odwyer/photon
|
|
6
|
+
import type { PhotonImage as PhotonImageType } from '@silvia-odwyer/photon';
|
|
7
7
|
import { getDebug } from '../logger';
|
|
8
8
|
import type { Rect } from '../types';
|
|
9
9
|
import { ifInNode } from '../utils';
|
|
@@ -61,44 +61,36 @@ export async function resizeAndConvertImgBuffer(
|
|
|
61
61
|
imgDebug(`resizeImg start, target size: ${newSize.width}x${newSize.height}`);
|
|
62
62
|
|
|
63
63
|
if (ifInNode) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const metadata = await Sharp(inputData).metadata();
|
|
68
|
-
const { width: originalWidth, height: originalHeight } = metadata;
|
|
69
|
-
|
|
70
|
-
if (!originalWidth || !originalHeight) {
|
|
71
|
-
throw Error('Undefined width or height from the input image.');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
newSize.width === originalWidth &&
|
|
76
|
-
newSize.height === originalHeight
|
|
77
|
-
) {
|
|
78
|
-
return {
|
|
79
|
-
buffer: inputData,
|
|
80
|
-
format: inputFormat,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
64
|
+
const Sharp = await getSharp();
|
|
65
|
+
const metadata = await Sharp(inputData).metadata();
|
|
66
|
+
const { width: originalWidth, height: originalHeight } = metadata;
|
|
83
67
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.toBuffer();
|
|
88
|
-
|
|
89
|
-
const resizeEndTime = Date.now();
|
|
90
|
-
imgDebug(
|
|
91
|
-
`resizeImg done (Sharp), target size: ${newSize.width}x${newSize.height}, cost: ${resizeEndTime - resizeStartTime}ms`,
|
|
92
|
-
);
|
|
68
|
+
if (!originalWidth || !originalHeight) {
|
|
69
|
+
throw Error('Undefined width or height from the input image.');
|
|
70
|
+
}
|
|
93
71
|
|
|
72
|
+
if (newSize.width === originalWidth && newSize.height === originalHeight) {
|
|
94
73
|
return {
|
|
95
|
-
buffer:
|
|
96
|
-
|
|
97
|
-
format: 'jpeg',
|
|
74
|
+
buffer: inputData,
|
|
75
|
+
format: inputFormat,
|
|
98
76
|
};
|
|
99
|
-
} catch (error) {
|
|
100
|
-
imgDebug('Sharp failed, falling back to Photon:', error);
|
|
101
77
|
}
|
|
78
|
+
|
|
79
|
+
const resizedBuffer = await Sharp(inputData)
|
|
80
|
+
.resize(newSize.width, newSize.height)
|
|
81
|
+
.jpeg({ quality: 90 })
|
|
82
|
+
.toBuffer();
|
|
83
|
+
|
|
84
|
+
const resizeEndTime = Date.now();
|
|
85
|
+
imgDebug(
|
|
86
|
+
`resizeImg done (Sharp), target size: ${newSize.width}x${newSize.height}, cost: ${resizeEndTime - resizeStartTime}ms`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
buffer: resizedBuffer,
|
|
91
|
+
// by Sharp.jpeg()
|
|
92
|
+
format: 'jpeg',
|
|
93
|
+
};
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
// browser environment: use Photon (or Canvas fallback)
|
|
@@ -410,6 +402,38 @@ export async function paddingToMatchBlockByBase64(
|
|
|
410
402
|
height: number;
|
|
411
403
|
imageBase64: string;
|
|
412
404
|
}> {
|
|
405
|
+
if (ifInNode) {
|
|
406
|
+
const { body } = parseBase64(imageBase64);
|
|
407
|
+
const inputBuffer = Buffer.from(body, 'base64');
|
|
408
|
+
const Sharp = await getSharp();
|
|
409
|
+
const metadata = await Sharp(inputBuffer).metadata();
|
|
410
|
+
const width = metadata.width;
|
|
411
|
+
const height = metadata.height;
|
|
412
|
+
if (!width || !height) {
|
|
413
|
+
throw new Error('Failed to get image dimensions');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const targetWidth = Math.ceil(width / blockSize) * blockSize;
|
|
417
|
+
const targetHeight = Math.ceil(height / blockSize) * blockSize;
|
|
418
|
+
if (targetWidth === width && targetHeight === height) {
|
|
419
|
+
return { width, height, imageBase64 };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const output = await Sharp(inputBuffer)
|
|
423
|
+
.extend({
|
|
424
|
+
right: targetWidth - width,
|
|
425
|
+
bottom: targetHeight - height,
|
|
426
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
|
427
|
+
})
|
|
428
|
+
.jpeg({ quality: 90 })
|
|
429
|
+
.toBuffer();
|
|
430
|
+
return {
|
|
431
|
+
width: targetWidth,
|
|
432
|
+
height: targetHeight,
|
|
433
|
+
imageBase64: createImgBase64ByFormat('jpeg', output.toString('base64')),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
413
437
|
const photonImage = await photonFromBase64(imageBase64);
|
|
414
438
|
try {
|
|
415
439
|
const paddedResult = await paddingToMatchBlock(photonImage, blockSize);
|
|
@@ -435,6 +459,29 @@ export async function cropByRect(
|
|
|
435
459
|
height: number;
|
|
436
460
|
imageBase64: string;
|
|
437
461
|
}> {
|
|
462
|
+
if (ifInNode) {
|
|
463
|
+
const { body } = parseBase64(imageBase64);
|
|
464
|
+
const Sharp = await getSharp();
|
|
465
|
+
const left = Math.trunc(rect.left);
|
|
466
|
+
const top = Math.trunc(rect.top);
|
|
467
|
+
const width = Math.trunc(rect.left + rect.width) - left;
|
|
468
|
+
const height = Math.trunc(rect.top + rect.height) - top;
|
|
469
|
+
const output = await Sharp(Buffer.from(body, 'base64'))
|
|
470
|
+
.extract({
|
|
471
|
+
left,
|
|
472
|
+
top,
|
|
473
|
+
width,
|
|
474
|
+
height,
|
|
475
|
+
})
|
|
476
|
+
.jpeg({ quality: 90 })
|
|
477
|
+
.toBuffer();
|
|
478
|
+
return {
|
|
479
|
+
width,
|
|
480
|
+
height,
|
|
481
|
+
imageBase64: createImgBase64ByFormat('jpeg', output.toString('base64')),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
438
485
|
const { crop } = await getPhoton();
|
|
439
486
|
const photonImage = await photonFromBase64(imageBase64);
|
|
440
487
|
const { left, top, width, height } = rect;
|
|
@@ -593,48 +640,43 @@ export async function scaleImage(
|
|
|
593
640
|
imgDebug(`scaleImage start, scale factor: ${scale}`);
|
|
594
641
|
|
|
595
642
|
if (ifInNode) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const originalWidth = metadata.width || 0;
|
|
601
|
-
const originalHeight = metadata.height || 0;
|
|
643
|
+
const Sharp = await getSharp();
|
|
644
|
+
const metadata = await Sharp(buffer).metadata();
|
|
645
|
+
const originalWidth = metadata.width || 0;
|
|
646
|
+
const originalHeight = metadata.height || 0;
|
|
602
647
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
648
|
+
if (originalWidth === 0 || originalHeight === 0) {
|
|
649
|
+
throw new Error('Failed to get image dimensions');
|
|
650
|
+
}
|
|
606
651
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
652
|
+
const newWidth = Math.round(originalWidth * scale);
|
|
653
|
+
const newHeight = Math.round(originalHeight * scale);
|
|
654
|
+
|
|
655
|
+
const resizedBuffer = await Sharp(buffer)
|
|
656
|
+
.resize(newWidth, newHeight, {
|
|
657
|
+
kernel: 'lanczos3',
|
|
658
|
+
fit: 'fill',
|
|
659
|
+
})
|
|
660
|
+
.jpeg({
|
|
661
|
+
quality: 90,
|
|
662
|
+
})
|
|
663
|
+
.toBuffer();
|
|
664
|
+
|
|
665
|
+
const scaleEndTime = Date.now();
|
|
666
|
+
imgDebug(
|
|
667
|
+
`scaleImage done (Sharp): ${originalWidth}x${originalHeight} -> ${newWidth}x${newHeight} (scale=${scale}), cost: ${scaleEndTime - scaleStartTime}ms`,
|
|
668
|
+
);
|
|
624
669
|
|
|
625
|
-
|
|
670
|
+
const base64 = `data:image/jpeg;base64,${resizedBuffer.toString('base64')}`;
|
|
626
671
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
} catch (error) {
|
|
633
|
-
imgDebug('Sharp failed, falling back to Photon:', error);
|
|
634
|
-
}
|
|
672
|
+
return {
|
|
673
|
+
width: newWidth,
|
|
674
|
+
height: newHeight,
|
|
675
|
+
imageBase64: base64,
|
|
676
|
+
};
|
|
635
677
|
}
|
|
636
678
|
|
|
637
|
-
// Browser environment
|
|
679
|
+
// Browser environment: use Photon (or Canvas fallback)
|
|
638
680
|
const { PhotonImage, SamplingFilter, resize } = await getPhoton();
|
|
639
681
|
const inputBytes = new Uint8Array(buffer);
|
|
640
682
|
// Support both sync (Photon) and async (Canvas fallback) versions
|
package/src/logger.ts
CHANGED
|
@@ -8,28 +8,53 @@ import { ifInNode } from './utils';
|
|
|
8
8
|
const topicPrefix = 'midscene';
|
|
9
9
|
// Map to store file streams
|
|
10
10
|
const logStreams = new Map<string, fs.WriteStream>();
|
|
11
|
+
// A WriteStream queues every write made after it reports backpressure. The
|
|
12
|
+
// main process can keep running while macOS has deprioritized a backgrounded
|
|
13
|
+
// app, so retaining diagnostic logs here can otherwise grow without bound.
|
|
14
|
+
// Drop best-effort logs until the stream drains instead.
|
|
15
|
+
const backpressuredLogStreams = new Set<string>();
|
|
16
|
+
const unavailableLogStreams = new Set<string>();
|
|
11
17
|
// Map to store debug instances
|
|
12
18
|
const debugInstances = new Map<string, DebugFunction>();
|
|
13
19
|
|
|
14
20
|
// Function to get or create a log stream
|
|
15
|
-
function getLogStream(topic: string): fs.WriteStream {
|
|
21
|
+
function getLogStream(topic: string): fs.WriteStream | null {
|
|
16
22
|
const topicFileName = topic.replace(/:/g, '-');
|
|
23
|
+
if (unavailableLogStreams.has(topicFileName)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
17
26
|
if (!logStreams.has(topicFileName)) {
|
|
18
27
|
const logFile = path.join(
|
|
19
28
|
getMidsceneRunSubDir('log'),
|
|
20
29
|
`${topicFileName}.log`,
|
|
21
30
|
);
|
|
22
31
|
const stream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
32
|
+
// A stream error without a listener terminates the Electron main process.
|
|
33
|
+
// Logging must remain best-effort, so disable this topic after a file error
|
|
34
|
+
// rather than repeatedly queuing writes to a broken stream.
|
|
35
|
+
stream.on('error', () => {
|
|
36
|
+
unavailableLogStreams.add(topicFileName);
|
|
37
|
+
backpressuredLogStreams.delete(topicFileName);
|
|
38
|
+
if (logStreams.get(topicFileName) === stream) {
|
|
39
|
+
logStreams.delete(topicFileName);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
23
42
|
logStreams.set(topicFileName, stream);
|
|
24
43
|
}
|
|
25
|
-
return logStreams.get(topicFileName)
|
|
44
|
+
return logStreams.get(topicFileName) ?? null;
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
// Function to write log to file
|
|
29
48
|
function writeLogToFile(topic: string, message: string): void {
|
|
30
49
|
if (!ifInNode) return;
|
|
31
50
|
|
|
51
|
+
const topicFileName = topic.replace(/:/g, '-');
|
|
52
|
+
if (backpressuredLogStreams.has(topicFileName)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
const stream = getLogStream(topic);
|
|
57
|
+
if (!stream) return;
|
|
33
58
|
// Generate ISO format timestamp with local timezone
|
|
34
59
|
const now = new Date();
|
|
35
60
|
// Use sv-SE locale to get ISO-like format (YYYY-MM-DD HH:mm:ss)
|
|
@@ -47,7 +72,17 @@ function writeLogToFile(topic: string, message: string): void {
|
|
|
47
72
|
.padStart(2, '0');
|
|
48
73
|
const timezoneString = `${sign}${hours}:${minutes}`;
|
|
49
74
|
const localISOTime = `${isoDate}T${isoTime}.${milliseconds}${timezoneString}`;
|
|
50
|
-
|
|
75
|
+
try {
|
|
76
|
+
if (!stream.write(`[${localISOTime}] ${message}\n`)) {
|
|
77
|
+
backpressuredLogStreams.add(topicFileName);
|
|
78
|
+
stream.once('drain', () => {
|
|
79
|
+
backpressuredLogStreams.delete(topicFileName);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
unavailableLogStreams.add(topicFileName);
|
|
84
|
+
backpressuredLogStreams.delete(topicFileName);
|
|
85
|
+
}
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
export type DebugFunction = (...args: unknown[]) => void;
|
package/src/recorder.ts
CHANGED
|
@@ -34,6 +34,19 @@ export interface MidsceneRecorderPageInfo {
|
|
|
34
34
|
height: number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A screenshot stored outside the recording event payload.
|
|
39
|
+
*
|
|
40
|
+
* Recorder events are persisted in the Studio renderer. Keeping full data
|
|
41
|
+
* URLs there makes long recordings retain every screenshot in the renderer
|
|
42
|
+
* heap, so screenshot bytes live in the Playground run directory instead.
|
|
43
|
+
*/
|
|
44
|
+
export interface MidsceneRecorderScreenshotAssetRef {
|
|
45
|
+
id: string;
|
|
46
|
+
mimeType: string;
|
|
47
|
+
bytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
export interface MidsceneRecorderEvent {
|
|
38
51
|
type: MidsceneRecorderEventType;
|
|
39
52
|
source?: MidsceneRecorderSourceKind;
|
|
@@ -46,6 +59,8 @@ export interface MidsceneRecorderEvent {
|
|
|
46
59
|
pageInfo: MidsceneRecorderPageInfo;
|
|
47
60
|
screenshotBefore?: string;
|
|
48
61
|
screenshotAfter?: string;
|
|
62
|
+
/** The single screenshot retained for AI description and Markdown export. */
|
|
63
|
+
screenshotAsset?: MidsceneRecorderScreenshotAssetRef;
|
|
49
64
|
semantic?: MidsceneRecorderSemantic;
|
|
50
65
|
elementDescription?: string;
|
|
51
66
|
descriptionLoading?: boolean;
|
package/src/zod-schema-utils.ts
CHANGED
|
@@ -33,9 +33,7 @@ export function unwrapZodField(field: unknown): unknown {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Check if a field is a Midscene locator field
|
|
36
|
-
*
|
|
37
|
-
* 1. midscene_location_field_flag in shape (result schema)
|
|
38
|
-
* 2. prompt field in shape (input schema)
|
|
36
|
+
* Locator input schemas are identified by their prompt field.
|
|
39
37
|
*/
|
|
40
38
|
export function isMidsceneLocatorField(field: unknown): boolean {
|
|
41
39
|
const actualField = unwrapZodField(field) as {
|
|
@@ -45,11 +43,6 @@ export function isMidsceneLocatorField(field: unknown): boolean {
|
|
|
45
43
|
if (actualField._def?.typeName === 'ZodObject') {
|
|
46
44
|
const shape = actualField._def.shape?.();
|
|
47
45
|
if (shape) {
|
|
48
|
-
// Method 1: Check for the location field flag (for result schema)
|
|
49
|
-
if ('midscene_location_field_flag' in shape) {
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
// Method 2: Check if it's the input schema by checking for 'prompt' field
|
|
53
46
|
if ('prompt' in shape && shape.prompt) {
|
|
54
47
|
return true;
|
|
55
48
|
}
|