Skip to content

Commit 8f9379c

Browse files
feat: Support buffers as an input in server mode (#159)
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 8f9379c

File tree

14 files changed

+722
-88
lines changed

14 files changed

+722
-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: 199 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,69 @@ class ODiffServer {
1616
this.pendingRequests = new Map();
1717
this.requestId = 0;
1818
this.exiting = false;
19+
this.writeLock = Promise.resolve();
1920

2021
// Start server initialization immediately
2122
/** @type {Promise | null} */
2223
this._initPromise = this._initialize();
2324
}
2425

26+
/** @returns {Promise<(value?: unknown) => unknown>} */
27+
async _acquireWriteLock() {
28+
const currentLock = this.writeLock;
29+
/** @type {(value?: unknown) => unknown} */
30+
let releaseLock;
31+
this.writeLock = new Promise((resolve) => {
32+
releaseLock = resolve;
33+
});
34+
await currentLock;
35+
return releaseLock;
36+
}
37+
38+
/**
39+
* This is internal node js bs handling a separate buffer for stdin
40+
* that can be overflown or underflow, so we have to wait for it to
41+
* process the data otherwise the data corruption can happen
42+
* @private
43+
* @param {string | Buffer} data - Data to write
44+
* @returns {Promise<void>}
45+
*/
46+
async _writeWithBackpressure(data) {
47+
const noDrainNeeded = this.process?.stdin.write(data);
48+
if (!noDrainNeeded) {
49+
await new Promise((resolve, reject) => {
50+
const stdin = this.process?.stdin;
51+
if (!stdin) {
52+
reject(new Error("Process stdin not available"));
53+
return;
54+
}
55+
56+
let settled = false;
57+
58+
stdin.once("drain", () => {
59+
if (!settled) {
60+
settled = true;
61+
resolve(undefined);
62+
}
63+
});
64+
65+
stdin.once("error", (err) => {
66+
if (!settled) {
67+
settled = true;
68+
reject(err);
69+
}
70+
});
71+
72+
stdin.once("close", () => {
73+
if (!settled) {
74+
settled = true;
75+
reject(new Error("Stream closed before drain"));
76+
}
77+
});
78+
});
79+
}
80+
}
81+
2582
/**
2683
* Internal method to initialize the server process
2784
* @private
@@ -77,7 +134,12 @@ class ODiffServer {
77134
clearTimeout(pending.timeoutId);
78135
}
79136

80-
pending.resolve(response);
137+
// Reject if response contains an error, otherwise resolve
138+
if (response.error) {
139+
pending.reject(new Error(response.error));
140+
} else {
141+
pending.resolve(response);
142+
}
81143
} else {
82144
console.warn(
83145
`Received response for unknown request ID: ${response.requestId}`,
@@ -114,7 +176,7 @@ class ODiffServer {
114176
* @param {string} comparePath - Path to comparison image
115177
* @param {string} diffOutput - Path to output diff image
116178
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
117-
* @returns {Promise<Object>} Comparison result
179+
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
118180
*/
119181
async compare(basePath, comparePath, diffOutput, options = {}) {
120182
if (this._initPromise && !this.ready) {
@@ -127,9 +189,10 @@ class ODiffServer {
127189
await this._initPromise;
128190
}
129191

130-
return new Promise((resolve, reject) => {
131-
const requestId = this.requestId++;
132-
let timeoutId;
192+
const requestId = this.requestId++;
193+
let timeoutId;
194+
195+
const resultPromise = new Promise((resolve, reject) => {
133196
if (options.timeout !== undefined) {
134197
timeoutId = setTimeout(() => {
135198
if (this.pendingRequests.has(requestId)) {
@@ -142,33 +205,140 @@ class ODiffServer {
142205
}
143206

144207
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-
};
208+
});
161209

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}`));
210+
const request = {
211+
requestId: requestId,
212+
base: basePath,
213+
compare: comparePath,
214+
output: diffOutput,
215+
options: {
216+
threshold: options.threshold,
217+
failOnLayoutDiff: options.failOnLayoutDiff,
218+
antialiasing: options.antialiasing,
219+
captureDiffLines: options.captureDiffLines,
220+
outputDiffMask: options.outputDiffMask,
221+
ignoreRegions: options.ignoreRegions,
222+
diffColor: options.diffColor,
223+
diffOverlay: options.diffOverlay,
224+
},
225+
};
226+
227+
// Acquire write lock to prevent concurrent requests from interleaving
228+
const release = await this._acquireWriteLock();
229+
try {
230+
await this._writeWithBackpressure(JSON.stringify(request) + "\n");
231+
} catch (err) {
232+
this.pendingRequests.delete(requestId);
233+
if (timeoutId !== undefined) {
234+
clearTimeout(timeoutId);
170235
}
236+
throw new Error(`odiff: Failed to send request: ${err.message}`);
237+
} finally {
238+
release();
239+
}
240+
241+
return resultPromise;
242+
}
243+
244+
/**
245+
* Compare two images buffers, the buffer data is the actual encoded file bytes.
246+
* **Important**: Always prefer file paths compare if you are saving images to disk anyway.
247+
*
248+
* @param {Buffer} baseBuffer - Buffer containing base image data
249+
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} baseFormat - Format: "png", "jpeg", "bmp", "tiff", "webp"
250+
* @param {Buffer} compareBuffer - Buffer containing compare image data
251+
* @param {"png" | "jpeg" | "bmp" | "tiff" | "webp"} compareFormat - Format of compare image
252+
* @param {string} diffOutput - Path to output diff image
253+
* @param {import("./odiff.d.ts").ODiffOptions & { timeout?: number }} [options] - Comparison options
254+
* @returns {Promise<import("./odiff.d.ts").ODiffResult>} Comparison result
255+
*/
256+
async compareBuffers(
257+
baseBuffer,
258+
baseFormat,
259+
compareBuffer,
260+
compareFormat,
261+
diffOutput,
262+
options = {},
263+
) {
264+
// Wait for server initialization
265+
if (this._initPromise && !this.ready) {
266+
await this._initPromise;
267+
}
268+
269+
if (!this._initPromise && !this.ready) {
270+
this._initPromise = this._initialize();
271+
await this._initPromise;
272+
}
273+
274+
if (!Buffer.isBuffer(baseBuffer) || !Buffer.isBuffer(compareBuffer)) {
275+
throw new Error(
276+
"Both baseBuffer and compareBuffer must be Buffer instances",
277+
);
278+
}
279+
280+
const requestId = this.requestId++;
281+
let timeoutId;
282+
283+
const resultPromise = new Promise((resolve, reject) => {
284+
if (options.timeout !== undefined) {
285+
timeoutId = setTimeout(() => {
286+
if (this.pendingRequests.has(requestId)) {
287+
this.pendingRequests.delete(requestId);
288+
reject(
289+
new Error(`odiff: Request timed out after ${options.timeout}ms`),
290+
);
291+
}
292+
}, options.timeout);
293+
}
294+
295+
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
171296
});
297+
298+
const request = {
299+
type: "buffer",
300+
requestId: requestId,
301+
baseLength: baseBuffer.length,
302+
baseFormat: baseFormat,
303+
compareLength: compareBuffer.length,
304+
compareFormat: compareFormat,
305+
output: diffOutput,
306+
options: {
307+
threshold: options.threshold,
308+
failOnLayoutDiff: options.failOnLayoutDiff,
309+
antialiasing: options.antialiasing,
310+
captureDiffLines: options.captureDiffLines,
311+
outputDiffMask: options.outputDiffMask,
312+
ignoreRegions: options.ignoreRegions,
313+
diffColor: options.diffColor,
314+
diffOverlay: options.diffOverlay,
315+
},
316+
};
317+
318+
// Acquire write lock to prevent concurrent requests from interleaving
319+
// This is CRITICAL for buffer comparisons since we write:
320+
// 1. JSON request line
321+
// 2. Base buffer (raw bytes)
322+
// 3. Compare buffer (raw bytes)
323+
// These three writes must be atomic and ordered to avoid data corruption
324+
const release = await this._acquireWriteLock();
325+
try {
326+
await this._writeWithBackpressure(JSON.stringify(request) + "\n");
327+
await this._writeWithBackpressure(baseBuffer);
328+
await this._writeWithBackpressure(compareBuffer);
329+
} catch (err) {
330+
this.pendingRequests.delete(requestId);
331+
if (timeoutId !== undefined) {
332+
clearTimeout(timeoutId);
333+
}
334+
throw new Error(`odiff: Failed to send request: ${err.message}`);
335+
} finally {
336+
if (release) {
337+
release();
338+
}
339+
}
340+
341+
return resultPromise;
172342
}
173343

174344
/**

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)