roxify 1.6.9 → 1.7.0
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 +17 -1
- package/dist/utils/decoder.d.ts +13 -0
- package/dist/utils/decoder.js +168 -14
- package/package.json +1 -1
- package/roxify_native-x86_64-unknown-linux-gnu.node +0 -0
- package/roxify_native.node +0 -0
package/README.md
CHANGED
|
@@ -46,6 +46,7 @@ The core compression and image-processing logic is written in Rust and exposed t
|
|
|
46
46
|
- **Audio container** -- encode data as structured multi-frequency tones (not white noise) in WAV files
|
|
47
47
|
- **Directory packing** -- encode entire directory trees into a single PNG
|
|
48
48
|
- **Screenshot reconstitution** -- recover data from photographed or screenshotted PNGs
|
|
49
|
+
- **Stretch-resilient decoding** -- automatically un-stretches nearest-neighbor scaled images back to original pixel data
|
|
49
50
|
- **CLI and programmatic API** -- use from the terminal or import as a library
|
|
50
51
|
- **Cross-platform** -- prebuilt binaries for Linux x64, macOS x64/ARM64, and Windows x64
|
|
51
52
|
- **Full TypeScript support** with exported types and TSDoc annotations
|
|
@@ -388,6 +389,21 @@ interface DecodeResult {
|
|
|
388
389
|
| `screenshot` | Encodes data as RGB pixels in a standard PNG. The image looks like a gradient or noise pattern and survives re-uploads and social media processing. | Sharing on image-only platforms, bypassing file-type filters |
|
|
389
390
|
| `compact` | Minimal 1x1 PNG with data embedded in a custom ancillary chunk (`rXDT`). Produces the smallest possible output. | Programmatic use, archival, maximum compression ratio |
|
|
390
391
|
|
|
392
|
+
### Stretch-Resilient Decoding
|
|
393
|
+
|
|
394
|
+
Roxify automatically detects and recovers data from **nearest-neighbor stretched** images. If a roxified PNG is scaled up (e.g., zoomed in a browser, pasted in a document, resized in an image editor with nearest-neighbor interpolation), the decoder:
|
|
395
|
+
|
|
396
|
+
1. **Crops** the image to the non-background bounding box
|
|
397
|
+
2. **Collapses** horizontal runs of identical pixels back to single logical pixels
|
|
398
|
+
3. **Deduplicates** consecutive identical rows
|
|
399
|
+
|
|
400
|
+
This means you can share a roxified image at any zoom level and it will still decode correctly. Non-uniform stretch factors and white padding are fully supported.
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
# Works even on stretched/zoomed screenshots
|
|
404
|
+
rox decode zoomed-screenshot.png -o output/
|
|
405
|
+
```
|
|
406
|
+
|
|
391
407
|
---
|
|
392
408
|
|
|
393
409
|
## Encryption
|
|
@@ -603,7 +619,7 @@ Input --> RS ECC Encode --> Interleave --> Block Encode (MFSK audio / QR-like im
|
|
|
603
619
|
### Decompression Pipeline
|
|
604
620
|
|
|
605
621
|
```
|
|
606
|
-
Input --> PNG Parse --> AES-256-GCM Decrypt (optional) --> Zstd Decompress --> Output
|
|
622
|
+
Input --> PNG Parse --> Un-stretch (if needed) --> AES-256-GCM Decrypt (optional) --> Zstd Decompress --> Output
|
|
607
623
|
```
|
|
608
624
|
|
|
609
625
|
### Lossy-Resilient Decode Pipeline
|
package/dist/utils/decoder.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { DecodeOptions, DecodeResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Un-stretch an image that was nearest-neighbor scaled.
|
|
4
|
+
* 1. Crops to non-background bounding box
|
|
5
|
+
* 2. Collapses horizontal runs of identical pixels into single pixels
|
|
6
|
+
* 3. Removes duplicate consecutive rows
|
|
7
|
+
*
|
|
8
|
+
* Returns null if the image doesn't appear to be stretched.
|
|
9
|
+
*/
|
|
10
|
+
export declare function unstretchImage(rawRGB: Buffer, width: number, height: number, tolerance?: number): {
|
|
11
|
+
data: Buffer;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
} | null;
|
|
2
15
|
/**
|
|
3
16
|
* Decode a ROX PNG or buffer into the original binary payload or files list.
|
|
4
17
|
*
|
package/dist/utils/decoder.js
CHANGED
|
@@ -15,6 +15,133 @@ import { parallelZstdDecompress, tryZstdDecompress } 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
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Un-stretch an image that was nearest-neighbor scaled.
|
|
20
|
+
* 1. Crops to non-background bounding box
|
|
21
|
+
* 2. Collapses horizontal runs of identical pixels into single pixels
|
|
22
|
+
* 3. Removes duplicate consecutive rows
|
|
23
|
+
*
|
|
24
|
+
* Returns null if the image doesn't appear to be stretched.
|
|
25
|
+
*/
|
|
26
|
+
export function unstretchImage(rawRGB, width, height, tolerance = 0) {
|
|
27
|
+
if (width <= 0 || height <= 0 || rawRGB.length < width * height * 3) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Step 1: Find bounding box of non-background pixels
|
|
31
|
+
// Background = white-ish (all channels >= 240)
|
|
32
|
+
let minX = width, maxX = -1, minY = height, maxY = -1;
|
|
33
|
+
for (let y = 0; y < height; y++) {
|
|
34
|
+
const rowBase = y * width * 3;
|
|
35
|
+
for (let x = 0; x < width; x++) {
|
|
36
|
+
const idx = rowBase + x * 3;
|
|
37
|
+
const r = rawRGB[idx], g = rawRGB[idx + 1], b = rawRGB[idx + 2];
|
|
38
|
+
// Skip background: white or near-white
|
|
39
|
+
if (r >= 240 && g >= 240 && b >= 240)
|
|
40
|
+
continue;
|
|
41
|
+
if (x < minX)
|
|
42
|
+
minX = x;
|
|
43
|
+
if (x > maxX)
|
|
44
|
+
maxX = x;
|
|
45
|
+
if (y < minY)
|
|
46
|
+
minY = y;
|
|
47
|
+
if (y > maxY)
|
|
48
|
+
maxY = y;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (maxX < minX || maxY < minY)
|
|
52
|
+
return null; // all background
|
|
53
|
+
const cropW = maxX - minX + 1;
|
|
54
|
+
const cropH = maxY - minY + 1;
|
|
55
|
+
// Don't process tiny images
|
|
56
|
+
if (cropW < 2 || cropH < 2)
|
|
57
|
+
return null;
|
|
58
|
+
// Step 2: Collapse horizontal runs per row + deduplicate rows
|
|
59
|
+
const pixelsMatch = (buf, i1, i2) => {
|
|
60
|
+
if (tolerance === 0) {
|
|
61
|
+
return (buf[i1] === buf[i2] &&
|
|
62
|
+
buf[i1 + 1] === buf[i2 + 1] &&
|
|
63
|
+
buf[i1 + 2] === buf[i2 + 2]);
|
|
64
|
+
}
|
|
65
|
+
return (Math.abs(buf[i1] - buf[i2]) +
|
|
66
|
+
Math.abs(buf[i1 + 1] - buf[i2 + 1]) +
|
|
67
|
+
Math.abs(buf[i1 + 2] - buf[i2 + 2]) <=
|
|
68
|
+
tolerance);
|
|
69
|
+
};
|
|
70
|
+
const logicalRows = [];
|
|
71
|
+
let logicalW = -1;
|
|
72
|
+
let prevRowKey = '';
|
|
73
|
+
for (let y = minY; y <= maxY; y++) {
|
|
74
|
+
const rowBase = y * width * 3;
|
|
75
|
+
const pixels = [];
|
|
76
|
+
let prevIdx = -1;
|
|
77
|
+
for (let x = minX; x <= maxX; x++) {
|
|
78
|
+
const idx = rowBase + x * 3;
|
|
79
|
+
if (prevIdx >= 0 && pixelsMatch(rawRGB, idx, prevIdx)) {
|
|
80
|
+
continue; // same as previous pixel, skip (collapse run)
|
|
81
|
+
}
|
|
82
|
+
pixels.push(rawRGB[idx], rawRGB[idx + 1], rawRGB[idx + 2]);
|
|
83
|
+
prevIdx = idx;
|
|
84
|
+
}
|
|
85
|
+
// Compute row key for dedup
|
|
86
|
+
const rowKey = pixels.join(',');
|
|
87
|
+
if (rowKey === prevRowKey) {
|
|
88
|
+
continue; // duplicate row, skip
|
|
89
|
+
}
|
|
90
|
+
prevRowKey = rowKey;
|
|
91
|
+
const rowW = pixels.length / 3;
|
|
92
|
+
if (logicalW === -1) {
|
|
93
|
+
logicalW = rowW;
|
|
94
|
+
}
|
|
95
|
+
else if (rowW !== logicalW) {
|
|
96
|
+
// A uniform-color row collapses to 1 pixel — expand to logicalW
|
|
97
|
+
if (rowW === 1 && logicalW > 1) {
|
|
98
|
+
const r = pixels[0], g = pixels[1], b = pixels[2];
|
|
99
|
+
for (let f = 1; f < logicalW; f++) {
|
|
100
|
+
pixels.push(r, g, b);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (logicalW === 1 && rowW > 1) {
|
|
104
|
+
// First row was uniform, adopt new width and expand it
|
|
105
|
+
const prevRow = logicalRows[logicalRows.length - 1];
|
|
106
|
+
if (prevRow) {
|
|
107
|
+
const pr = prevRow.pixels[0], pg = prevRow.pixels[1], pb = prevRow.pixels[2];
|
|
108
|
+
prevRow.pixels = [];
|
|
109
|
+
for (let f = 0; f < rowW; f++) {
|
|
110
|
+
prevRow.pixels.push(pr, pg, pb);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
logicalW = rowW;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Inconsistent row widths → not a clean stretch
|
|
117
|
+
// Try with tolerance if we haven't already
|
|
118
|
+
if (tolerance === 0) {
|
|
119
|
+
return unstretchImage(rawRGB, width, height, 30);
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
logicalRows.push({ pixels, key: rowKey });
|
|
125
|
+
}
|
|
126
|
+
if (logicalRows.length === 0 || logicalW <= 0)
|
|
127
|
+
return null;
|
|
128
|
+
const logicalH = logicalRows.length;
|
|
129
|
+
// Sanity: must have actually reduced the image
|
|
130
|
+
if (logicalW >= cropW && logicalH >= cropH)
|
|
131
|
+
return null;
|
|
132
|
+
// Build output buffer
|
|
133
|
+
const data = Buffer.allocUnsafe(logicalW * logicalH * 3);
|
|
134
|
+
let outIdx = 0;
|
|
135
|
+
for (const row of logicalRows) {
|
|
136
|
+
for (let i = 0; i < row.pixels.length; i++) {
|
|
137
|
+
data[outIdx++] = row.pixels[i];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (process.env.ROX_DEBUG) {
|
|
141
|
+
console.log(`DEBUG: unstretch ${width}x${height} → crop ${cropW}x${cropH} → logical ${logicalW}x${logicalH}`);
|
|
142
|
+
}
|
|
143
|
+
return { data, width: logicalW, height: logicalH };
|
|
144
|
+
}
|
|
18
145
|
async function tryDecompress(payload, onProgress) {
|
|
19
146
|
return await parallelZstdDecompress(payload, onProgress);
|
|
20
147
|
}
|
|
@@ -458,29 +585,56 @@ export async function decodePngToBinary(input, opts = {}) {
|
|
|
458
585
|
hasBlockMagic = true;
|
|
459
586
|
}
|
|
460
587
|
}
|
|
461
|
-
let logicalWidth;
|
|
462
|
-
let logicalHeight;
|
|
463
|
-
let logicalData;
|
|
588
|
+
let logicalWidth = 0;
|
|
589
|
+
let logicalHeight = 0;
|
|
590
|
+
let logicalData = Buffer.alloc(0);
|
|
464
591
|
if (hasMarkerStart || hasPixelMagic || hasBlockMagic) {
|
|
465
592
|
logicalWidth = currentWidth;
|
|
466
593
|
logicalHeight = currentHeight;
|
|
467
594
|
logicalData = rawRGB;
|
|
468
595
|
}
|
|
469
596
|
else {
|
|
470
|
-
|
|
471
|
-
|
|
597
|
+
// Try cropAndReconstitute first (for screenshots with markers)
|
|
598
|
+
let reconSuccess = false;
|
|
599
|
+
try {
|
|
600
|
+
if (process.env.ROX_DEBUG || opts.debugDir) {
|
|
601
|
+
console.log('DEBUG: about to call cropAndReconstitute, debugDir=', opts.debugDir);
|
|
602
|
+
}
|
|
603
|
+
const reconstructed = await cropAndReconstitute(processedBuf, opts.debugDir);
|
|
604
|
+
if (process.env.ROX_DEBUG || opts.debugDir) {
|
|
605
|
+
console.log('DEBUG: cropAndReconstitute returned, reconstructed len=', reconstructed.length);
|
|
606
|
+
}
|
|
607
|
+
const rawData = native.sharpToRaw(reconstructed);
|
|
608
|
+
if (process.env.ROX_DEBUG || opts.debugDir) {
|
|
609
|
+
console.log('DEBUG: rawData from reconstructed:', rawData.width, 'x', rawData.height, 'pixels=', Math.floor(rawData.pixels.length / 3));
|
|
610
|
+
}
|
|
611
|
+
logicalWidth = rawData.width;
|
|
612
|
+
logicalHeight = rawData.height;
|
|
613
|
+
logicalData = Buffer.from(rawData.pixels);
|
|
614
|
+
reconSuccess = true;
|
|
472
615
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
616
|
+
catch (reconErr) {
|
|
617
|
+
if (process.env.ROX_DEBUG) {
|
|
618
|
+
console.log('DEBUG: cropAndReconstitute failed:', reconErr instanceof Error ? reconErr.message : reconErr);
|
|
619
|
+
}
|
|
476
620
|
}
|
|
477
|
-
|
|
478
|
-
if (
|
|
479
|
-
|
|
621
|
+
// Fallback: try un-stretching (nearest-neighbor scaled images)
|
|
622
|
+
if (!reconSuccess) {
|
|
623
|
+
const pixelW = isBlockEncoded ? currentWidth / 2 : currentWidth;
|
|
624
|
+
const pixelH = isBlockEncoded ? currentHeight / 2 : currentHeight;
|
|
625
|
+
if (process.env.ROX_DEBUG) {
|
|
626
|
+
console.log(`DEBUG: trying unstretch on ${pixelW}x${pixelH} rawRGB`);
|
|
627
|
+
}
|
|
628
|
+
const unstretched = unstretchImage(rawRGB, pixelW, pixelH);
|
|
629
|
+
if (unstretched) {
|
|
630
|
+
logicalWidth = unstretched.width;
|
|
631
|
+
logicalHeight = unstretched.height;
|
|
632
|
+
logicalData = unstretched.data;
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
throw new Error('No valid markers found and image unstretch failed');
|
|
636
|
+
}
|
|
480
637
|
}
|
|
481
|
-
logicalWidth = rawData.width;
|
|
482
|
-
logicalHeight = rawData.height;
|
|
483
|
-
logicalData = Buffer.from(rawData.pixels);
|
|
484
638
|
}
|
|
485
639
|
if (process.env.ROX_DEBUG) {
|
|
486
640
|
console.log('DEBUG: Logical grid reconstructed:', logicalWidth, 'x', logicalHeight, '=', logicalWidth * logicalHeight, 'pixels');
|
package/package.json
CHANGED
|
Binary file
|
package/roxify_native.node
CHANGED
|
Binary file
|