roxify 1.1.12 → 1.2.1

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/README.md CHANGED
@@ -103,3 +103,15 @@ const pngBuffer = await encodeBinaryToPng(inputBuffer, {
103
103
  ## License
104
104
 
105
105
  This package is proprietary (UNLICENSED). The repository remains private; the package is published to npm for distribution. If there is significant community interest, it may be open-sourced in the future.
106
+
107
+ ## Minimal PNG container (minpng) 🔧
108
+
109
+ This library includes a compact encoder/decoder that targets the smallest possible PNG container while guaranteeing pixel-perfect recovery from screenshots when no lossy filtering is applied.
110
+
111
+ - Inputs must be raw RGB 8-bit buffers (no alpha).
112
+ - Transformations used: Paeth 2D predictor (left/top), RGB decorrelation (G, R−G, B−G), zigzag traversal.
113
+ - Compression: Zstd at maximum compression level.
114
+ - Output: neutral PNG (no ICC/gamma/alpha), all data mapped to RGB bytes only.
115
+ - The decoder searches for start/end markers and a compact header embedded in pixels to perform a reliable, deterministic roundtrip.
116
+
117
+ Use `encodeMinPng(rgbBuf, width, height)` and `decodeMinPng(pngBuf)` from the public API (`roxify`).
package/dist/cli.js CHANGED
@@ -145,29 +145,55 @@ async function encodeCommand(args) {
145
145
  try {
146
146
  let inputBuffer;
147
147
  let displayName;
148
- if (inputPaths.length > 1) {
149
- console.log(`Packing ${inputPaths.length} inputs...`);
150
- const bar = new cliProgress.SingleBar({
151
- format: ' {bar} {percentage}% | {step} | {mbValue}/{mbTotal} MB',
152
- }, cliProgress.Presets.shades_classic);
153
- let totalBytes = 0;
154
- const onProgress = (readBytes, total) => {
155
- if (totalBytes === 0) {
156
- totalBytes = total;
157
- bar.start(totalBytes, 0, {
158
- step: 'Reading files',
159
- mbValue: '0.00',
160
- mbTotal: (totalBytes / 1024 / 1024).toFixed(2),
161
- });
162
- }
163
- bar.update(readBytes, {
164
- step: 'Reading files',
165
- mbValue: (readBytes / 1024 / 1024).toFixed(2),
148
+ const encodeBar = new cliProgress.SingleBar({
149
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
150
+ }, cliProgress.Presets.shades_classic);
151
+ let barStarted = false;
152
+ const startEncode = Date.now();
153
+ let currentEncodeStep = 'Starting';
154
+ let displayedPct = 0;
155
+ let targetPct = 0;
156
+ const TICK_MS = 100;
157
+ const PCT_STEP = 1;
158
+ const encodeHeartbeat = setInterval(() => {
159
+ const elapsed = Date.now() - startEncode;
160
+ if (!barStarted) {
161
+ encodeBar.start(100, Math.floor(displayedPct), {
162
+ step: currentEncodeStep,
163
+ elapsed: '0',
166
164
  });
167
- };
165
+ barStarted = true;
166
+ }
167
+ if (displayedPct < targetPct) {
168
+ displayedPct = Math.min(displayedPct + PCT_STEP, targetPct);
169
+ }
170
+ else if (displayedPct < 99) {
171
+ displayedPct = Math.min(displayedPct + PCT_STEP, 99);
172
+ }
173
+ encodeBar.update(Math.floor(displayedPct), {
174
+ step: currentEncodeStep,
175
+ elapsed: String(Math.floor(elapsed / 1000)),
176
+ });
177
+ }, TICK_MS);
178
+ let totalBytes = 0;
179
+ let lastShownFile;
180
+ const onProgress = (readBytes, total, currentFile) => {
181
+ if (totalBytes === 0)
182
+ totalBytes = total;
183
+ const packPct = Math.floor((readBytes / totalBytes) * 25);
184
+ targetPct = Math.max(targetPct, packPct);
185
+ if (currentFile && currentFile !== lastShownFile) {
186
+ lastShownFile = currentFile;
187
+ }
188
+ currentEncodeStep = currentFile
189
+ ? `Reading files: ${currentFile}`
190
+ : 'Reading files';
191
+ };
192
+ if (inputPaths.length > 1) {
193
+ currentEncodeStep = 'Reading files';
168
194
  const packResult = packPaths(inputPaths, undefined, onProgress);
169
- bar.stop();
170
195
  inputBuffer = packResult.buf;
196
+ console.log('');
171
197
  console.log(`Packed ${packResult.list.length} files -> ${(inputBuffer.length /
172
198
  1024 /
173
199
  1024).toFixed(2)} MB`);
@@ -180,8 +206,10 @@ async function encodeCommand(args) {
180
206
  const st = statSync(resolvedInput);
181
207
  if (st.isDirectory()) {
182
208
  console.log(`Packing directory...`);
183
- const packResult = packPaths([resolvedInput], resolvedInput);
209
+ currentEncodeStep = 'Reading files';
210
+ const packResult = packPaths([resolvedInput], resolvedInput, onProgress);
184
211
  inputBuffer = packResult.buf;
212
+ console.log('');
185
213
  console.log(`Packed ${packResult.list.length} files -> ${(inputBuffer.length /
186
214
  1024 /
187
215
  1024).toFixed(2)} MB`);
@@ -208,72 +236,68 @@ async function encodeCommand(args) {
208
236
  options.encrypt = parsed.encrypt || 'aes';
209
237
  }
210
238
  console.log(`Encoding ${displayName} -> ${resolvedOutput}\n`);
211
- const encodeBar = new cliProgress.SingleBar({
212
- format: ' {bar} {percentage}% | {step} | {elapsed}s',
213
- }, cliProgress.Presets.shades_classic);
214
- encodeBar.start(100, 0, {
215
- step: 'Starting',
216
- elapsed: '0',
217
- });
218
- const startEncode = Date.now();
219
- let currentEncodePct = 0;
220
- let currentEncodeStep = 'Starting';
221
- const encodeHeartbeat = setInterval(() => {
222
- encodeBar.update(Math.floor(currentEncodePct), {
223
- step: currentEncodeStep,
224
- elapsed: String(Math.floor((Date.now() - startEncode) / 1000)),
225
- });
226
- }, 1000);
227
239
  options.onProgress = (info) => {
228
- let pct = 0;
229
240
  let stepLabel = 'Processing';
241
+ let pct = 0;
230
242
  if (info.phase === 'compress_start') {
231
- pct = 5;
243
+ pct = 25;
232
244
  stepLabel = 'Compressing';
233
245
  }
234
246
  else if (info.phase === 'compress_progress') {
235
- pct = 5 + Math.floor((info.loaded / info.total) * 45);
247
+ pct = 25 + Math.floor((info.loaded / info.total) * 50);
236
248
  stepLabel = 'Compressing';
237
249
  }
238
250
  else if (info.phase === 'compress_done') {
239
- pct = 50;
251
+ pct = 75;
240
252
  stepLabel = 'Compressed';
241
253
  }
242
254
  else if (info.phase === 'encrypt_start') {
243
- pct = 60;
255
+ pct = 76;
244
256
  stepLabel = 'Encrypting';
245
257
  }
246
258
  else if (info.phase === 'encrypt_done') {
247
- pct = 75;
259
+ pct = 80;
248
260
  stepLabel = 'Encrypted';
249
261
  }
250
262
  else if (info.phase === 'meta_prep_done') {
251
- pct = 80;
263
+ pct = 82;
252
264
  stepLabel = 'Preparing';
253
265
  }
254
266
  else if (info.phase === 'png_gen') {
255
- pct = 90;
267
+ if (info.loaded !== undefined && info.total !== undefined) {
268
+ pct = 82 + Math.floor((info.loaded / info.total) * 16);
269
+ }
270
+ else {
271
+ pct = 98;
272
+ }
256
273
  stepLabel = 'Generating PNG';
257
274
  }
275
+ else if (info.phase === 'optimizing') {
276
+ if (info.loaded !== undefined && info.total !== undefined) {
277
+ pct = 82 + Math.floor((info.loaded / info.total) * 18);
278
+ }
279
+ else {
280
+ pct = 98;
281
+ }
282
+ stepLabel = 'Optimizing PNG';
283
+ }
258
284
  else if (info.phase === 'done') {
259
285
  pct = 100;
260
286
  stepLabel = 'Done';
261
287
  }
262
- currentEncodePct = pct;
288
+ targetPct = Math.max(targetPct, pct);
263
289
  currentEncodeStep = stepLabel;
264
- encodeBar.update(Math.floor(pct), {
265
- step: stepLabel,
266
- elapsed: String(Math.floor((Date.now() - startEncode) / 1000)),
267
- });
268
290
  };
269
291
  const output = await encodeBinaryToPng(inputBuffer, options);
270
292
  const encodeTime = Date.now() - startEncode;
271
293
  clearInterval(encodeHeartbeat);
272
- encodeBar.update(100, {
273
- step: 'done',
274
- elapsed: String(Math.floor(encodeTime / 1000)),
275
- });
276
- encodeBar.stop();
294
+ if (barStarted) {
295
+ encodeBar.update(100, {
296
+ step: 'done',
297
+ elapsed: String(Math.floor(encodeTime / 1000)),
298
+ });
299
+ encodeBar.stop();
300
+ }
277
301
  writeFileSync(resolvedOutput, output);
278
302
  const outputSize = (output.length / 1024 / 1024).toFixed(2);
279
303
  const inputSize = (inputBuffer.length / 1024 / 1024).toFixed(2);
@@ -321,52 +345,61 @@ async function decodeCommand(args) {
321
345
  const decodeBar = new cliProgress.SingleBar({
322
346
  format: ' {bar} {percentage}% | {step} | {elapsed}s',
323
347
  }, cliProgress.Presets.shades_classic);
324
- decodeBar.start(100, 0, {
325
- step: 'Decoding',
326
- elapsed: '0',
327
- });
348
+ let barStarted = false;
328
349
  const startDecode = Date.now();
329
- let currentPct = 50;
350
+ let currentPct = 0;
351
+ let targetPct = 0;
330
352
  let currentStep = 'Decoding';
331
353
  const heartbeat = setInterval(() => {
332
- decodeBar.update(Math.floor(currentPct), {
333
- step: currentStep,
334
- elapsed: String(Math.floor((Date.now() - startDecode) / 1000)),
335
- });
336
- }, 1000);
354
+ if (currentPct < targetPct) {
355
+ currentPct = Math.min(currentPct + 2, targetPct);
356
+ }
357
+ if (!barStarted && targetPct > 0) {
358
+ decodeBar.start(100, Math.floor(currentPct), {
359
+ step: currentStep,
360
+ elapsed: String(Math.floor((Date.now() - startDecode) / 1000)),
361
+ });
362
+ barStarted = true;
363
+ }
364
+ else if (barStarted) {
365
+ decodeBar.update(Math.floor(currentPct), {
366
+ step: currentStep,
367
+ elapsed: String(Math.floor((Date.now() - startDecode) / 1000)),
368
+ });
369
+ }
370
+ }, 100);
337
371
  options.onProgress = (info) => {
338
372
  if (info.phase === 'decompress_start') {
339
- currentPct = 50;
373
+ targetPct = 50;
340
374
  currentStep = 'Decompressing';
341
375
  }
342
376
  else if (info.phase === 'decompress_progress' &&
343
377
  info.loaded &&
344
378
  info.total) {
345
- currentPct = 50 + Math.floor((info.loaded / info.total) * 40);
379
+ targetPct = 50 + Math.floor((info.loaded / info.total) * 40);
346
380
  currentStep = `Decompressing (${info.loaded}/${info.total})`;
347
381
  }
348
382
  else if (info.phase === 'decompress_done') {
349
- currentPct = 90;
383
+ targetPct = 90;
350
384
  currentStep = 'Decompressed';
351
385
  }
352
386
  else if (info.phase === 'done') {
353
- currentPct = 100;
387
+ targetPct = 100;
354
388
  currentStep = 'Done';
355
389
  }
356
- decodeBar.update(Math.floor(currentPct), {
357
- step: currentStep,
358
- elapsed: String(Math.floor((Date.now() - startDecode) / 1000)),
359
- });
360
390
  };
361
391
  const inputBuffer = readFileSync(resolvedInput);
362
392
  const result = await decodePngToBinary(inputBuffer, options);
363
393
  const decodeTime = Date.now() - startDecode;
364
394
  clearInterval(heartbeat);
365
- decodeBar.update(100, {
366
- step: 'done',
367
- elapsed: String(Math.floor(decodeTime / 1000)),
368
- });
369
- decodeBar.stop();
395
+ if (barStarted) {
396
+ currentPct = 100;
397
+ decodeBar.update(100, {
398
+ step: 'done',
399
+ elapsed: String(Math.floor(decodeTime / 1000)),
400
+ });
401
+ decodeBar.stop();
402
+ }
370
403
  if (result.files) {
371
404
  const baseDir = parsed.output || outputPath || '.';
372
405
  for (const file of result.files) {
package/dist/index.d.ts CHANGED
@@ -15,80 +15,22 @@ export declare class DataFormatError extends Error {
15
15
  * @public
16
16
  */
17
17
  export interface EncodeOptions {
18
- /**
19
- * Compression algorithm to use.
20
- * - `'zstd'`: Zstandard compression (maximum compression for smallest files)
21
- * @defaultValue `'zstd'`
22
- */
23
18
  compression?: 'zstd';
24
- /**
25
- * Passphrase for encryption. If provided without `encrypt` option, defaults to AES-256-GCM.
26
- */
27
19
  passphrase?: string;
28
- /**
29
- * Original filename to embed in the encoded data.
30
- */
31
20
  name?: string;
32
- /**
33
- * Encoding mode to use:
34
- * - `'compact'`: Minimal 1x1 PNG with data in custom chunk (smallest, fastest)
35
- * - `'pixel'`: Encode data as RGB pixel values
36
- * - `'screenshot'`: Optimized for screenshot-like appearance (recommended)
37
- * @defaultValue `'screenshot'`
38
- */
39
21
  mode?: 'compact' | 'pixel' | 'screenshot';
40
- /**
41
- * Encryption method:
42
- * - `'auto'`: Try all methods and pick smallest result
43
- * - `'aes'`: AES-256-GCM authenticated encryption (secure)
44
- * - `'xor'`: Simple XOR cipher (legacy, not recommended)
45
- * - `'none'`: No encryption
46
- * @defaultValue `'aes'` when passphrase is provided
47
- */
48
22
  encrypt?: 'auto' | 'aes' | 'xor' | 'none';
49
- /**
50
- * Internal flag to skip auto-detection. Not for public use.
51
- * @internal
52
- */
53
23
  _skipAuto?: boolean;
54
- /**
55
- * Output format:
56
- * - `'auto'`: Choose best format automatically
57
- * - `'png'`: Force PNG output
58
- * - `'rox'`: Force raw ROX binary format (no PNG wrapper)
59
- * @defaultValue `'auto'`
60
- */
61
24
  output?: 'auto' | 'png' | 'rox';
62
- /**
63
- * Whether to include the filename in the encoded metadata.
64
- * @defaultValue `true`
65
- */
66
25
  includeName?: boolean;
67
- /**
68
- * Whether to include the file list in the encoded metadata for archives.
69
- * @defaultValue `false`
70
- */
71
26
  includeFileList?: boolean;
72
- /**
73
- * List of file paths for archives (used if includeFileList is true).
74
- */
75
27
  fileList?: string[];
76
- /**
77
- * Brotli compression quality (0-11).
78
- * - Lower values = faster compression, larger output
79
- * - Higher values = slower compression, smaller output
80
- * @defaultValue `1` (optimized for speed)
81
- */
82
28
  brQuality?: number;
83
29
  onProgress?: (info: {
84
30
  phase: string;
85
31
  loaded?: number;
86
32
  total?: number;
87
33
  }) => void;
88
- /**
89
- * Whether to display a progress bar in the console.
90
- * @defaultValue `false`
91
- */
92
34
  showProgress?: boolean;
93
35
  }
94
36
  /**
@@ -96,27 +38,15 @@ export interface EncodeOptions {
96
38
  * @public
97
39
  */
98
40
  export interface DecodeResult {
99
- /**
100
- * The decoded binary data.
101
- */
102
41
  buf?: Buffer;
103
- /**
104
- * Metadata extracted from the encoded image.
105
- */
106
42
  meta?: {
107
- /**
108
- * Original filename, if it was embedded during encoding.
109
- */
110
43
  name?: string;
111
44
  };
112
- /**
113
- * Extracted files, if selective extraction was requested.
114
- */
115
45
  files?: PackedFile[];
116
46
  }
47
+ export declare function optimizePngBuffer(pngBuf: Buffer, fast?: boolean): Promise<Buffer>;
117
48
  /**
118
- * Options for decoding a PNG back to binary data.
119
- * @public
49
+ * Path to write decoded output directly to disk (streamed) to avoid high memory usage.
120
50
  */
121
51
  export interface DecodeOptions {
122
52
  /**
@@ -188,6 +118,7 @@ export declare function encodeBinaryToPng(input: Buffer, opts?: EncodeOptions):
188
118
  * writeFileSync(meta?.name ?? 'decoded.txt', buf);
189
119
  */
190
120
  export declare function decodePngToBinary(pngBuf: Buffer, opts?: DecodeOptions): Promise<DecodeResult>;
121
+ export { decodeMinPng, encodeMinPng } from './minpng.js';
191
122
  export { packPaths, unpackBuffer } from './pack.js';
192
123
  /**
193
124
  * List files in a Rox PNG archive without decoding the full payload.