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.
- package/Cargo.toml +14 -2
- package/README.md +15 -0
- package/dist/cli.js +8 -1
- package/dist/utils/constants.d.ts +5 -0
- package/dist/utils/constants.js +1 -0
- package/dist/utils/decoder.js +40 -3
- package/dist/utils/encoder.js +24 -11
- package/dist/utils/inspection.d.ts +0 -28
- package/dist/utils/inspection.js +124 -406
- package/dist/utils/native.js +1 -9
- package/dist/utils/rust-cli-wrapper.js +41 -38
- package/dist/utils/types.d.ts +1 -1
- package/libroxify_native-x86_64-unknown-linux-gnu.node +0 -0
- package/native/bench_hybrid.rs +145 -0
- package/native/bwt.rs +25 -69
- package/native/hybrid.rs +92 -70
- package/native/lib.rs +6 -3
- package/native/mtf.rs +106 -0
- package/native/rans_byte.rs +190 -0
- package/native/test_small_bwt.rs +31 -0
- package/native/test_stages.rs +70 -0
- package/package.json +1 -1
- package/dist/rox-macos-universal +0 -0
- package/dist/roxify_native +0 -0
- package/dist/roxify_native-macos-arm64 +0 -0
- package/dist/roxify_native-macos-x64 +0 -0
- package/dist/roxify_native.exe +0 -0
- package/roxify_native-aarch64-apple-darwin.node +0 -0
- package/roxify_native-aarch64-pc-windows-msvc.node +0 -0
- package/roxify_native-aarch64-unknown-linux-gnu.node +0 -0
- package/roxify_native-i686-pc-windows-msvc.node +0 -0
- package/roxify_native-i686-unknown-linux-gnu.node +0 -0
- package/roxify_native-x86_64-apple-darwin.node +0 -0
- package/roxify_native-x86_64-pc-windows-gnu.node +0 -0
- package/roxify_native-x86_64-pc-windows-msvc.node +0 -0
- 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.
|
|
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';
|
package/dist/utils/constants.js
CHANGED
|
@@ -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 },
|
package/dist/utils/decoder.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/utils/encoder.js
CHANGED
|
@@ -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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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>;
|