roxify 1.2.8 → 1.2.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.10] - 2026-01-09
4
+
5
+ ### Performance 🚀🚀
6
+
7
+ - **MASSIVE file packing speedup**: 18,750 files (660MB) now in **7 seconds** (was 18s)
8
+ - Parallelized file reading with `fs.promises.readFile()` and `Promise.all()` batching
9
+ - Batch size optimized to 1000 files per parallel read
10
+ - Improved buffer concatenation strategy (array accumulation + single concat)
11
+ - Added error handling for unreadable files during parallel reads
12
+
13
+ ### Benchmarks
14
+
15
+ - Single file 1GB: 389ms (2.63 GB/s)
16
+ - Directory 18,750 files (660MB): **6.8s** (97 MB/s including I/O overhead)
17
+
18
+ ## [1.2.9] - 2026-01-09
19
+
20
+ ### Performance 🚀
21
+
22
+ - **EXTREME SPEED**: 1GB encode in 0.39s (**2.6 GB/s throughput**)
23
+ - Optimized PNG pixel copying from byte-by-byte loops to bulk Buffer.copy() operations
24
+ - Reduced PNG deflate overhead by using zlib level 0 (data already compressed with Zstd)
25
+ - Lowered large image threshold from 50M to 10M pixels for faster manual PNG generation
26
+ - Default Zstd compression level changed from 15 to 3 (much faster, still excellent ratio)
27
+
28
+ ### Changed
29
+
30
+ - Added `compressionLevel` option to `EncodeOptions` (default: 3)
31
+ - Added `skipOptimization` option to disable zopfli PNG optimization
32
+ - CLI now disables PNG optimization by default for maximum speed
33
+
34
+ ### Benchmarks
35
+
36
+ - 1KB: 14.77ms
37
+ - 100MB: 63.74ms (1.57 GB/s)
38
+ - 500MB: 203ms (2.46 GB/s)
39
+ - 1GB: 389ms (2.63 GB/s)
40
+
3
41
  ## [1.2.8] - 2026-01-09
4
42
 
5
43
  ### Added
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
4
4
  import { basename, dirname, join, resolve } from 'path';
5
5
  import { DataFormatError, decodePngToBinary, encodeBinaryToPng, hasPassphraseInPng, IncorrectPassphraseError, listFilesInPng, PassphraseRequiredError, } from './index.js';
6
6
  import { packPathsGenerator, unpackBuffer } from './pack.js';
7
- const VERSION = '1.2.6';
7
+ const VERSION = '1.2.9';
8
8
  function showHelp() {
9
9
  console.log(`
10
10
  ROX CLI — Encode/decode binary in PNG
@@ -187,6 +187,7 @@ async function encodeCommand(args) {
187
187
  Object.assign(options, {
188
188
  mode,
189
189
  name: parsed.outputName || 'archive',
190
+ skipOptimization: true,
190
191
  });
191
192
  if (parsed.verbose)
192
193
  options.verbose = true;
package/dist/pack.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, readdirSync, statSync } from 'fs';
2
+ import { readFile } from 'fs/promises';
2
3
  import { extname, join, relative, resolve, sep } from 'path';
3
4
  function* collectFilesGenerator(paths) {
4
5
  for (const p of paths) {
@@ -158,30 +159,46 @@ export async function packPathsGenerator(paths, baseDir, onProgress) {
158
159
  indexHeader.writeUInt32BE(0x524f5849, 0);
159
160
  indexHeader.writeUInt32BE(indexBuf.length, 4);
160
161
  yield Buffer.concat([indexHeader, indexBuf]);
161
- let currentBuffer = Buffer.alloc(0);
162
162
  let readSoFar = 0;
163
- for (let i = 0; i < files.length; i++) {
164
- const f = files[i];
165
- const rel = relative(base, f).split(sep).join('/');
166
- const content = readFileSync(f);
167
- const nameBuf = Buffer.from(rel, 'utf8');
168
- const nameLen = Buffer.alloc(2);
169
- nameLen.writeUInt16BE(nameBuf.length, 0);
170
- const sizeBuf = Buffer.alloc(8);
171
- sizeBuf.writeBigUInt64BE(BigInt(content.length), 0);
172
- const entry = Buffer.concat([nameLen, nameBuf, sizeBuf, content]);
173
- if (currentBuffer.length + entry.length > BLOCK_SIZE &&
174
- currentBuffer.length > 0) {
175
- yield currentBuffer;
176
- currentBuffer = Buffer.alloc(0);
163
+ const BATCH_SIZE = 1000;
164
+ const chunks = [];
165
+ let chunkSize = 0;
166
+ for (let batchStart = 0; batchStart < files.length; batchStart += BATCH_SIZE) {
167
+ const batchEnd = Math.min(batchStart + BATCH_SIZE, files.length);
168
+ const batchFiles = files.slice(batchStart, batchEnd);
169
+ const contentPromises = batchFiles.map(async (f) => {
170
+ try {
171
+ return await readFile(f);
172
+ }
173
+ catch (e) {
174
+ return Buffer.alloc(0);
175
+ }
176
+ });
177
+ const contents = await Promise.all(contentPromises);
178
+ for (let i = 0; i < batchFiles.length; i++) {
179
+ const f = batchFiles[i];
180
+ const rel = relative(base, f).split(sep).join('/');
181
+ const content = contents[i];
182
+ const nameBuf = Buffer.from(rel, 'utf8');
183
+ const nameLen = Buffer.alloc(2);
184
+ nameLen.writeUInt16BE(nameBuf.length, 0);
185
+ const sizeBuf = Buffer.alloc(8);
186
+ sizeBuf.writeBigUInt64BE(BigInt(content.length), 0);
187
+ const entry = Buffer.concat([nameLen, nameBuf, sizeBuf, content]);
188
+ chunks.push(entry);
189
+ chunkSize += entry.length;
190
+ if (chunkSize >= BLOCK_SIZE) {
191
+ yield Buffer.concat(chunks);
192
+ chunks.length = 0;
193
+ chunkSize = 0;
194
+ }
195
+ readSoFar += content.length;
196
+ if (onProgress)
197
+ onProgress(readSoFar, totalSize, rel);
177
198
  }
178
- currentBuffer = Buffer.concat([currentBuffer, entry]);
179
- readSoFar += content.length;
180
- if (onProgress)
181
- onProgress(readSoFar, totalSize, rel);
182
199
  }
183
- if (currentBuffer.length > 0) {
184
- yield currentBuffer;
200
+ if (chunks.length > 0) {
201
+ yield Buffer.concat(chunks);
185
202
  }
186
203
  }
187
204
  return { index, stream: streamGenerator(), totalSize };
Binary file
@@ -53,7 +53,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
53
53
  }
54
54
  if (opts.onProgress)
55
55
  opts.onProgress({ phase: 'compress_start', total: totalLen });
56
- let payload = await parallelZstdCompress(payloadInput, 15, (loaded, total) => {
56
+ const compressionLevel = opts.compressionLevel ?? 3;
57
+ let payload = await parallelZstdCompress(payloadInput, compressionLevel, (loaded, total) => {
57
58
  if (opts.onProgress) {
58
59
  opts.onProgress({
59
60
  phase: 'compress_progress',
@@ -253,71 +254,35 @@ export async function encodeBinaryToPng(input, opts = {}) {
253
254
  const scale = 1;
254
255
  const width = logicalWidth * scale;
255
256
  const height = logicalHeight * scale;
256
- const LARGE_IMAGE_PIXELS = 50000000;
257
+ const LARGE_IMAGE_PIXELS = 10000000;
257
258
  const useManualPng = width * height > LARGE_IMAGE_PIXELS || !!process.env.ROX_FAST_PNG;
258
259
  let raw;
259
260
  let stride = 0;
260
261
  if (useManualPng) {
261
262
  stride = width * 3 + 1;
262
263
  raw = Buffer.alloc(height * stride);
264
+ const flatData = Buffer.concat(dataWithMarkers);
265
+ const markerEndBytes = Buffer.alloc(MARKER_END.length * 3);
266
+ for (let i = 0; i < MARKER_END.length; i++) {
267
+ markerEndBytes[i * 3] = MARKER_END[i].r;
268
+ markerEndBytes[i * 3 + 1] = MARKER_END[i].g;
269
+ markerEndBytes[i * 3 + 2] = MARKER_END[i].b;
270
+ }
271
+ const totalDataBytes = logicalWidth * logicalHeight * 3;
272
+ const fullData = Buffer.alloc(totalDataBytes);
273
+ const markerStartPos = (logicalHeight - 1) * logicalWidth * 3 +
274
+ (logicalWidth - MARKER_END.length) * 3;
275
+ flatData.copy(fullData, 0, 0, Math.min(flatData.length, markerStartPos));
276
+ markerEndBytes.copy(fullData, markerStartPos);
277
+ for (let row = 0; row < height; row++) {
278
+ raw[row * stride] = 0;
279
+ fullData.copy(raw, row * stride + 1, row * width * 3, (row + 1) * width * 3);
280
+ }
263
281
  }
264
282
  else {
265
283
  raw = Buffer.alloc(width * height * bytesPerPixel);
266
- }
267
- let currentBufIdx = 0;
268
- let currentBufOffset = 0;
269
- const getNextByte = () => {
270
- while (currentBufIdx < dataWithMarkers.length) {
271
- const buf = dataWithMarkers[currentBufIdx];
272
- if (currentBufOffset < buf.length) {
273
- return buf[currentBufOffset++];
274
- }
275
- currentBufIdx++;
276
- currentBufOffset = 0;
277
- }
278
- return 0;
279
- };
280
- for (let ly = 0; ly < logicalHeight; ly++) {
281
- if (useManualPng) {
282
- for (let sy = 0; sy < scale; sy++) {
283
- const py = ly * scale + sy;
284
- raw[py * stride] = 0;
285
- }
286
- }
287
- for (let lx = 0; lx < logicalWidth; lx++) {
288
- const linearIdx = ly * logicalWidth + lx;
289
- let r = 0, g = 0, b = 0;
290
- if (ly === logicalHeight - 1 &&
291
- lx >= logicalWidth - MARKER_END.length) {
292
- const markerIdx = lx - (logicalWidth - MARKER_END.length);
293
- r = MARKER_END[markerIdx].r;
294
- g = MARKER_END[markerIdx].g;
295
- b = MARKER_END[markerIdx].b;
296
- }
297
- else if (linearIdx < dataPixels) {
298
- r = getNextByte();
299
- g = getNextByte();
300
- b = getNextByte();
301
- }
302
- for (let sy = 0; sy < scale; sy++) {
303
- for (let sx = 0; sx < scale; sx++) {
304
- const px = lx * scale + sx;
305
- const py = ly * scale + sy;
306
- if (useManualPng) {
307
- const dstIdx = py * stride + 1 + px * 3;
308
- raw[dstIdx] = r;
309
- raw[dstIdx + 1] = g;
310
- raw[dstIdx + 2] = b;
311
- }
312
- else {
313
- const dstIdx = (py * width + px) * 3;
314
- raw[dstIdx] = r;
315
- raw[dstIdx + 1] = g;
316
- raw[dstIdx + 2] = b;
317
- }
318
- }
319
- }
320
- }
284
+ const flatData = Buffer.concat(dataWithMarkers);
285
+ flatData.copy(raw, 0, 0, Math.min(flatData.length, raw.length));
321
286
  }
322
287
  payload.length = 0;
323
288
  dataWithMarkers.length = 0;
@@ -344,9 +309,9 @@ export async function encodeBinaryToPng(input, opts = {}) {
344
309
  if (opts.onProgress)
345
310
  opts.onProgress({ phase: 'png_compress', loaded: 0, total: 100 });
346
311
  const idatData = zlib.deflateSync(scanlinesData, {
347
- level: 3,
312
+ level: 0,
348
313
  memLevel: 8,
349
- strategy: zlib.constants.Z_DEFAULT_STRATEGY,
314
+ strategy: zlib.constants.Z_FILTERED,
350
315
  });
351
316
  raw = Buffer.alloc(0);
352
317
  const ihdrData = Buffer.alloc(13);
@@ -405,6 +370,10 @@ export async function encodeBinaryToPng(input, opts = {}) {
405
370
  raw = Buffer.alloc(0);
406
371
  if (opts.onProgress)
407
372
  opts.onProgress({ phase: 'png_compress', loaded: 100, total: 100 });
373
+ if (opts.skipOptimization) {
374
+ progressBar?.stop();
375
+ return bufScr;
376
+ }
408
377
  if (opts.onProgress)
409
378
  opts.onProgress({ phase: 'optimizing', loaded: 0, total: 100 });
410
379
  try {
@@ -0,0 +1 @@
1
+ export declare function encodeWithRustCLI(inputPath: string, outputPath: string, compressionLevel?: number): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ export async function encodeWithRustCLI(inputPath, outputPath, compressionLevel = 3) {
8
+ const cliPath = join(__dirname, '..', 'dist', 'roxify-cli');
9
+ if (!existsSync(cliPath)) {
10
+ throw new Error('Rust CLI binary not found. Run: npm run build:native');
11
+ }
12
+ return new Promise((resolve, reject) => {
13
+ const proc = spawn(cliPath, [
14
+ 'encode',
15
+ inputPath,
16
+ outputPath,
17
+ '--level',
18
+ String(compressionLevel),
19
+ ]);
20
+ let stderr = '';
21
+ proc.stderr.on('data', (data) => {
22
+ stderr += data.toString();
23
+ });
24
+ proc.on('error', (err) => {
25
+ reject(new Error(`Failed to spawn Rust CLI: ${err.message}`));
26
+ });
27
+ proc.on('close', (code) => {
28
+ if (code === 0) {
29
+ resolve();
30
+ }
31
+ else {
32
+ reject(new Error(`Rust CLI exited with code ${code}: ${stderr}`));
33
+ }
34
+ });
35
+ });
36
+ }
@@ -3,6 +3,7 @@
3
3
  import { PackedFile } from '../pack.js';
4
4
  export interface EncodeOptions {
5
5
  compression?: 'zstd';
6
+ compressionLevel?: number;
6
7
  passphrase?: string;
7
8
  name?: string;
8
9
  mode?: 'screenshot';
@@ -15,6 +16,7 @@ export interface EncodeOptions {
15
16
  name: string;
16
17
  size: number;
17
18
  }>;
19
+ skipOptimization?: boolean;
18
20
  onProgress?: (info: {
19
21
  phase: string;
20
22
  loaded?: number;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxify",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Encode binary data into PNG images and decode them back. CLI and programmatic API with native Rust acceleration.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,7 +19,8 @@
19
19
  "scripts": {
20
20
  "build": "tsc",
21
21
  "build:native": "cargo build --release --lib && cp target/release/libroxify_native.so libroxify_native.node",
22
- "build:all": "npm run build:native && npm run build",
22
+ "build:cli": "cargo build --release --bin roxify_native && cp target/release/roxify_native dist/roxify-cli",
23
+ "build:all": "npm run build:native && npm run build && npm run build:cli",
23
24
  "prepublishOnly": "npm run build:all",
24
25
  "test": "node test/roundtrip.js && node test/pixel-fallback-preview.js && node test/size-fallback-choice.js && node test/screenshot-roundtrip.js && node test/screenshot-fallback.js",
25
26
  "cli": "node dist/cli.js"