Skip to content

Commit 39baac9

Browse files
committed
feat: Support buffers as an input in server mode
closes #53 This implements a pretty efficient way to accept buffers as an input by receiving the buffer length as an input in the command and reading excact amount of bytes
1 parent a56c4f3 commit 39baac9

File tree

14 files changed

+698
-88
lines changed

14 files changed

+698
-88
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
node_modules/
33
zig-out/
44
.benchtree/
5+
images/gen/*
6+
!images/gen/.gitkeep
57

68
# node js + playwright
79
node_modules/

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ odiff --help
143143

144144
NodeJS Api is pretty tiny as well. Here is a typescript interface we have:
145145

146-
146+
<!--inline-interface-start-->
147147
```tsx
148148
export type ODiffOptions = Partial<{
149149
/** Color used to highlight different pixels in the output (in hex format e.g. #cd2cc9). */
@@ -220,7 +220,7 @@ declare function compare(
220220
* server.stop();
221221
* ```
222222
*
223-
* It is absolutely fine to keep odiff server living in the module root
223+
* It is absolutely fine to keep odiff sever leaving in the module root
224224
* even if you have several independent workers, it will automatically spawn
225225
* a server process per each multiplexed core to work in parallel
226226
*
@@ -261,6 +261,27 @@ export declare class ODiffServer {
261261
options?: ODiffOptions & { timeout?: number },
262262
): Promise<ODiffResult>;
263263

264+
/**
265+
* Compare two images buffers, the buffer data is the actual encoded file bytes.
266+
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
267+
*
268+
* @param baseBuffer - Buffer containing base image data
269+
* @param baseFormat - Format of base image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
270+
* @param compareBuffer - Buffer containing compare image data
271+
* @param compareFormat - Format of compare image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
272+
* @param diffOutput - Path to output diff image
273+
* @param options - Comparison options with optional timeout for request
274+
* @returns Promise resolving to comparison result
275+
*/
276+
compareBuffers(
277+
baseBuffer: Buffer,
278+
baseFormat: string,
279+
compareBuffer: Buffer,
280+
compareFormat: string,
281+
diffOutput: string,
282+
options?: ODiffOptions & { timeout?: number },
283+
): Promise<ODiffResult>;
284+
264285
/**
265286
* Stop the odiff server process
266287
* Should be called when done with all comparisons
@@ -271,7 +292,6 @@ export declare class ODiffServer {
271292

272293
export { compare, ODiffServer };
273294
```
274-
275295
<!--inline-interface-end-->
276296

277297
Compare option will return `{ match: true }` if images are identical. Otherwise return `{ match: false, reason: "*" }` with a reason why images were different.

images/diff.png

-777 KB
Loading

images/diff_buffer_different.png

3.21 KB
Loading

images/diff_white_mask.png

13 KB
Loading

images/gen/.gitkeep

Whitespace-only changes.

npm_packages/odiff-bin/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ odiff --help
143143

144144
NodeJS Api is pretty tiny as well. Here is a typescript interface we have:
145145

146-
146+
<!--inline-interface-start-->
147147
```tsx
148148
export type ODiffOptions = Partial<{
149149
/** Color used to highlight different pixels in the output (in hex format e.g. #cd2cc9). */
@@ -274,6 +274,7 @@ export { compare, ODiffServer };
274274

275275
<!--inline-interface-end-->
276276

277+
277278
Compare option will return `{ match: true }` if images are identical. Otherwise return `{ match: false, reason: "*" }` with a reason why images were different.
278279

279280
> Make sure that diff output file will be created only if images have pixel difference we can see 👀

npm_packages/odiff-bin/odiff.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ export declare class ODiffServer {
114114
options?: ODiffOptions & { timeout?: number },
115115
): Promise<ODiffResult>;
116116

117+
/**
118+
* Compare two images buffers, the buffer data is the actual encoded file bytes.
119+
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
120+
*
121+
* @param baseBuffer - Buffer containing base image data
122+
* @param baseFormat - Format of base image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
123+
* @param compareBuffer - Buffer containing compare image data
124+
* @param compareFormat - Format of compare image: "png", "jpeg", "jpg", "bmp", "tiff", "webp"
125+
* @param diffOutput - Path to output diff image
126+
* @param options - Comparison options with optional timeout for request
127+
* @returns Promise resolving to comparison result
128+
*/
129+
compareBuffers(
130+
baseBuffer: Buffer,
131+
baseFormat: string,
132+
compareBuffer: Buffer,
133+
compareFormat: string,
134+
diffOutput: string,
135+
options?: ODiffOptions & { timeout?: number },
136+
): Promise<ODiffResult>;
137+
117138
/**
118139
* Stop the odiff server process
119140
* Should be called when done with all comparisons

npm_packages/odiff-bin/server.js

Lines changed: 175 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @ts-check
22
const { spawn } = require("child_process");
33
const path = require("path");
4+
const { throwDeprecation } = require("process");
45
const readline = require("readline");
56

67
class ODiffServer {
@@ -16,12 +17,23 @@ class ODiffServer {
1617
this.pendingRequests = new Map();
1718
this.requestId = 0;
1819
this.exiting = false;
20+
this.writeLock = Promise.resolve();
1921

2022
// Start server initialization immediately
2123
/** @type {Promise | null} */
2224
this._initPromise = this._initialize();
2325
}
2426

27+
async _acquireWriteLock() {
28+
const currentLock = this.writeLock;
29+
let releaseLock;
30+
this.writeLock = new Promise((resolve) => {
31+
releaseLock = resolve;
32+
});
33+
await currentLock;
34+
return releaseLock;
35+
}
36+
2537
/**
2638
* Internal method to initialize the server process
2739
* @private
@@ -77,7 +89,12 @@ class ODiffServer {
7789
clearTimeout(pending.timeoutId);
7890
}
7991

80-
pending.resolve(response);
92+
// Reject if response contains an error, otherwise resolve
93+
if (response.error) {
94+
pending.reject(new Error(response.error));
95+
} else {
96+
pending.resolve(response);
97+
}
8198
} else {
8299
console.warn(
83100
`Received response for unknown request ID: ${response.requestId}`,
@@ -114,7 +131,7 @@ class ODiffServer {
114131
* @param {string} comparePath - Path to comparison image
115132
* @param {string} diffOutput - Path to output diff image
116133
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
117-
* @returns {Promise<Object>} Comparison result
134+
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
118135
*/
119136
async compare(basePath, comparePath, diffOutput, options = {}) {
120137
if (this._initPromise && !this.ready) {
@@ -127,9 +144,10 @@ class ODiffServer {
127144
await this._initPromise;
128145
}
129146

130-
return new Promise((resolve, reject) => {
131-
const requestId = this.requestId++;
132-
let timeoutId;
147+
const requestId = this.requestId++;
148+
let timeoutId;
149+
150+
const resultPromise = new Promise((resolve, reject) => {
133151
if (options.timeout !== undefined) {
134152
timeoutId = setTimeout(() => {
135153
if (this.pendingRequests.has(requestId)) {
@@ -142,33 +160,161 @@ class ODiffServer {
142160
}
143161

144162
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
145-
const request = {
146-
requestId: requestId,
147-
base: basePath,
148-
compare: comparePath,
149-
output: diffOutput,
150-
options: {
151-
threshold: options.threshold,
152-
failOnLayoutDiff: options.failOnLayoutDiff,
153-
antialiasing: options.antialiasing,
154-
captureDiffLines: options.captureDiffLines,
155-
outputDiffMask: options.outputDiffMask,
156-
ignoreRegions: options.ignoreRegions,
157-
diffColor: options.diffColor,
158-
diffOverlay: options.diffOverlay,
159-
},
160-
};
163+
});
161164

162-
try {
163-
this.process?.stdin.write(JSON.stringify(request) + "\n");
164-
} catch (err) {
165-
this.pendingRequests.delete(requestId);
166-
if (timeoutId !== undefined) {
167-
clearTimeout(timeoutId);
168-
}
169-
reject(new Error(`odiff: Failed to send request: ${err.message}`));
165+
const request = {
166+
requestId: requestId,
167+
base: basePath,
168+
compare: comparePath,
169+
output: diffOutput,
170+
options: {
171+
threshold: options.threshold,
172+
failOnLayoutDiff: options.failOnLayoutDiff,
173+
antialiasing: options.antialiasing,
174+
captureDiffLines: options.captureDiffLines,
175+
outputDiffMask: options.outputDiffMask,
176+
ignoreRegions: options.ignoreRegions,
177+
diffColor: options.diffColor,
178+
diffOverlay: options.diffOverlay,
179+
},
180+
};
181+
182+
// Acquire write lock to prevent concurrent requests from interleaving
183+
const release = await this._acquireWriteLock();
184+
try {
185+
if (!this.process?.stdin.write(JSON.stringify(request) + "\n")) {
186+
await new Promise((drainResolve) => {
187+
this.process?.stdin.once("drain", drainResolve);
188+
});
189+
}
190+
} catch (err) {
191+
this.pendingRequests.delete(requestId);
192+
if (timeoutId !== undefined) {
193+
clearTimeout(timeoutId);
194+
}
195+
throw new Error(`odiff: Failed to send request: ${err.message}`);
196+
} finally {
197+
release();
198+
}
199+
200+
return resultPromise;
201+
}
202+
203+
/**
204+
* Compare two images buffers, the buffer data is the actual encoded file bytes.
205+
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
206+
*
207+
* @param {Buffer} baseBuffer - Buffer containing base image data
208+
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} baseFormat - Format: "png", "jpeg", "bmp", "tiff", "webp"
209+
* @param {Buffer} compareBuffer - Buffer containing compare image data
210+
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} compareFormat - Format of compare image
211+
* @param {string} diffOutput - Path to output diff image
212+
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
213+
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
214+
*/
215+
async compareBuffers(
216+
baseBuffer,
217+
baseFormat,
218+
compareBuffer,
219+
compareFormat,
220+
diffOutput,
221+
options = {},
222+
) {
223+
// Wait for server initialization
224+
if (this._initPromise && !this.ready) {
225+
await this._initPromise;
226+
}
227+
228+
if (!this._initPromise && !this.ready) {
229+
this._initPromise = this._initialize();
230+
await this._initPromise;
231+
}
232+
233+
if (!Buffer.isBuffer(baseBuffer) || !Buffer.isBuffer(compareBuffer)) {
234+
throw new Error(
235+
"Both baseBuffer and compareBuffer must be Buffer instances",
236+
);
237+
}
238+
239+
const requestId = this.requestId++;
240+
let timeoutId;
241+
242+
const resultPromise = new Promise((resolve, reject) => {
243+
if (options.timeout !== undefined) {
244+
timeoutId = setTimeout(() => {
245+
if (this.pendingRequests.has(requestId)) {
246+
this.pendingRequests.delete(requestId);
247+
reject(
248+
new Error(`odiff: Request timed out after ${options.timeout}ms`),
249+
);
250+
}
251+
}, options.timeout);
170252
}
253+
254+
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
171255
});
256+
257+
const request = {
258+
type: "buffer",
259+
requestId: requestId,
260+
baseLength: baseBuffer.length,
261+
baseFormat: baseFormat,
262+
compareLength: compareBuffer.length,
263+
compareFormat: compareFormat,
264+
output: diffOutput,
265+
options: {
266+
threshold: options.threshold,
267+
failOnLayoutDiff: options.failOnLayoutDiff,
268+
antialiasing: options.antialiasing,
269+
captureDiffLines: options.captureDiffLines,
270+
outputDiffMask: options.outputDiffMask,
271+
ignoreRegions: options.ignoreRegions,
272+
diffColor: options.diffColor,
273+
diffOverlay: options.diffOverlay,
274+
},
275+
};
276+
277+
// Acquire write lock to prevent concurrent requests from interleaving
278+
// This is CRITICAL for buffer comparisons since we write:
279+
// 1. JSON request line
280+
// 2. Base buffer (raw bytes)
281+
// 3. Compare buffer (raw bytes)
282+
// These three writes must be atomic to avoid data corruption
283+
const release = await this._acquireWriteLock();
284+
try {
285+
// Write JSON request
286+
if (!this.process?.stdin.write(JSON.stringify(request) + "\n")) {
287+
await new Promise((drainResolve) => {
288+
this.process?.stdin.once("drain", drainResolve);
289+
});
290+
}
291+
292+
// Write base buffer
293+
if (!this.process?.stdin.write(baseBuffer)) {
294+
await new Promise((drainResolve) => {
295+
this.process?.stdin.once("drain", drainResolve);
296+
});
297+
}
298+
299+
// Write compare buffer
300+
if (!this.process?.stdin.write(compareBuffer)) {
301+
await new Promise((drainResolve) => {
302+
this.process?.stdin.once("drain", drainResolve);
303+
});
304+
}
305+
} catch (err) {
306+
this.pendingRequests.delete(requestId);
307+
if (timeoutId !== undefined) {
308+
clearTimeout(timeoutId);
309+
}
310+
throw new Error(`odiff: Failed to send request: ${err.message}`);
311+
} finally {
312+
if (release) {
313+
release();
314+
}
315+
}
316+
317+
return resultPromise;
172318
}
173319

174320
/**

npm_packages/tests/ava.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
module.exports = {
22
files: ["src/**/*.test.cjs"],
3+
timeout: "60s",
34
};

0 commit comments

Comments
 (0)