roxify 1.10.0 → 1.10.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.
Files changed (36) hide show
  1. package/Cargo.toml +14 -2
  2. package/README.md +15 -0
  3. package/dist/cli.js +8 -1
  4. package/dist/utils/constants.d.ts +5 -0
  5. package/dist/utils/constants.js +1 -0
  6. package/dist/utils/decoder.js +40 -3
  7. package/dist/utils/encoder.js +24 -11
  8. package/dist/utils/inspection.d.ts +0 -28
  9. package/dist/utils/inspection.js +124 -406
  10. package/dist/utils/native.js +1 -9
  11. package/dist/utils/rust-cli-wrapper.js +41 -38
  12. package/dist/utils/types.d.ts +1 -1
  13. package/libroxify_native-x86_64-unknown-linux-gnu.node +0 -0
  14. package/native/bench_hybrid.rs +145 -0
  15. package/native/bwt.rs +25 -69
  16. package/native/hybrid.rs +92 -70
  17. package/native/lib.rs +6 -3
  18. package/native/mtf.rs +106 -0
  19. package/native/rans_byte.rs +190 -0
  20. package/native/test_small_bwt.rs +31 -0
  21. package/native/test_stages.rs +70 -0
  22. package/package.json +1 -1
  23. package/dist/rox-macos-universal +0 -0
  24. package/dist/roxify_native +0 -0
  25. package/dist/roxify_native-macos-arm64 +0 -0
  26. package/dist/roxify_native-macos-x64 +0 -0
  27. package/dist/roxify_native.exe +0 -0
  28. package/roxify_native-aarch64-apple-darwin.node +0 -0
  29. package/roxify_native-aarch64-pc-windows-msvc.node +0 -0
  30. package/roxify_native-aarch64-unknown-linux-gnu.node +0 -0
  31. package/roxify_native-i686-pc-windows-msvc.node +0 -0
  32. package/roxify_native-i686-unknown-linux-gnu.node +0 -0
  33. package/roxify_native-x86_64-apple-darwin.node +0 -0
  34. package/roxify_native-x86_64-pc-windows-gnu.node +0 -0
  35. package/roxify_native-x86_64-pc-windows-msvc.node +0 -0
  36. package/roxify_native-x86_64-unknown-linux-gnu.node +0 -0
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "roxify_native"
3
- version = "1.9.8"
3
+ version = "1.10.1"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
@@ -13,6 +13,18 @@ path = "native/lib.rs"
13
13
  name = "roxify_native"
14
14
  path = "native/main.rs"
15
15
 
16
+ [[bin]]
17
+ name = "bench_hybrid"
18
+ path = "native/bench_hybrid.rs"
19
+
20
+ [[bin]]
21
+ name = "test_stages"
22
+ path = "native/test_stages.rs"
23
+
24
+ [[bin]]
25
+ name = "test_small_bwt"
26
+ path = "native/test_small_bwt.rs"
27
+
16
28
  [dev-dependencies]
17
29
 
18
30
  [dependencies]
@@ -43,6 +55,7 @@ bytemuck = { version = "1.14", features = ["derive"] }
43
55
  tokio = { version = "1", features = ["sync", "rt"], optional = true }
44
56
  parking_lot = "0.12"
45
57
  pollster = { version = "0.3", optional = true }
58
+ libsais = { version = "0.2.0", default-features = false }
46
59
 
47
60
  [features]
48
61
  # default is intentionally empty so the crate compiles fast for local checks.
@@ -79,4 +92,3 @@ codegen-units = 16
79
92
  debug = false
80
93
  incremental = true
81
94
  panic = "abort"
82
-
package/README.md CHANGED
@@ -39,6 +39,7 @@ The core compression and image-processing logic is written in Rust and exposed t
39
39
  ## Features
40
40
 
41
41
  - **Native Rust acceleration** via N-API with automatic fallback to pure JavaScript
42
+ - **BWT-ANS compression** -- Burrows-Wheeler Transform + Move-to-Front + RLE + rANS entropy coding via libsais O(n) SA-IS (18.1 MB/s encode, 31.2 MB/s decode)
42
43
  - **Multi-threaded Zstd compression** (level 19) with parallel chunk processing via Rayon
43
44
  - **AES-256-GCM encryption** with PBKDF2 key derivation (100,000 iterations)
44
45
  - **Lossless roundtrip** -- encoded data is recovered byte-for-byte
@@ -58,6 +59,20 @@ The core compression and image-processing logic is written in Rust and exposed t
58
59
 
59
60
  All measurements were taken on Linux x64 (Intel i7-6700K @ 4.0 GHz, 32 GB RAM) with Node.js v20. Every tool uses its **maximum compression** setting: zip -9, gzip -9, 7z LZMA2 -mx=9, and Roxify Zstd level 19. Roxify produces a valid PNG or WAV file rather than a raw archive.
60
61
 
62
+ ### BWT-ANS Native Compression (Rust, via N-API)
63
+
64
+ Direct API calls through the native module (BWT → MTF → RLE0 → rANS, 1 MB blocks):
65
+
66
+ | Dataset | Original | Compressed | Ratio | Encode | Decode | Enc Throughput | Dec Throughput |
67
+ | ------- | -------- | ---------- | ----- | ------ | ------ | -------------- | -------------- |
68
+ | Repetitive text 45 KB | 45 KB | 207 B | 0.5% | < 1 ms | < 1 ms | — | — |
69
+ | Rust source 6 KB | 6 KB | 2.0 KB | 33.7% | < 1 ms | < 1 ms | — | — |
70
+ | node_modules tar 175 MB | 175.5 MB | 38.4 MB | 21.9% | 9.68 s | 5.62 s | 18.1 MB/s | 31.2 MB/s |
71
+ | Random 100 KB | 100 KB | 101 KB | 101.5% | < 1 ms | < 1 ms | — | — |
72
+ | Zeros 100 KB | 100 KB | 51 B | 0.1% | < 1 ms | < 1 ms | — | — |
73
+
74
+ > BWT-ANS achieves **21.9% on real-world node_modules** (175 MB), with **18 MB/s encode** and **31 MB/s decode** throughput. 100% lossless roundtrip verified on all datasets.
75
+
61
76
  ### Compression Ratio (Maximum Compression for All Tools)
62
77
 
63
78
  | Dataset | Original | zip -9 | gzip -9 | 7z LZMA2 -9 | Roxify PNG | Roxify WAV |
package/dist/cli.js CHANGED
@@ -66,6 +66,7 @@ Commands:
66
66
  Options:
67
67
  --image Use PNG container (default)
68
68
  --sound Use WAV audio container (smaller overhead, faster)
69
+ --bwt-ans Use BWT-ANS compression instead of Zstd
69
70
  -p, --passphrase <pass> Use passphrase (AES-256-GCM)
70
71
  -m, --mode <mode> Mode: screenshot (default)
71
72
  -e, --encrypt <type> auto|aes|xor|none
@@ -135,6 +136,10 @@ function parseArgs(args) {
135
136
  parsed.forceTs = true;
136
137
  i++;
137
138
  }
139
+ else if (key === 'bwt-ans') {
140
+ parsed.compression = 'bwt-ans';
141
+ i++;
142
+ }
138
143
  else if (key === 'lossy-resilient') {
139
144
  parsed.lossyResilient = true;
140
145
  i++;
@@ -292,7 +297,7 @@ async function encodeCommand(args) {
292
297
  catch (e) {
293
298
  anyInputDir = false;
294
299
  }
295
- if (isRustBinaryAvailable() && !parsed.forceTs && containerMode !== 'sound') {
300
+ if (isRustBinaryAvailable() && !parsed.forceTs && containerMode !== 'sound' && parsed.compression !== 'bwt-ans') {
296
301
  try {
297
302
  console.log(`Encoding to ${resolvedOutput} (Using native Rust encoder)\n`);
298
303
  const startTime = Date.now();
@@ -395,6 +400,8 @@ async function encodeCommand(args) {
395
400
  options.verbose = true;
396
401
  if (parsed.noCompress)
397
402
  options.compression = 'none';
403
+ if (parsed.compression === 'bwt-ans')
404
+ options.compression = 'bwt-ans';
398
405
  if (parsed.passphrase) {
399
406
  options.passphrase = parsed.passphrase;
400
407
  options.encrypt = parsed.encrypt || 'aes';
@@ -29,6 +29,11 @@ export declare const COMPRESSION_MARKERS: {
29
29
  g: number;
30
30
  b: number;
31
31
  }[];
32
+ 'bwt-ans': {
33
+ r: number;
34
+ g: number;
35
+ b: number;
36
+ }[];
32
37
  };
33
38
  export declare const FORMAT_MARKERS: {
34
39
  png: {
@@ -19,6 +19,7 @@ export const MARKER_START = MARKER_COLORS;
19
19
  export const MARKER_END = [...MARKER_COLORS].reverse();
20
20
  export const COMPRESSION_MARKERS = {
21
21
  zstd: [{ r: 0, g: 255, b: 0 }],
22
+ 'bwt-ans': [{ r: 0, g: 128, b: 255 }],
22
23
  };
23
24
  export const FORMAT_MARKERS = {
24
25
  png: { r: 0, g: 255, b: 255 },
@@ -11,7 +11,7 @@ import { native } from './native.js';
11
11
  import { cropAndReconstitute } from './reconstitution.js';
12
12
  import { decodeRobustAudio, isRobustAudioWav } from './robust-audio.js';
13
13
  import { decodeRobustImage, isRobustImage } from './robust-image.js';
14
- import { parallelZstdDecompress, tryZstdDecompress } from './zstd.js';
14
+ import { parallelZstdDecompress } from './zstd.js';
15
15
  function isColorMatch(r1, g1, b1, r2, g2, b2) {
16
16
  return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) < 50;
17
17
  }
@@ -142,7 +142,16 @@ export function unstretchImage(rawRGB, width, height, tolerance = 0) {
142
142
  }
143
143
  return { data, width: logicalW, height: logicalH };
144
144
  }
145
+ const RBW1_MAGIC = Buffer.from('RBW1');
145
146
  async function tryDecompress(payload, onProgress) {
147
+ if (payload.length >= 4 && payload.subarray(0, 4).equals(RBW1_MAGIC) && native?.hybridDecompress) {
148
+ if (onProgress)
149
+ onProgress({ phase: 'decompress_start', total: 1 });
150
+ const result = Buffer.from(native.hybridDecompress(payload));
151
+ if (onProgress)
152
+ onProgress({ phase: 'decompress_done', loaded: 1, total: 1 });
153
+ return result;
154
+ }
146
155
  return await parallelZstdDecompress(payload, onProgress);
147
156
  }
148
157
  function detectImageFormat(buf) {
@@ -466,7 +475,7 @@ export async function decodePngToBinary(input, opts = {}) {
466
475
  if (opts.onProgress)
467
476
  opts.onProgress({ phase: 'decompress_start' });
468
477
  try {
469
- payload = await tryZstdDecompress(payload, (info) => {
478
+ payload = await tryDecompress(payload, (info) => {
470
479
  if (opts.onProgress)
471
480
  opts.onProgress(info);
472
481
  });
@@ -660,7 +669,7 @@ export async function decodePngToBinary(input, opts = {}) {
660
669
  const rawPayload = logicalData.slice(idx, idx + payloadLen);
661
670
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
662
671
  try {
663
- payload = await tryZstdDecompress(payload, (info) => {
672
+ payload = await tryDecompress(payload, (info) => {
664
673
  if (opts.onProgress)
665
674
  opts.onProgress(info);
666
675
  });
@@ -1170,6 +1179,34 @@ export async function decodePngToBinary(input, opts = {}) {
1170
1179
  e instanceof DataFormatError) {
1171
1180
  throw e;
1172
1181
  }
1182
+ try {
1183
+ const rawPayload = Buffer.from(native.extractPayloadFromPng(processedBuf));
1184
+ let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
1185
+ payload = await tryDecompress(payload, (info) => {
1186
+ if (opts.onProgress)
1187
+ opts.onProgress(info);
1188
+ });
1189
+ if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
1190
+ throw new DataFormatError('Missing ROX1 magic after native extraction');
1191
+ }
1192
+ payload = payload.slice(MAGIC.length);
1193
+ const nameFromPng = native.extractNameFromPng
1194
+ ? (() => { try {
1195
+ return native.extractNameFromPng(processedBuf);
1196
+ }
1197
+ catch {
1198
+ return undefined;
1199
+ } })()
1200
+ : undefined;
1201
+ return { buf: payload, meta: { name: nameFromPng } };
1202
+ }
1203
+ catch (nativeErr) {
1204
+ if (nativeErr instanceof PassphraseRequiredError ||
1205
+ nativeErr instanceof IncorrectPassphraseError ||
1206
+ nativeErr instanceof DataFormatError) {
1207
+ throw nativeErr;
1208
+ }
1209
+ }
1173
1210
  const errMsg = e instanceof Error ? e.message : String(e);
1174
1211
  throw new Error('Failed to decode PNG: ' + errMsg);
1175
1212
  }
@@ -93,7 +93,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
93
93
  // This must be checked BEFORE TS compression to avoid double-compression.
94
94
  if (typeof native.nativeEncodePngWithNameAndFilelist === 'function' &&
95
95
  opts.includeFileList &&
96
- opts.fileList) {
96
+ opts.fileList &&
97
+ opts.compression !== 'bwt-ans') {
97
98
  const fileName = opts.name || undefined;
98
99
  const inputBuf = Array.isArray(input) ? Buffer.concat(input) : input;
99
100
  let sizeMap = null;
@@ -170,15 +171,27 @@ export async function encodeBinaryToPng(input, opts = {}) {
170
171
  }
171
172
  if (opts.onProgress)
172
173
  opts.onProgress({ phase: 'compress_start', total: totalLen });
173
- let payload = await parallelZstdCompress(payloadInput, compressionLevel, (loaded, total) => {
174
- if (opts.onProgress) {
175
- opts.onProgress({
176
- phase: 'compress_progress',
177
- loaded,
178
- total,
179
- });
180
- }
181
- }, opts.dict);
174
+ let payload;
175
+ if (opts.compression === 'bwt-ans' && native?.hybridCompress) {
176
+ const flat = Array.isArray(payloadInput) ? Buffer.concat(payloadInput) : payloadInput;
177
+ if (opts.onProgress)
178
+ opts.onProgress({ phase: 'compress_progress', loaded: 0, total: 1 });
179
+ const compressed = Buffer.from(native.hybridCompress(flat));
180
+ payload = [compressed];
181
+ if (opts.onProgress)
182
+ opts.onProgress({ phase: 'compress_progress', loaded: 1, total: 1 });
183
+ }
184
+ else {
185
+ payload = await parallelZstdCompress(payloadInput, compressionLevel, (loaded, total) => {
186
+ if (opts.onProgress) {
187
+ opts.onProgress({
188
+ phase: 'compress_progress',
189
+ loaded,
190
+ total,
191
+ });
192
+ }
193
+ }, opts.dict);
194
+ }
182
195
  if (opts.onProgress)
183
196
  opts.onProgress({ phase: 'compress_done', loaded: payload.length });
184
197
  if (Array.isArray(input)) {
@@ -392,7 +405,7 @@ export async function encodeBinaryToPng(input, opts = {}) {
392
405
  [...dataWithoutMarkers, Buffer.alloc(padding)]
393
406
  : dataWithoutMarkers;
394
407
  const markerStartBytes = colorsToBytes(MARKER_START);
395
- const compressionMarkerBytes = colorsToBytes(COMPRESSION_MARKERS.zstd);
408
+ const compressionMarkerBytes = colorsToBytes(opts.compression === 'bwt-ans' ? COMPRESSION_MARKERS['bwt-ans'] : COMPRESSION_MARKERS.zstd);
396
409
  const dataWithMarkers = [
397
410
  markerStartBytes,
398
411
  compressionMarkerBytes,
@@ -1,35 +1,7 @@
1
- /**
2
- * List files stored inside a ROX PNG without fully extracting it.
3
- * Returns `null` if no file list could be found.
4
- *
5
- * @param pngBuf - Buffer containing a PNG file.
6
- * @param opts - Options to include sizes.
7
- * @returns Promise resolving to an array of file names or objects with sizes.
8
- *
9
- * @example
10
- * ```js
11
- * import { listFilesInPng } from 'roxify';
12
- * const files = await listFilesInPng(fs.readFileSync('out.png'), { includeSizes: true });
13
- * console.log(files);
14
- * ```
15
- */
16
1
  export declare function listFilesInPng(pngBuf: Buffer, opts?: {
17
2
  includeSizes?: boolean;
18
3
  }): Promise<string[] | {
19
4
  name: string;
20
5
  size: number;
21
6
  }[] | null>;
22
- /**
23
- * Check if a PNG contains an encrypted payload requiring a passphrase.
24
- *
25
- * @param pngBuf - Buffer containing a PNG file.
26
- * @returns Promise resolving to `true` if the PNG requires a passphrase.
27
- *
28
- * @example
29
- * ```js
30
- * import { hasPassphraseInPng } from 'roxify';
31
- * const needPass = await hasPassphraseInPng(fs.readFileSync('out.png'));
32
- * console.log('needs passphrase?', needPass);
33
- * ```
34
- */
35
7
  export declare function hasPassphraseInPng(pngBuf: Buffer): Promise<boolean>;