roxify 1.6.5 → 1.6.7

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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { DataFormatError, decodePngToBinary, encodeBinaryToPng, hasPassphraseInP
6
6
  import { packPathsGenerator, unpackBuffer } from './pack.js';
7
7
  import * as cliProgress from './stub-progress.js';
8
8
  import { encodeWithRustCLI, isRustBinaryAvailable, } from './utils/rust-cli-wrapper.js';
9
- const VERSION = '1.6.2';
9
+ const VERSION = '1.6.1';
10
10
  function getDirectorySize(dirPath) {
11
11
  let totalSize = 0;
12
12
  try {
@@ -52,22 +52,25 @@ async function readLargeFile(filePath) {
52
52
  }
53
53
  function showHelp() {
54
54
  console.log(`
55
- ROX CLI — Encode/decode binary in PNG
55
+ ROX CLI — Encode/decode binary in PNG or WAV
56
56
 
57
57
  Usage:
58
58
  npx rox <command> [options]
59
59
 
60
60
  Commands:
61
- encode <input>... [output] Encode one or more files/directories into a PNG
62
- decode <input> [output] Decode PNG to original file
63
- list <input> List files in a Rox PNG archive
64
- havepassphrase <input> Check whether the PNG requires a passphrase
61
+ encode <input>... [output] Encode one or more files/directories
62
+ decode <input> [output] Decode PNG/WAV to original file
63
+ list <input> List files in a Rox archive
64
+ havepassphrase <input> Check whether the archive requires a passphrase
65
65
 
66
66
  Options:
67
+ --image Use PNG container (default)
68
+ --sound Use WAV audio container (smaller overhead, faster)
67
69
  -p, --passphrase <pass> Use passphrase (AES-256-GCM)
68
70
  -m, --mode <mode> Mode: screenshot (default)
69
71
  -e, --encrypt <type> auto|aes|xor|none
70
72
  --no-compress Disable compression
73
+ --dict <file> Use zstd dictionary when compressing
71
74
  --force-ts Force TypeScript encoder (slower but supports encryption)
72
75
  -o, --output <path> Output file path
73
76
  -s, --sizes Show file sizes in 'list' output (default)
@@ -115,6 +118,14 @@ function parseArgs(args) {
115
118
  parsed.forceTs = true;
116
119
  i++;
117
120
  }
121
+ else if (key === 'sound') {
122
+ parsed.container = 'sound';
123
+ i++;
124
+ }
125
+ else if (key === 'image') {
126
+ parsed.container = 'image';
127
+ i++;
128
+ }
118
129
  else if (key === 'debug-dir') {
119
130
  parsed.debugDir = args[i + 1];
120
131
  i += 2;
@@ -123,6 +134,10 @@ function parseArgs(args) {
123
134
  parsed.files = args[i + 1].split(',');
124
135
  i += 2;
125
136
  }
137
+ else if (key === 'dict') {
138
+ parsed.dict = args[i + 1];
139
+ i += 2;
140
+ }
126
141
  else {
127
142
  const value = args[i + 1];
128
143
  parsed[key] = value;
@@ -175,15 +190,11 @@ function parseArgs(args) {
175
190
  }
176
191
  async function encodeCommand(args) {
177
192
  const parsed = parseArgs(args);
178
- const inputPaths = parsed.output
179
- ? parsed._
180
- : parsed._.length > 1
181
- ? parsed._.slice(0, -1)
193
+ const inputPaths = parsed.output ? parsed._
194
+ : parsed._.length > 1 ? parsed._.slice(0, -1)
182
195
  : parsed._;
183
- const outputPath = parsed.output
184
- ? undefined
185
- : parsed._.length > 1
186
- ? parsed._[parsed._.length - 1]
196
+ const outputPath = parsed.output ? undefined
197
+ : parsed._.length > 1 ? parsed._[parsed._.length - 1]
187
198
  : undefined;
188
199
  const firstInput = inputPaths[0];
189
200
  if (!firstInput) {
@@ -200,12 +211,14 @@ async function encodeCommand(args) {
200
211
  safeCwd = '/';
201
212
  }
202
213
  const resolvedInputs = inputPaths.map((p) => resolve(safeCwd, p));
214
+ const containerMode = parsed.container || 'image'; // default: image (PNG)
215
+ const containerExt = containerMode === 'sound' ? '.wav' : '.png';
203
216
  let outputName = inputPaths.length === 1 ? basename(firstInput) : 'archive';
204
217
  if (inputPaths.length === 1 && !statSync(resolvedInputs[0]).isDirectory()) {
205
- outputName = outputName.replace(/(\.[^.]+)?$/, '.png');
218
+ outputName = outputName.replace(/(\.[^.]+)?$/, containerExt);
206
219
  }
207
220
  else {
208
- outputName += '.png';
221
+ outputName += containerExt;
209
222
  }
210
223
  let resolvedOutput;
211
224
  try {
@@ -240,7 +253,7 @@ async function encodeCommand(args) {
240
253
  catch (e) {
241
254
  anyInputDir = false;
242
255
  }
243
- if (isRustBinaryAvailable() && !parsed.forceTs && !anyInputDir) {
256
+ if (isRustBinaryAvailable() && !parsed.forceTs && containerMode !== 'sound') {
244
257
  try {
245
258
  console.log(`Encoding to ${resolvedOutput} (Using native Rust encoder)\n`);
246
259
  const startTime = Date.now();
@@ -257,7 +270,7 @@ async function encodeCommand(args) {
257
270
  }, 500);
258
271
  const encryptType = parsed.encrypt === 'xor' ? 'xor' : 'aes';
259
272
  const fileName = basename(inputPaths[0]);
260
- await encodeWithRustCLI(inputPaths.length === 1 ? resolvedInputs[0] : resolvedInputs[0], resolvedOutput, 12, parsed.passphrase, encryptType, fileName);
273
+ await encodeWithRustCLI(inputPaths.length === 1 ? resolvedInputs[0] : resolvedInputs[0], resolvedOutput, 19, parsed.passphrase, encryptType, fileName);
261
274
  clearInterval(progressInterval);
262
275
  const encodeTime = Date.now() - startTime;
263
276
  encodeBar.update(100, {
@@ -290,6 +303,15 @@ async function encodeCommand(args) {
290
303
  }
291
304
  }
292
305
  let options = {};
306
+ if (parsed.dict) {
307
+ try {
308
+ options.dict = readFileSync(parsed.dict);
309
+ }
310
+ catch (e) {
311
+ console.error(`failed to read dictionary file: ${parsed.dict}`);
312
+ process.exit(1);
313
+ }
314
+ }
293
315
  try {
294
316
  const encodeBar = new cliProgress.SingleBar({
295
317
  format: ' {bar} {percentage}% | {step} | {elapsed}s',
@@ -326,8 +348,9 @@ async function encodeCommand(args) {
326
348
  mode,
327
349
  name: parsed.outputName || 'archive',
328
350
  skipOptimization: false,
329
- compressionLevel: 12,
351
+ compressionLevel: 19,
330
352
  outputFormat: 'auto',
353
+ container: containerMode,
331
354
  });
332
355
  if (parsed.verbose)
333
356
  options.verbose = true;
@@ -337,7 +360,7 @@ async function encodeCommand(args) {
337
360
  options.passphrase = parsed.passphrase;
338
361
  options.encrypt = parsed.encrypt || 'aes';
339
362
  }
340
- console.log(`Encoding to ${resolvedOutput} (Mode: ${mode})\n`);
363
+ console.log(`Encoding to ${resolvedOutput} (Mode: ${mode}, Container: ${containerMode === 'sound' ? 'WAV' : 'PNG'})\n`);
341
364
  let inputData;
342
365
  let inputSizeVal = 0;
343
366
  let displayName;
@@ -347,9 +370,8 @@ async function encodeCommand(args) {
347
370
  totalBytes = total;
348
371
  const packPct = Math.floor((readBytes / totalBytes) * 25);
349
372
  targetPct = Math.max(targetPct, packPct);
350
- currentEncodeStep = currentFile
351
- ? `Reading files: ${currentFile}`
352
- : 'Reading files';
373
+ currentEncodeStep =
374
+ currentFile ? `Reading files: ${currentFile}` : 'Reading files';
353
375
  };
354
376
  if (inputPaths.length > 1) {
355
377
  currentEncodeStep = 'Reading files';
@@ -511,6 +533,15 @@ async function decodeCommand(args) {
511
533
  if (parsed.files) {
512
534
  options.files = parsed.files;
513
535
  }
536
+ if (parsed.dict) {
537
+ try {
538
+ options.dict = readFileSync(parsed.dict);
539
+ }
540
+ catch (e) {
541
+ console.error(`Failed to read dictionary file: ${parsed.dict}`);
542
+ process.exit(1);
543
+ }
544
+ }
514
545
  console.log(' ');
515
546
  console.log(`Decoding...`);
516
547
  console.log(' ');
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ export * from './utils/audio.js';
1
2
  export * from './utils/constants.js';
2
3
  export * from './utils/crc.js';
3
4
  export * from './utils/decoder.js';
5
+ export * from './utils/ecc.js';
4
6
  export * from './utils/encoder.js';
5
7
  export * from './utils/errors.js';
6
8
  export * from './utils/helpers.js';
@@ -8,7 +10,9 @@ export * from './utils/inspection.js';
8
10
  export { native } from './utils/native.js';
9
11
  export * from './utils/optimization.js';
10
12
  export * from './utils/reconstitution.js';
11
- export { encodeWithRustCLI, isRustBinaryAvailable, } from './utils/rust-cli-wrapper.js';
13
+ export * from './utils/robust-audio.js';
14
+ export * from './utils/robust-image.js';
15
+ export { encodeWithRustCLI, isRustBinaryAvailable } from './utils/rust-cli-wrapper.js';
12
16
  export * from './utils/types.js';
13
17
  export * from './utils/zstd.js';
14
18
  export { packPaths, packPathsToParts, unpackBuffer } from './pack.js';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
+ export * from './utils/audio.js';
1
2
  export * from './utils/constants.js';
2
3
  export * from './utils/crc.js';
3
4
  export * from './utils/decoder.js';
5
+ export * from './utils/ecc.js';
4
6
  export * from './utils/encoder.js';
5
7
  export * from './utils/errors.js';
6
8
  export * from './utils/helpers.js';
@@ -8,7 +10,9 @@ export * from './utils/inspection.js';
8
10
  export { native } from './utils/native.js';
9
11
  export * from './utils/optimization.js';
10
12
  export * from './utils/reconstitution.js';
11
- export { encodeWithRustCLI, isRustBinaryAvailable, } from './utils/rust-cli-wrapper.js';
13
+ export * from './utils/robust-audio.js';
14
+ export * from './utils/robust-image.js';
15
+ export { encodeWithRustCLI, isRustBinaryAvailable } from './utils/rust-cli-wrapper.js';
12
16
  export * from './utils/types.js';
13
17
  export * from './utils/zstd.js';
14
18
  export { packPaths, packPathsToParts, unpackBuffer } from './pack.js';
package/dist/pack.d.ts CHANGED
@@ -27,3 +27,4 @@ export declare function packPathsGenerator(paths: string[], baseDir?: string, on
27
27
  stream: AsyncGenerator<Buffer>;
28
28
  totalSize: number;
29
29
  }>;
30
+ export declare function isTar(buf: Buffer): boolean;
package/dist/pack.js CHANGED
@@ -58,6 +58,9 @@ export function packPaths(paths, baseDir, onProgress) {
58
58
  export function unpackBuffer(buf, fileList) {
59
59
  if (buf.length < 8)
60
60
  return null;
61
+ if (isTar(buf)) {
62
+ return unpackTar(buf, fileList);
63
+ }
61
64
  const magic = buf.readUInt32BE(0);
62
65
  if (magic === 0x524f5849) {
63
66
  const indexLen = buf.readUInt32BE(4);
@@ -65,9 +68,7 @@ export function unpackBuffer(buf, fileList) {
65
68
  const index = JSON.parse(indexBuf.toString('utf8'));
66
69
  const dataStart = 8 + indexLen;
67
70
  const files = [];
68
- const entriesToProcess = fileList
69
- ? index.filter((e) => fileList.includes(e.path))
70
- : index;
71
+ const entriesToProcess = fileList ? index.filter((e) => fileList.includes(e.path)) : index;
71
72
  for (const entry of entriesToProcess) {
72
73
  const entryStart = dataStart + entry.offset;
73
74
  let ptr = entryStart;
@@ -203,3 +204,51 @@ export async function packPathsGenerator(paths, baseDir, onProgress) {
203
204
  }
204
205
  return { index, stream: streamGenerator(), totalSize };
205
206
  }
207
+ export function isTar(buf) {
208
+ if (buf.length < 263)
209
+ return false;
210
+ return buf.slice(257, 262).toString('ascii') === 'ustar';
211
+ }
212
+ function unpackTar(buf, fileList) {
213
+ const files = [];
214
+ let offset = 0;
215
+ while (offset + 512 <= buf.length) {
216
+ const header = buf.slice(offset, offset + 512);
217
+ let allZero = true;
218
+ for (let i = 0; i < 512; i++) {
219
+ if (header[i] !== 0) {
220
+ allZero = false;
221
+ break;
222
+ }
223
+ }
224
+ if (allZero)
225
+ break;
226
+ const nameRaw = header.slice(0, 100).toString('ascii');
227
+ const name = nameRaw.replace(/\0+$/, '');
228
+ const sizeOctal = header
229
+ .slice(124, 136)
230
+ .toString('ascii')
231
+ .replace(/\0+$/, '')
232
+ .trim();
233
+ const size = parseInt(sizeOctal, 8) || 0;
234
+ const typeFlag = header[156];
235
+ const prefix = header.slice(345, 500).toString('ascii').replace(/\0+$/, '');
236
+ const fullPath = prefix ? `${prefix}/${name}` : name;
237
+ const cleanPath = fullPath
238
+ .split('/')
239
+ .filter((c) => c && c !== '.' && c !== '..')
240
+ .join('/');
241
+ offset += 512;
242
+ if (typeFlag === 0 || typeFlag === 0x30) {
243
+ if (offset + size > buf.length)
244
+ break;
245
+ const content = buf.slice(offset, offset + size);
246
+ if (!fileList || fileList.includes(cleanPath)) {
247
+ files.push({ path: cleanPath, buf: Buffer.from(content) });
248
+ }
249
+ }
250
+ const blocks = Math.ceil(size / 512);
251
+ offset += blocks * 512;
252
+ }
253
+ return files.length > 0 ? { files } : null;
254
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * WAV container for binary data.
3
+ *
4
+ * Encodes raw bytes as 8-bit unsigned PCM mono samples (44100 Hz).
5
+ * Header is exactly 44 bytes. Total container overhead: 44 bytes (constant).
6
+ *
7
+ * Compared to PNG (stored deflate): PNG overhead grows with data size
8
+ * (zlib framing, filter bytes, chunk CRCs). WAV overhead is constant.
9
+ */
10
+ /**
11
+ * Pack raw bytes into a WAV file (8-bit PCM, mono, 44100 Hz).
12
+ * The bytes are stored directly as unsigned PCM samples.
13
+ */
14
+ export declare function bytesToWav(data: Buffer): Buffer;
15
+ /**
16
+ * Extract raw bytes from a WAV file.
17
+ * Returns the PCM data (the original bytes).
18
+ */
19
+ export declare function wavToBytes(wav: Buffer): Buffer;
20
+ /**
21
+ * Check if a buffer starts with a RIFF/WAVE header.
22
+ */
23
+ export declare function isWav(buf: Buffer): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * WAV container for binary data.
3
+ *
4
+ * Encodes raw bytes as 8-bit unsigned PCM mono samples (44100 Hz).
5
+ * Header is exactly 44 bytes. Total container overhead: 44 bytes (constant).
6
+ *
7
+ * Compared to PNG (stored deflate): PNG overhead grows with data size
8
+ * (zlib framing, filter bytes, chunk CRCs). WAV overhead is constant.
9
+ */
10
+ const WAV_HEADER_SIZE = 44;
11
+ const SAMPLE_RATE = 44100;
12
+ const BITS_PER_SAMPLE = 8;
13
+ const NUM_CHANNELS = 1;
14
+ /**
15
+ * Pack raw bytes into a WAV file (8-bit PCM, mono, 44100 Hz).
16
+ * The bytes are stored directly as unsigned PCM samples.
17
+ */
18
+ export function bytesToWav(data) {
19
+ const dataSize = data.length;
20
+ const fileSize = WAV_HEADER_SIZE - 8 + dataSize;
21
+ const byteRate = SAMPLE_RATE * NUM_CHANNELS * (BITS_PER_SAMPLE / 8);
22
+ const blockAlign = NUM_CHANNELS * (BITS_PER_SAMPLE / 8);
23
+ const wav = Buffer.alloc(WAV_HEADER_SIZE + dataSize);
24
+ let offset = 0;
25
+ // RIFF header
26
+ wav.write('RIFF', offset, 'ascii');
27
+ offset += 4;
28
+ wav.writeUInt32LE(fileSize, offset);
29
+ offset += 4;
30
+ wav.write('WAVE', offset, 'ascii');
31
+ offset += 4;
32
+ // fmt sub-chunk
33
+ wav.write('fmt ', offset, 'ascii');
34
+ offset += 4;
35
+ wav.writeUInt32LE(16, offset);
36
+ offset += 4; // sub-chunk size (PCM = 16)
37
+ wav.writeUInt16LE(1, offset);
38
+ offset += 2; // audio format (1 = PCM)
39
+ wav.writeUInt16LE(NUM_CHANNELS, offset);
40
+ offset += 2;
41
+ wav.writeUInt32LE(SAMPLE_RATE, offset);
42
+ offset += 4;
43
+ wav.writeUInt32LE(byteRate, offset);
44
+ offset += 4;
45
+ wav.writeUInt16LE(blockAlign, offset);
46
+ offset += 2;
47
+ wav.writeUInt16LE(BITS_PER_SAMPLE, offset);
48
+ offset += 2;
49
+ // data sub-chunk
50
+ wav.write('data', offset, 'ascii');
51
+ offset += 4;
52
+ wav.writeUInt32LE(dataSize, offset);
53
+ offset += 4;
54
+ data.copy(wav, offset);
55
+ return wav;
56
+ }
57
+ /**
58
+ * Extract raw bytes from a WAV file.
59
+ * Returns the PCM data (the original bytes).
60
+ */
61
+ export function wavToBytes(wav) {
62
+ if (wav.length < WAV_HEADER_SIZE) {
63
+ throw new Error('WAV data too short');
64
+ }
65
+ if (wav.toString('ascii', 0, 4) !== 'RIFF') {
66
+ throw new Error('Not a RIFF file');
67
+ }
68
+ if (wav.toString('ascii', 8, 12) !== 'WAVE') {
69
+ throw new Error('Not a WAVE file');
70
+ }
71
+ // Find the "data" sub-chunk
72
+ let offset = 12;
73
+ while (offset + 8 <= wav.length) {
74
+ const chunkId = wav.toString('ascii', offset, offset + 4);
75
+ const chunkSize = wav.readUInt32LE(offset + 4);
76
+ if (chunkId === 'data') {
77
+ const dataStart = offset + 8;
78
+ const dataEnd = dataStart + chunkSize;
79
+ if (dataEnd > wav.length) {
80
+ return wav.subarray(dataStart);
81
+ }
82
+ return wav.subarray(dataStart, dataEnd);
83
+ }
84
+ offset += 8 + chunkSize;
85
+ if (chunkSize % 2 !== 0)
86
+ offset += 1; // RIFF word alignment
87
+ }
88
+ throw new Error('data chunk not found in WAV');
89
+ }
90
+ /**
91
+ * Check if a buffer starts with a RIFF/WAVE header.
92
+ */
93
+ export function isWav(buf) {
94
+ return (buf.length >= 12 &&
95
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && // RIFF
96
+ buf[8] === 0x57 && buf[9] === 0x41 && buf[10] === 0x56 && buf[11] === 0x45 // WAVE
97
+ );
98
+ }
@@ -3,12 +3,18 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import { join } from 'path';
5
5
  import { unpackBuffer } from '../pack.js';
6
+ import { isWav, wavToBytes } from './audio.js';
6
7
  import { CHUNK_TYPE, MAGIC, MARKER_END, MARKER_START, PIXEL_MAGIC, PIXEL_MAGIC_BLOCK, PNG_HEADER, } from './constants.js';
7
8
  import { DataFormatError, IncorrectPassphraseError, PassphraseRequiredError, } from './errors.js';
8
9
  import { colorsToBytes, deltaDecode, tryDecryptIfNeeded } from './helpers.js';
9
10
  import { native } from './native.js';
10
11
  import { cropAndReconstitute } from './reconstitution.js';
12
+ import { decodeRobustAudio, isRobustAudioWav } from './robust-audio.js';
13
+ import { decodeRobustImage, isRobustImage } from './robust-image.js';
11
14
  import { parallelZstdDecompress, tryZstdDecompress } from './zstd.js';
15
+ function isColorMatch(r1, g1, b1, r2, g2, b2) {
16
+ return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) < 50;
17
+ }
12
18
  async function tryDecompress(payload, onProgress) {
13
19
  return await parallelZstdDecompress(payload, onProgress);
14
20
  }
@@ -150,6 +156,107 @@ export async function decodePngToBinary(input, opts = {}) {
150
156
  }
151
157
  if (opts.onProgress)
152
158
  opts.onProgress({ phase: 'processed' });
159
+ // ─── Robust audio detection (lossy-resilient WAV) ──────────────────────────
160
+ if (isWav(processedBuf) && isRobustAudioWav(processedBuf)) {
161
+ try {
162
+ const result = decodeRobustAudio(processedBuf);
163
+ if (opts.onProgress)
164
+ opts.onProgress({ phase: 'done' });
165
+ progressBar?.stop();
166
+ // Try unpack multi-file archive
167
+ try {
168
+ const unpack = unpackBuffer(result.data);
169
+ if (unpack && unpack.files && unpack.files.length > 0) {
170
+ return { files: unpack.files, correctedErrors: result.correctedErrors };
171
+ }
172
+ }
173
+ catch (e) { }
174
+ return { buf: result.data, correctedErrors: result.correctedErrors };
175
+ }
176
+ catch (e) {
177
+ // Fall through to legacy WAV decoding
178
+ }
179
+ }
180
+ // ─── WAV container detection ───────────────────────────────────────────────
181
+ if (isWav(processedBuf)) {
182
+ const pcmData = wavToBytes(processedBuf);
183
+ // The WAV payload starts with PIXEL_MAGIC ("PXL1")
184
+ if (pcmData.length >= 4 && pcmData.subarray(0, 4).equals(PIXEL_MAGIC)) {
185
+ let idx = 4; // skip PIXEL_MAGIC
186
+ const version = pcmData[idx++];
187
+ const nameLen = pcmData[idx++];
188
+ let name;
189
+ if (nameLen > 0) {
190
+ name = pcmData.subarray(idx, idx + nameLen).toString('utf8');
191
+ idx += nameLen;
192
+ }
193
+ const payloadLen = pcmData.readUInt32BE(idx);
194
+ idx += 4;
195
+ const rawPayload = pcmData.subarray(idx, idx + payloadLen);
196
+ idx += payloadLen;
197
+ // Check for rXFL file list after payload
198
+ let fileListJson;
199
+ if (idx + 8 < pcmData.length &&
200
+ pcmData.subarray(idx, idx + 4).toString('utf8') === 'rXFL') {
201
+ idx += 4;
202
+ const jsonLen = pcmData.readUInt32BE(idx);
203
+ idx += 4;
204
+ fileListJson = pcmData.subarray(idx, idx + jsonLen).toString('utf8');
205
+ }
206
+ let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
207
+ if (opts.onProgress)
208
+ opts.onProgress({ phase: 'decompress_start' });
209
+ try {
210
+ payload = await tryDecompress(payload, (info) => {
211
+ if (opts.onProgress)
212
+ opts.onProgress(info);
213
+ });
214
+ }
215
+ catch (e) {
216
+ const errMsg = e instanceof Error ? e.message : String(e);
217
+ if (opts.passphrase)
218
+ throw new IncorrectPassphraseError('Incorrect passphrase (WAV mode, zstd failed: ' + errMsg + ')');
219
+ throw new DataFormatError('WAV mode zstd decompression failed: ' + errMsg);
220
+ }
221
+ if (!payload.subarray(0, MAGIC.length).equals(MAGIC)) {
222
+ throw new DataFormatError('Invalid ROX format in WAV (missing ROX1 magic after decompression)');
223
+ }
224
+ payload = payload.subarray(MAGIC.length);
225
+ if (opts.onProgress)
226
+ opts.onProgress({ phase: 'done' });
227
+ progressBar?.stop();
228
+ // Try unpack multi-file archive
229
+ try {
230
+ const unpack = unpackBuffer(payload);
231
+ if (unpack && unpack.files && unpack.files.length > 0) {
232
+ return { files: unpack.files, meta: { name } };
233
+ }
234
+ }
235
+ catch (e) { }
236
+ return { buf: payload, meta: { name } };
237
+ }
238
+ }
239
+ // ─── Robust image detection (lossy-resilient PNG) ──────────────────────────
240
+ try {
241
+ if (isRobustImage(processedBuf)) {
242
+ const result = decodeRobustImage(processedBuf);
243
+ if (opts.onProgress)
244
+ opts.onProgress({ phase: 'done' });
245
+ progressBar?.stop();
246
+ try {
247
+ const unpack = unpackBuffer(result.data);
248
+ if (unpack && unpack.files && unpack.files.length > 0) {
249
+ return { files: unpack.files, correctedErrors: result.correctedErrors };
250
+ }
251
+ }
252
+ catch (e) { }
253
+ return { buf: result.data, correctedErrors: result.correctedErrors };
254
+ }
255
+ }
256
+ catch (e) {
257
+ // Fall through to standard decoding
258
+ }
259
+ // ─── MAGIC header (compact mode) ──────────────────────────────────────────
153
260
  if (processedBuf.subarray(0, MAGIC.length).equals(MAGIC)) {
154
261
  const d = processedBuf.subarray(MAGIC.length);
155
262
  const nameLen = d[0];
@@ -331,9 +438,7 @@ export async function decodePngToBinary(input, opts = {}) {
331
438
  if (firstPixels.length === MARKER_START.length) {
332
439
  hasMarkerStart = true;
333
440
  for (let i = 0; i < MARKER_START.length; i++) {
334
- if (firstPixels[i].r !== MARKER_START[i].r ||
335
- firstPixels[i].g !== MARKER_START[i].g ||
336
- firstPixels[i].b !== MARKER_START[i].b) {
441
+ if (!isColorMatch(firstPixels[i].r, firstPixels[i].g, firstPixels[i].b, MARKER_START[i].r, MARKER_START[i].g, MARKER_START[i].b)) {
337
442
  hasMarkerStart = false;
338
443
  break;
339
444
  }
@@ -422,9 +527,7 @@ export async function decodePngToBinary(input, opts = {}) {
422
527
  let match = true;
423
528
  for (let mi = 0; mi < MARKER_START.length && match; mi++) {
424
529
  const offset = (i + mi) * 3;
425
- if (logicalData[offset] !== MARKER_START[mi].r ||
426
- logicalData[offset + 1] !== MARKER_START[mi].g ||
427
- logicalData[offset + 2] !== MARKER_START[mi].b) {
530
+ if (!isColorMatch(logicalData[offset], logicalData[offset + 1], logicalData[offset + 2], MARKER_START[mi].r, MARKER_START[mi].g, MARKER_START[mi].b)) {
428
531
  match = false;
429
532
  }
430
533
  }
@@ -445,9 +548,7 @@ export async function decodePngToBinary(input, opts = {}) {
445
548
  for (let mi = 0; mi < MARKER_START.length && match; mi++) {
446
549
  const idx = (y * logicalWidth + (x + mi)) * 3;
447
550
  if (idx + 2 >= logicalData.length ||
448
- logicalData[idx] !== MARKER_START[mi].r ||
449
- logicalData[idx + 1] !== MARKER_START[mi].g ||
450
- logicalData[idx + 2] !== MARKER_START[mi].b) {
551
+ !isColorMatch(logicalData[idx], logicalData[idx + 1], logicalData[idx + 2], MARKER_START[mi].r, MARKER_START[mi].g, MARKER_START[mi].b)) {
451
552
  match = false;
452
553
  }
453
554
  }
@@ -540,9 +641,7 @@ export async function decodePngToBinary(input, opts = {}) {
540
641
  }
541
642
  for (let i = 0; i < MARKER_START.length; i++) {
542
643
  const offset = (startIdx + i) * 3;
543
- if (logicalData[offset] !== MARKER_START[i].r ||
544
- logicalData[offset + 1] !== MARKER_START[i].g ||
545
- logicalData[offset + 2] !== MARKER_START[i].b) {
644
+ if (!isColorMatch(logicalData[offset], logicalData[offset + 1], logicalData[offset + 2], MARKER_START[i].r, MARKER_START[i].g, MARKER_START[i].b)) {
546
645
  throw new Error('Marker START not found - image format not supported');
547
646
  }
548
647
  }
@@ -576,9 +675,7 @@ export async function decodePngToBinary(input, opts = {}) {
576
675
  break;
577
676
  }
578
677
  const offset = pixelIdx * 3;
579
- if (logicalData[offset] !== MARKER_END[mi].r ||
580
- logicalData[offset + 1] !== MARKER_END[mi].g ||
581
- logicalData[offset + 2] !== MARKER_END[mi].b) {
678
+ if (!isColorMatch(logicalData[offset], logicalData[offset + 1], logicalData[offset + 2], MARKER_END[mi].r, MARKER_END[mi].g, MARKER_END[mi].b)) {
582
679
  matchEnd = false;
583
680
  }
584
681
  }
@@ -730,9 +827,7 @@ export async function decodePngToBinary(input, opts = {}) {
730
827
  for (let mi = 0; mi < MARKER_START.length && match; mi++) {
731
828
  const idx = (y * logicalWidth2 + (x + mi)) * 3;
732
829
  if (idx + 2 >= logicalData2.length ||
733
- logicalData2[idx] !== MARKER_START[mi].r ||
734
- logicalData2[idx + 1] !== MARKER_START[mi].g ||
735
- logicalData2[idx + 2] !== MARKER_START[mi].b) {
830
+ !isColorMatch(logicalData2[idx], logicalData2[idx + 1], logicalData2[idx + 2], MARKER_START[mi].r, MARKER_START[mi].g, MARKER_START[mi].b)) {
736
831
  match = false;
737
832
  }
738
833
  }
@@ -809,9 +904,7 @@ export async function decodePngToBinary(input, opts = {}) {
809
904
  break;
810
905
  }
811
906
  const offset = pixelIdx * 3;
812
- if (logicalData2[offset] !== MARKER_END[mi].r ||
813
- logicalData2[offset + 1] !== MARKER_END[mi].g ||
814
- logicalData2[offset + 2] !== MARKER_END[mi].b) {
907
+ if (!isColorMatch(logicalData2[offset], logicalData2[offset + 1], logicalData2[offset + 2], MARKER_END[mi].r, MARKER_END[mi].g, MARKER_END[mi].b)) {
815
908
  matchEnd2 = false;
816
909
  }
817
910
  }