diff --git a/src/main/capabilities/vision.ts b/src/main/capabilities/vision.ts index 6193755..76c9fcf 100644 --- a/src/main/capabilities/vision.ts +++ b/src/main/capabilities/vision.ts @@ -22,6 +22,40 @@ async function capturePrimaryScreenPng(): Promise { return PNG.sync.read(source.thumbnail.toPNG()); } +/** 按魔数判断是否 PNG(89 50 4E 47) */ +function isPngBuffer(buffer: Buffer): boolean { + return buffer.length > 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47; +} + +/** 解码用户提供的图片文件为 pngjs PNG。pngjs 只认 PNG, 模板/输入图常是 JPG—— + * 非 PNG 时走 @napi-rs/canvas 解码(JPG/BMP/WebP 等), 否则报 "unrecognised content at end of stream" */ +async function decodeImageFile(filePath: string): Promise { + let buffer: Buffer; + try { + buffer = await fs.readFile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new RuntimeError("validation_error", `找不到图片文件:${filePath}`, { + recommendation: "确认路径正确,或直接点参数旁的「屏幕框选」截取一张" + }); + } + throw error; + } + if (isPngBuffer(buffer)) return PNG.sync.read(buffer); + const { loadImage, createCanvas } = await import("@napi-rs/canvas"); + const image = await loadImage(buffer); + if (!image.width || !image.height) { + throw new RuntimeError("validation_error", "图片解码失败", { detail: filePath }); + } + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + const imageData = context.getImageData(0, 0, image.width, image.height); + const png = new PNG({ width: image.width, height: image.height }); + png.data = Buffer.from(imageData.data); + return png; +} + /** 解析区域 JSON:对象 {x,y,width,height}(兼容 left/top、x1y1x2y2 角点)或数组 [x,y,width,height],并夹取到图片边界内 */ export function parseRegion(raw: string, image: PNG): { x: number; y: number; width: number; height: number } { let parsed: unknown; @@ -104,7 +138,7 @@ register("Image_Screenshot", async (ctx, node, args) => { }); } const sourceFile = path.resolve(imageArg); - image = PNG.sync.read(await fs.readFile(sourceFile)); + image = await decodeImageFile(sourceFile); output = folder ? path.resolve(path.join(folder, name)) : sourceFile; } else { // 当前屏幕来源:image_path 即保存路径(文件夹则自动追加默认文件名),缺省用 输出目录+文件名;再缺省落流程证据目录(路径可预期) @@ -194,8 +228,13 @@ function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: n export async function findImage(templatePath: string, screenshotPath: string, accuracy: number): Promise { const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]); const screen = PNG.sync.read(screenBuffer); - const template = PNG.sync.read(templateBuffer); - if (template.width > screen.width || template.height > screen.height) return null; + // 模板图可能是 JPG 等非 PNG 格式, 按魔数选择解码器 + const template = isPngBuffer(templateBuffer) ? PNG.sync.read(templateBuffer) : await decodeImageFile(templatePath); + if (template.width > screen.width || template.height > screen.height) { + throw new RuntimeError("validation_error", `模板图片(${template.width}×${template.height})比截图画面(${screen.width}×${screen.height})还大,无法查找`, { + recommendation: "模板应是屏幕上要寻找的小图案(按钮、图标、文字块),不要用整屏大图" + }); + } const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2)); const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18)); const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12));