From 431d8a58c03892c1774f23b449f3c35b337ced2a Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Fri, 29 May 2026 13:41:52 +0300 Subject: [PATCH] fix: calibrate errors --- src/browser/calibrator.ts | 28 ++++++---- src/browser/camera/index.ts | 2 +- src/browser/commands/assert-view/index.js | 2 +- src/browser/screen-shooter/viewport/index.js | 4 +- src/image.ts | 34 +++++++++++- test/src/browser/calibrator.js | 54 ++++++++++++++++++- test/src/browser/camera/index.js | 4 +- .../browser/screen-shooter/viewport/index.js | 8 +-- test/src/image.js | 12 ++--- 9 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/browser/calibrator.ts b/src/browser/calibrator.ts index 870621428..5234cfb6f 100644 --- a/src/browser/calibrator.ts +++ b/src/browser/calibrator.ts @@ -3,7 +3,7 @@ import path from "path"; import looksSame from "looks-same"; import { CoreError } from "./core-error"; import { ExistingBrowser } from "./existing-browser"; -import type { Image } from "../image"; +import type { Image, RGB } from "../image"; const DIRECTION = { FORWARD: "forward", REVERSE: "reverse" } as const; @@ -49,7 +49,11 @@ export class Calibrator { const { innerWidth, pixelRatio } = features; const hasPixelRatio = Boolean(pixelRatio && pixelRatio > 1.0); - const imageFeatures = await this._analyzeImage(image, { calculateColorLength: hasPixelRatio }); + const { width: imageWidth, height: imageHeight } = image.getSize(); + const searchColor = image.hasICCPChunk + ? await image.getRGB(Math.floor(imageWidth / 2), Math.floor(imageHeight / 2)) + : { R: 148, G: 250, B: 0 }; + const imageFeatures = await this._analyzeImage(image, { calculateColorLength: hasPixelRatio, searchColor }); if (!imageFeatures) { throw new CoreError( @@ -70,9 +74,9 @@ export class Calibrator { private async _analyzeImage( image: Image, - params: { calculateColorLength?: boolean }, + params: { calculateColorLength?: boolean; searchColor: RGB }, ): Promise { - const imageHeight = (await image.getSize()).height; + const imageHeight = image.getSize().height; for (let y = 0; y < imageHeight; y++) { const result = await analyzeRow(y, image, params); @@ -88,9 +92,9 @@ export class Calibrator { async function analyzeRow( row: number, image: Image, - params: { calculateColorLength?: boolean } = {}, + params: { calculateColorLength?: boolean; searchColor: RGB }, ): Promise { - const markerStart = await findMarkerInRow(row, image, DIRECTION.FORWARD); + const markerStart = await findMarkerInRow(row, image, DIRECTION.FORWARD, params.searchColor); if (markerStart === -1) { return null; @@ -102,15 +106,19 @@ async function analyzeRow( return result; } - const markerEnd = await findMarkerInRow(row, image, DIRECTION.REVERSE); + const markerEnd = await findMarkerInRow(row, image, DIRECTION.REVERSE, params.searchColor); const colorLength = markerEnd - markerStart + 1; return { ...result, colorLength }; } -async function findMarkerInRow(row: number, image: Image, searchDirection: "forward" | "reverse"): Promise { - const imageWidth = (await image.getSize()).width; - const searchColor = { R: 148, G: 250, B: 0 }; +async function findMarkerInRow( + row: number, + image: Image, + searchDirection: "forward" | "reverse", + searchColor: RGB, +): Promise { + const imageWidth = image.getSize().width; if (searchDirection === DIRECTION.REVERSE) { return searchReverse_(); diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts index ccb494489..7d4d7ae39 100644 --- a/src/browser/camera/index.ts +++ b/src/browser/camera/index.ts @@ -45,7 +45,7 @@ export class Camera { const base64 = await this._takeScreenshot(); const image = Image.fromBase64(base64); - const { width, height } = await image.getSize(); + const { width, height } = image.getSize(); const imageArea: ImageArea = { left: 0, top: 0, width, height }; const calibratedArea = this._calibrateArea(imageArea); diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index c1620e6f0..1ce8c78c2 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -88,7 +88,7 @@ module.exports.default = browser => { const currImgInst = await screenShooter .capture(page, screenshoterOpts) .finally(() => browser.cleanupScreenshot(opts)); - const currSize = await currImgInst.getSize(); + const currSize = currImgInst.getSize(); const currImg = { path: temp.path(Object.assign(tempOpts, { suffix: ".png" })), size: currSize }; const test = session.executionContext.ctx.currentTest; diff --git a/src/browser/screen-shooter/viewport/index.js b/src/browser/screen-shooter/viewport/index.js index 93295ecf6..3856b6e59 100644 --- a/src/browser/screen-shooter/viewport/index.js +++ b/src/browser/screen-shooter/viewport/index.js @@ -35,7 +35,7 @@ module.exports = class Viewport { } async handleImage(image, area = {}) { - const { width, height } = await image.getSize(); + const { width, height } = image.getSize(); _.defaults(area, { left: 0, top: 0, width, height }); const capturedArea = this._transformToCaptureArea(area); @@ -57,7 +57,7 @@ module.exports = class Viewport { async extendBy(physicalScrollHeight, newImage) { this._viewport.height += physicalScrollHeight; - const { width, height } = await newImage.getSize(); + const { width, height } = newImage.getSize(); await this.handleImage(newImage, { left: 0, diff --git a/src/image.ts b/src/image.ts index 69aae7018..d47b42eef 100644 --- a/src/image.ts +++ b/src/image.ts @@ -80,12 +80,39 @@ export const extractBase64PngSize = (base64EncodedPng: string): ImageSize => { }; }; +const hasICCPChunk = (buffer: Buffer): boolean => { + const auxChunkSizeBytes = 12; // 4 bytes for length, 4 bytes for type, 4 bytes for crc + const iCCPChunkType = Buffer.from("iCCP", "ascii").readUInt32BE(0); + const IDATChunkType = Buffer.from("IDAT", "ascii").readUInt32BE(0); + const PLTEChunkType = Buffer.from("PLTE", "ascii").readUInt32BE(0); + + for (let nextChunkPointer = PNG_SIGNATURE.byteLength; nextChunkPointer <= buffer.length - auxChunkSizeBytes; ) { + const chunkLength = buffer.readUInt32BE(nextChunkPointer); + const chunkType = buffer.readUInt32BE(nextChunkPointer + 4); + + if (chunkType === iCCPChunkType) { + return true; + } + + // If the iCCP chunk appears, it must precede the first IDAT chunk, and it must also precede the PLTE chunk if present + // https://libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.iCCP + if (chunkType === IDATChunkType || chunkType === PLTEChunkType) { + return false; + } + + nextChunkPointer += chunkLength + auxChunkSizeBytes; + } + + return false; +}; + export class Image { private _imgDataPromise: Promise; private _imgData: Buffer | null = null; private _width: number; private _height: number; private _composeImages: this[] = []; + private _hasICCPChunk: boolean = false; static create(buffer: Buffer): Image { return new this(buffer); @@ -94,11 +121,16 @@ export class Image { constructor(buffer: Buffer) { this._width = buffer.readUInt32BE(PNG_WIDTH_OFFSET); this._height = buffer.readUInt32BE(PNG_HEIGHT_OFFSET); + this._hasICCPChunk = hasICCPChunk(buffer); this._imgDataPromise = jsquashDecode(buffer).then(({ data }) => { return Buffer.from(data.buffer, data.byteOffset, data.byteLength); }); } + public get hasICCPChunk(): boolean { + return this._hasICCPChunk; + } + async _getImgData(): Promise { if (this._imgData) { return this._imgData; @@ -120,7 +152,7 @@ export class Image { } } - async getSize(): Promise { + getSize(): ImageSize { this._ensureImagesHaveSameWidth(); const height = this._composeImages.reduce((acc, img) => acc + img._height, this._height); diff --git a/test/src/browser/calibrator.js b/test/src/browser/calibrator.js index a973a4a8e..f58d5190f 100644 --- a/test/src/browser/calibrator.js +++ b/test/src/browser/calibrator.js @@ -20,8 +20,11 @@ describe("calibrator", () => { const setScreenshot = imageName => { const imgPath = path.join(__dirname, "..", "..", "fixtures", imageName); const imgData = fs.readFileSync(imgPath); + const image = new Image(imgData); - browser.captureViewportImage.returns(Promise.resolve(new Image(imgData))); + browser.captureViewportImage.returns(Promise.resolve(image)); + + return image; }; beforeEach(() => { @@ -85,4 +88,53 @@ describe("calibrator", () => { return assert.isRejected(calibrator.calibrate(browser), CoreError); }); + + describe("when image has iCCP chunk", () => { + it("should use color at the center of the image to detect marker", async () => { + const image = setScreenshot("calibrate.png"); + const { width, height } = image.getSize(); + const centerX = Math.floor(width / 2); + const centerY = Math.floor(height / 2); + const centerColor = { R: 50, G: 100, B: 150 }; + const markerLeft = 7; + const markerTop = 4; + + image._hasICCPChunk = true; + + sinon.stub(image, "getRGB").callsFake(async (x, y) => { + if (x === centerX && y === centerY) { + return centerColor; + } + + if (x === markerLeft && y === markerTop) { + return centerColor; + } + + return { R: 0, G: 0, B: 0 }; + }); + + const result = await calibrator.calibrate(browser); + + assert.equal(result.top, markerTop); + assert.equal(result.left, markerLeft); + }); + }); + + describe("when image does not have iCCP chunk", () => { + it("should use hardcoded green color as search color", async () => { + const image = setScreenshot("calibrate.png"); + sinon.stub(image, "getRGB").callsFake(async (x, y) => { + if (x === 3 && y === 5) { + return { R: 148, G: 250, B: 0 }; + } + + return { R: 0, G: 0, B: 0 }; + }); + + const result = await calibrator.calibrate(browser); + + assert.equal(result.top, 5); + assert.equal(result.left, 3); + }); + }); }); diff --git a/test/src/browser/camera/index.js b/test/src/browser/camera/index.js index 61210fb65..56024a13e 100644 --- a/test/src/browser/camera/index.js +++ b/test/src/browser/camera/index.js @@ -18,7 +18,7 @@ describe("browser/camera", () => { }).Camera; image = sinon.createStubInstance(Image); - image.getSize.resolves({ width: 100500, height: 500100 }); + image.getSize.returns({ width: 100500, height: 500100 }); image.crop.resolves(); sandbox.stub(Image, "fromBase64").returns(image); @@ -40,7 +40,7 @@ describe("browser/camera", () => { describe("calibration", () => { it("should apply calibration on taken screenshot", async () => { const camera = Camera.create(null, sinon.stub().resolves()); - image.getSize.resolves({ width: 10, height: 10 }); + image.getSize.returns({ width: 10, height: 10 }); camera.calibrate({ top: 6, left: 4 }); await camera.captureViewportImage(); diff --git a/test/src/browser/screen-shooter/viewport/index.js b/test/src/browser/screen-shooter/viewport/index.js index c7f28f7eb..497ce0355 100644 --- a/test/src/browser/screen-shooter/viewport/index.js +++ b/test/src/browser/screen-shooter/viewport/index.js @@ -23,7 +23,7 @@ describe("Viewport", () => { beforeEach(() => { image = sandbox.createStubInstance(Image); - image.getSize.resolves({ width: 100500, height: 500100 }); + image.getSize.returns({ width: 100500, height: 500100 }); }); afterEach(() => sandbox.restore()); @@ -176,7 +176,7 @@ describe("Viewport", () => { }); describe("should crop to captureArea", () => { - beforeEach(() => image.getSize.resolves({ width: 7, height: 10 })); + beforeEach(() => image.getSize.returns({ width: 7, height: 10 })); it("with default area", async () => { const vieport = createViewport({ @@ -270,7 +270,7 @@ describe("Viewport", () => { newImage = sinon.createStubInstance(Image); newImage.crop.resolves(); - newImage.getSize.resolves({}); + newImage.getSize.returns({}); }); it("should increase viewport height value by scroll height", async () => { @@ -285,7 +285,7 @@ describe("Viewport", () => { }); it("should crop new image by passed scroll height", async () => { - newImage.getSize.resolves({ height: 4, width: 2 }); + newImage.getSize.returns({ height: 4, width: 2 }); const viewport = createViewport({ captureArea: { left: 0, top: 0, width: 4, height: 20 }, viewport: { left: 0, top: 0, width: 4, height: 8 }, diff --git a/test/src/image.js b/test/src/image.js index d9b4ffb0f..db24db725 100644 --- a/test/src/image.js +++ b/test/src/image.js @@ -182,33 +182,33 @@ describe("Image", () => { }); describe("getSize", () => { - it("should return size of single image", async () => { + it("should return size of single image", () => { const buffer = createMockPngBuffer(100, 50); const image = new Image(buffer); - const size = await image.getSize(); + const size = image.getSize(); assert.deepEqual(size, { width: 100, height: 50 }); }); - it("should return combined height for composed images", async () => { + it("should return combined height for composed images", () => { const buffer = createMockPngBuffer(100, 50); const image = new Image(buffer); const attachedImage1 = new Image(createMockPngBuffer(100, 30)); const attachedImage2 = new Image(createMockPngBuffer(100, 20)); image._composeImages = [attachedImage1, attachedImage2]; - const size = await image.getSize(); + const size = image.getSize(); assert.deepEqual(size, { width: 100, height: 100 }); // 50 + 30 + 20 }); - it("should ensure images have same width before calculating size", async () => { + it("should ensure images have same width before calculating size", () => { const buffer = createMockPngBuffer(100, 50); const image = new Image(buffer); sandbox.spy(image, "_ensureImagesHaveSameWidth"); - await image.getSize(); + image.getSize(); assert.calledOnce(image._ensureImagesHaveSameWidth); });