roxify 1.13.2 → 1.13.4

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.
@@ -1,8 +1,9 @@
1
1
  use std::io::{Write, BufWriter, Read};
2
2
  use std::fs::File;
3
- use std::path::Path;
3
+ use std::path::{Path, PathBuf};
4
+ use rayon::prelude::*;
5
+ use serde::Serialize;
4
6
  use walkdir::WalkDir;
5
- use tar::{Builder, Header};
6
7
 
7
8
  const PNG_HEADER: &[u8] = &[137, 80, 78, 71, 13, 10, 26, 10];
8
9
  const PIXEL_MAGIC: &[u8] = b"PXL1";
@@ -10,9 +11,35 @@ const MARKER_START: [(u8, u8, u8); 3] = [(255, 0, 0), (0, 255, 0), (0, 0, 255)];
10
11
  const MARKER_END: [(u8, u8, u8); 3] = [(0, 0, 255), (0, 255, 0), (255, 0, 0)];
11
12
  const MARKER_ZSTD: (u8, u8, u8) = (0, 255, 0);
12
13
  const MAGIC: &[u8] = b"ROX1";
14
+ const PACK_MAGIC: u32 = 0x524f5850;
15
+
16
+ const MIN_ZST_CAPACITY: usize = 16 * 1024 * 1024;
17
+ const MB: u64 = 1024 * 1024;
18
+ const MAX_FILE_BUFFER_CAPACITY: usize = 4 * 1024 * 1024;
19
+ const PARALLEL_IO_FILE_THRESHOLD: u64 = MB;
20
+ const PARALLEL_IO_BATCH_BYTES: u64 = 128 * MB;
21
+ const PARALLEL_IO_BATCH_FILES: usize = 512;
22
+ const PARALLEL_IO_MIN_FILES: usize = 8;
13
23
 
14
24
  pub type ProgressCallback = Box<dyn Fn(u64, u64, &str) + Send>;
15
25
 
26
+ struct DirectoryFile {
27
+ path: PathBuf,
28
+ rel_path: String,
29
+ size: u64,
30
+ }
31
+
32
+ #[derive(Serialize)]
33
+ struct FileListEntry {
34
+ name: String,
35
+ size: u64,
36
+ }
37
+
38
+ struct CollectedDirectory {
39
+ entries: Vec<DirectoryFile>,
40
+ total_bytes: u64,
41
+ }
42
+
16
43
  pub fn encode_dir_to_png(
17
44
  dir_path: &Path,
18
45
  output_path: &Path,
@@ -42,21 +69,12 @@ pub fn encode_dir_to_png_encrypted_with_progress(
42
69
  encrypt_type: Option<&str>,
43
70
  progress: Option<ProgressCallback>,
44
71
  ) -> anyhow::Result<()> {
45
- let tmp_zst = output_path.with_extension("tmp.zst");
72
+ let (zst_buf, file_list_json) = compress_dir_to_zst_mem(dir_path, compression_level, &progress)?;
46
73
 
47
- let file_list = compress_dir_to_zst(dir_path, &tmp_zst, compression_level, &progress)?;
48
-
49
- if let Some(ref cb) = progress {
50
- cb(90, 100, "writing_png");
51
- }
52
-
53
- let file_list_json = serde_json::to_string(&file_list)?;
54
-
55
- let result = write_png_from_zst(
56
- &tmp_zst, output_path, name, Some(&file_list_json),
57
- passphrase, encrypt_type,
74
+ let result = write_png_from_zst_mem(
75
+ zst_buf, output_path, name, Some(&file_list_json),
76
+ passphrase, encrypt_type, &progress,
58
77
  );
59
- let _ = std::fs::remove_file(&tmp_zst);
60
78
 
61
79
  if let Some(ref cb) = progress {
62
80
  cb(100, 100, "done");
@@ -65,31 +83,23 @@ pub fn encode_dir_to_png_encrypted_with_progress(
65
83
  result
66
84
  }
67
85
 
68
- fn compress_dir_to_zst(
86
+ fn compress_dir_to_zst_mem(
69
87
  dir_path: &Path,
70
- zst_path: &Path,
71
88
  compression_level: i32,
72
89
  progress: &Option<ProgressCallback>,
73
- ) -> anyhow::Result<Vec<serde_json::Value>> {
74
- let base = dir_path;
75
-
76
- let entries: Vec<_> = WalkDir::new(dir_path)
77
- .follow_links(false)
78
- .into_iter()
79
- .filter_map(|e| e.ok())
80
- .filter(|e| e.file_type().is_file())
81
- .collect();
82
-
83
- let total_files = entries.len() as u64;
84
-
85
- let zst_file = File::create(zst_path)?;
86
- let buf_writer = BufWriter::with_capacity(16 * 1024 * 1024, zst_file);
90
+ ) -> anyhow::Result<(Vec<u8>, String)> {
91
+ let collected = collect_directory_files(dir_path);
92
+ let total_bytes = collected.total_bytes;
93
+ let entries = collected.entries;
87
94
 
88
95
  let actual_level = compression_level.min(3);
89
- let mut encoder = zstd::stream::Encoder::new(buf_writer, actual_level)
96
+ let mut encoder = zstd::stream::Encoder::new(
97
+ Vec::with_capacity(estimate_zst_capacity(total_bytes)),
98
+ actual_level,
99
+ )
90
100
  .map_err(|e| anyhow::anyhow!("zstd init: {}", e))?;
91
101
 
92
- let threads = num_cpus::get() as u32;
102
+ let threads = select_zstd_threads(total_bytes);
93
103
  if threads > 1 {
94
104
  let _ = encoder.multithread(threads);
95
105
  }
@@ -97,62 +107,233 @@ fn compress_dir_to_zst(
97
107
  let _ = encoder.window_log(30);
98
108
 
99
109
  encoder.write_all(MAGIC)?;
110
+ encoder.write_all(&PACK_MAGIC.to_be_bytes())?;
111
+ encoder.write_all(&(entries.len() as u32).to_be_bytes())?;
112
+
113
+ let mut file_list = Vec::with_capacity(entries.len());
114
+ let mut bytes_processed: u64 = 0;
115
+ let mut last_pct: u64 = 0;
116
+ let mut entry_index = 0usize;
117
+ while entry_index < entries.len() {
118
+ let batch_end = select_parallel_batch_end(&entries, entry_index);
119
+ if batch_end > entry_index + 1 {
120
+ let loaded = load_small_file_batch(&entries[entry_index..batch_end])?;
121
+ for (entry, maybe_bytes) in entries[entry_index..batch_end].iter().zip(loaded.into_iter()) {
122
+ let Some(bytes) = maybe_bytes else {
123
+ continue;
124
+ };
100
125
 
101
- let mut file_list = Vec::new();
102
- {
103
- let mut tar_builder = Builder::new(&mut encoder);
104
- for (idx, entry) in entries.iter().enumerate() {
105
- let full = entry.path();
106
- let rel = full.strip_prefix(base).unwrap_or(full);
107
- let rel_str = rel.to_string_lossy().replace('\\', "/");
108
-
109
- let metadata = match std::fs::metadata(full) {
110
- Ok(m) => m,
111
- Err(_) => continue,
112
- };
113
- let size = metadata.len();
114
-
115
- let mut header = Header::new_gnu();
116
- header.set_size(size);
117
- header.set_mode(0o644);
118
- header.set_cksum();
119
-
120
- let file = match File::open(full) {
121
- Ok(f) => f,
122
- Err(_) => continue,
123
- };
124
- let buf_reader = std::io::BufReader::with_capacity(
125
- (size as usize).min(4 * 1024 * 1024).max(8192),
126
- file,
127
- );
128
-
129
- tar_builder.append_data(&mut header, &rel_str, buf_reader)
130
- .map_err(|e| anyhow::anyhow!("tar append {}: {}", rel_str, e))?;
131
-
132
- file_list.push(serde_json::json!({"name": rel_str, "size": size}));
133
-
134
- if let Some(ref cb) = progress {
135
- let pct = ((idx as u64 + 1) * 85 / total_files.max(1)).min(85);
136
- cb(pct, 100, "compressing");
126
+ write_pack_entry_header(&mut encoder, &entry.rel_path, entry.size)?;
127
+ encoder.write_all(&bytes)
128
+ .map_err(|e| anyhow::anyhow!("pack write {}: {}", entry.rel_path, e))?;
129
+
130
+ file_list.push(FileListEntry {
131
+ name: entry.rel_path.clone(),
132
+ size: entry.size,
133
+ });
134
+
135
+ bytes_processed += entry.size;
136
+ report_compress_progress(progress, total_bytes, bytes_processed, &mut last_pct);
137
137
  }
138
+ entry_index = batch_end;
139
+ continue;
140
+ }
141
+
142
+ let entry = &entries[entry_index];
143
+ if write_directory_entry(&mut encoder, entry)? {
144
+ file_list.push(FileListEntry {
145
+ name: entry.rel_path.clone(),
146
+ size: entry.size,
147
+ });
148
+
149
+ bytes_processed += entry.size;
150
+ report_compress_progress(progress, total_bytes, bytes_processed, &mut last_pct);
138
151
  }
139
- tar_builder.finish().map_err(|e| anyhow::anyhow!("tar finish: {}", e))?;
152
+ entry_index += 1;
140
153
  }
141
154
 
142
- encoder.finish().map_err(|e| anyhow::anyhow!("zstd finish: {}", e))?;
155
+ let zst_buf = encoder.finish().map_err(|e| anyhow::anyhow!("zstd finish: {}", e))?;
156
+ let file_list_json = serde_json::to_string(&file_list)?;
143
157
 
144
- Ok(file_list)
158
+ Ok((zst_buf, file_list_json))
145
159
  }
146
160
 
147
- fn write_png_from_zst(
148
- zst_path: &Path,
161
+ fn write_pack_entry_header<W: Write>(writer: &mut W, rel_path: &str, size: u64) -> anyhow::Result<()> {
162
+ let name_bytes = rel_path.as_bytes();
163
+ let name_len = u16::try_from(name_bytes.len())
164
+ .map_err(|_| anyhow::anyhow!("path too long for pack entry: {}", rel_path))?;
165
+ writer.write_all(&name_len.to_be_bytes())?;
166
+ writer.write_all(name_bytes)?;
167
+ writer.write_all(&size.to_be_bytes())?;
168
+ Ok(())
169
+ }
170
+
171
+ fn write_directory_entry<W: Write>(writer: &mut W, entry: &DirectoryFile) -> anyhow::Result<bool> {
172
+ let file = match File::open(&entry.path) {
173
+ Ok(file) => file,
174
+ Err(_) => return Ok(false),
175
+ };
176
+
177
+ write_pack_entry_header(writer, &entry.rel_path, entry.size)?;
178
+
179
+ let mut buf_reader = std::io::BufReader::with_capacity(file_buffer_capacity(entry.size), file);
180
+ std::io::copy(&mut buf_reader, writer)
181
+ .map_err(|e| anyhow::anyhow!("pack write {}: {}", entry.rel_path, e))?;
182
+
183
+ Ok(true)
184
+ }
185
+
186
+ fn load_small_file_batch(entries: &[DirectoryFile]) -> anyhow::Result<Vec<Option<Vec<u8>>>> {
187
+ entries.par_iter().map(load_directory_entry_bytes).collect()
188
+ }
189
+
190
+ fn load_directory_entry_bytes(entry: &DirectoryFile) -> anyhow::Result<Option<Vec<u8>>> {
191
+ let mut file = match File::open(&entry.path) {
192
+ Ok(file) => file,
193
+ Err(_) => return Ok(None),
194
+ };
195
+
196
+ let reserve = usize::try_from(entry.size.min(PARALLEL_IO_BATCH_BYTES)).unwrap_or(MAX_FILE_BUFFER_CAPACITY);
197
+ let mut bytes = Vec::with_capacity(reserve.max(8192));
198
+ file.read_to_end(&mut bytes)
199
+ .map_err(|e| anyhow::anyhow!("pack read {}: {}", entry.rel_path, e))?;
200
+
201
+ Ok(Some(bytes))
202
+ }
203
+
204
+ fn select_parallel_batch_end(entries: &[DirectoryFile], start: usize) -> usize {
205
+ let Some(first) = entries.get(start) else {
206
+ return start;
207
+ };
208
+ if !should_parallelize_entry(first) {
209
+ return start + 1;
210
+ }
211
+
212
+ let mut end = start;
213
+ let mut batch_bytes = 0u64;
214
+ while end < entries.len() {
215
+ let entry = &entries[end];
216
+ if !should_parallelize_entry(entry) {
217
+ break;
218
+ }
219
+ if end > start {
220
+ if end - start >= PARALLEL_IO_BATCH_FILES {
221
+ break;
222
+ }
223
+ if batch_bytes.saturating_add(entry.size) > PARALLEL_IO_BATCH_BYTES {
224
+ break;
225
+ }
226
+ }
227
+ batch_bytes = batch_bytes.saturating_add(entry.size);
228
+ end += 1;
229
+ }
230
+
231
+ if end - start >= PARALLEL_IO_MIN_FILES {
232
+ end
233
+ } else {
234
+ start + 1
235
+ }
236
+ }
237
+
238
+ fn should_parallelize_entry(entry: &DirectoryFile) -> bool {
239
+ entry.size <= PARALLEL_IO_FILE_THRESHOLD
240
+ }
241
+
242
+ fn file_buffer_capacity(size: u64) -> usize {
243
+ usize::try_from(size)
244
+ .unwrap_or(MAX_FILE_BUFFER_CAPACITY)
245
+ .min(MAX_FILE_BUFFER_CAPACITY)
246
+ .max(8192)
247
+ }
248
+
249
+ fn report_compress_progress(
250
+ progress: &Option<ProgressCallback>,
251
+ total_bytes: u64,
252
+ bytes_processed: u64,
253
+ last_pct: &mut u64,
254
+ ) {
255
+ if let Some(ref cb) = progress {
256
+ let pct = if total_bytes > 0 {
257
+ (bytes_processed * 89 / total_bytes).min(89)
258
+ } else {
259
+ 89
260
+ };
261
+ if pct > *last_pct {
262
+ *last_pct = pct;
263
+ cb(pct, 100, "compressing");
264
+ }
265
+ }
266
+ }
267
+
268
+ fn collect_directory_files(dir_path: &Path) -> CollectedDirectory {
269
+ let mut entries = Vec::new();
270
+ let mut total_bytes = 0u64;
271
+
272
+ for entry in WalkDir::new(dir_path)
273
+ .follow_links(false)
274
+ .into_iter()
275
+ .filter_map(|entry| entry.ok())
276
+ .filter(|entry| entry.file_type().is_file())
277
+ {
278
+ let size = match entry.metadata() {
279
+ Ok(metadata) => metadata.len(),
280
+ Err(_) => continue,
281
+ };
282
+ let path = entry.into_path();
283
+ let rel = path.strip_prefix(dir_path).unwrap_or(path.as_path());
284
+ let rel_path = normalize_rel_path(rel);
285
+
286
+ total_bytes += size;
287
+ entries.push(DirectoryFile {
288
+ path,
289
+ rel_path,
290
+ size,
291
+ });
292
+ }
293
+
294
+ CollectedDirectory {
295
+ entries,
296
+ total_bytes,
297
+ }
298
+ }
299
+
300
+ fn normalize_rel_path(path: &Path) -> String {
301
+ let rel_path = path.to_string_lossy();
302
+ if rel_path.contains('\\') {
303
+ rel_path.replace('\\', "/")
304
+ } else {
305
+ rel_path.into_owned()
306
+ }
307
+ }
308
+
309
+ fn estimate_zst_capacity(total_bytes: u64) -> usize {
310
+ let capped = total_bytes.min(usize::MAX as u64) as usize;
311
+ (capped / 3).max(MIN_ZST_CAPACITY)
312
+ }
313
+
314
+ fn select_zstd_threads(total_bytes: u64) -> u32 {
315
+ let max_threads = num_cpus::get().max(1) as u32;
316
+ if total_bytes <= 32 * MB {
317
+ 1
318
+ } else if total_bytes <= 128 * MB {
319
+ max_threads.min(2)
320
+ } else if total_bytes <= 512 * MB {
321
+ max_threads.min(4)
322
+ } else {
323
+ max_threads.min(8)
324
+ }
325
+ }
326
+
327
+ fn write_png_from_zst_mem(
328
+ zst_buf: Vec<u8>,
149
329
  output_path: &Path,
150
330
  name: Option<&str>,
151
331
  file_list: Option<&str>,
152
332
  passphrase: Option<&str>,
153
333
  _encrypt_type: Option<&str>,
334
+ progress: &Option<ProgressCallback>,
154
335
  ) -> anyhow::Result<()> {
155
- let zst_size = std::fs::metadata(zst_path)?.len() as usize;
336
+ let zst_size = zst_buf.len();
156
337
 
157
338
  let mut encryptor = match passphrase {
158
339
  Some(pass) if !pass.is_empty() => Some(crate::crypto::StreamingEncryptor::new(pass)?),
@@ -239,8 +420,7 @@ fn write_png_from_zst(
239
420
  ihdr[9] = 2;
240
421
  write_chunk_hdr(&mut w, b"IHDR", &ihdr)?;
241
422
 
242
- let mut zst_file = File::open(zst_path)?;
243
- let mut zst_reader = std::io::BufReader::with_capacity(16 * 1024 * 1024, &mut zst_file);
423
+ let mut zst_reader = std::io::Cursor::new(zst_buf);
244
424
 
245
425
  write_idat_streaming(
246
426
  &mut w,
@@ -255,6 +435,7 @@ fn write_png_from_zst(
255
435
  marker_end_pos,
256
436
  idat_len,
257
437
  total_data_bytes,
438
+ progress,
258
439
  )?;
259
440
 
260
441
  if let Some(fl) = file_list {
@@ -291,6 +472,7 @@ fn write_idat_streaming<W: Write, R: Read>(
291
472
  marker_end_pos: usize,
292
473
  idat_len: usize,
293
474
  total_data_bytes: usize,
475
+ progress: &Option<ProgressCallback>,
294
476
  ) -> anyhow::Result<()> {
295
477
  w.write_all(&(idat_len as u32).to_be_bytes())?;
296
478
  w.write_all(b"IDAT")?;
@@ -308,13 +490,18 @@ fn write_idat_streaming<W: Write, R: Read>(
308
490
  let fl_chunk_data = file_list_chunk.unwrap_or(&[]);
309
491
  let payload_total = header_bytes.len() + zst_size + hmac_trailer_len + fl_chunk_data.len();
310
492
  let padding_after = total_data_bytes - payload_total.min(total_data_bytes);
311
-
312
493
  let marker_end_bytes = build_marker_end_bytes();
313
494
 
314
495
  let mut flat_pos: usize = 0;
315
496
  let mut scanline_pos: usize = 0;
316
497
  let mut deflate_block_remaining: usize = 0;
317
498
 
499
+ let mut adler = simd_adler32::Adler32::new();
500
+
501
+ let buf_size = 1024 * 1024;
502
+ let mut transfer_buf = vec![0u8; buf_size];
503
+ let zero_buf = vec![0u8; buf_size];
504
+
318
505
  let mut header_pos: usize = 0;
319
506
  let mut zst_remaining = zst_size;
320
507
  let mut hmac_pos: usize = 0;
@@ -323,14 +510,9 @@ fn write_idat_streaming<W: Write, R: Read>(
323
510
  let mut fl_pos: usize = 0;
324
511
  let mut zero_remaining = padding_after;
325
512
 
326
- let mut adler_a: u32 = 1;
327
- let mut adler_b: u32 = 0;
513
+ let mut last_png_pct: u64 = 89;
328
514
 
329
- let buf_size = 1024 * 1024;
330
- let mut transfer_buf = vec![0u8; buf_size];
331
- let zero_buf = vec![0u8; buf_size];
332
-
333
- for _row in 0..height {
515
+ for row_idx in 0..height {
334
516
  if deflate_block_remaining == 0 {
335
517
  let remaining_scanlines = scanlines_total - scanline_pos;
336
518
  let block_size = remaining_scanlines.min(65535);
@@ -350,8 +532,7 @@ fn write_idat_streaming<W: Write, R: Read>(
350
532
  let filter_byte = [0u8];
351
533
  w.write_all(&filter_byte)?;
352
534
  crc.update(&filter_byte);
353
- adler_a = (adler_a + 0) % 65521;
354
- adler_b = (adler_b + adler_a) % 65521;
535
+ adler.write(&filter_byte);
355
536
  scanline_pos += 1;
356
537
  deflate_block_remaining -= 1;
357
538
 
@@ -388,10 +569,7 @@ fn write_idat_streaming<W: Write, R: Read>(
388
569
  let slice = &marker_end_bytes[me_offset..me_offset + take];
389
570
  w.write_all(slice)?;
390
571
  crc.update(slice);
391
- for &b in slice {
392
- adler_a = (adler_a + b as u32) % 65521;
393
- adler_b = (adler_b + adler_a) % 65521;
394
- }
572
+ adler.write(slice);
395
573
  flat_pos += take;
396
574
  chunk_written += take;
397
575
  scanline_pos += take;
@@ -406,10 +584,7 @@ fn write_idat_streaming<W: Write, R: Read>(
406
584
  let slice = &header_bytes[header_pos..header_pos + take];
407
585
  w.write_all(slice)?;
408
586
  crc.update(slice);
409
- for &b in slice {
410
- adler_a = (adler_a + b as u32) % 65521;
411
- adler_b = (adler_b + adler_a) % 65521;
412
- }
587
+ adler.write(slice);
413
588
  header_pos += take;
414
589
  flat_pos += take;
415
590
  chunk_written += take;
@@ -426,10 +601,7 @@ fn write_idat_streaming<W: Write, R: Read>(
426
601
  }
427
602
  w.write_all(&transfer_buf[..got])?;
428
603
  crc.update(&transfer_buf[..got]);
429
- for &b in &transfer_buf[..got] {
430
- adler_a = (adler_a + b as u32) % 65521;
431
- adler_b = (adler_b + adler_a) % 65521;
432
- }
604
+ adler.write(&transfer_buf[..got]);
433
605
  zst_remaining -= got;
434
606
  flat_pos += got;
435
607
  chunk_written += got;
@@ -448,10 +620,7 @@ fn write_idat_streaming<W: Write, R: Read>(
448
620
  let slice = &hmac_bytes[hmac_pos..hmac_pos + take];
449
621
  w.write_all(slice)?;
450
622
  crc.update(slice);
451
- for &b in slice {
452
- adler_a = (adler_a + b as u32) % 65521;
453
- adler_b = (adler_b + adler_a) % 65521;
454
- }
623
+ adler.write(slice);
455
624
  hmac_pos += take;
456
625
  flat_pos += take;
457
626
  chunk_written += take;
@@ -470,10 +639,7 @@ fn write_idat_streaming<W: Write, R: Read>(
470
639
  let slice = &fl_chunk_data[fl_pos..fl_pos + take];
471
640
  w.write_all(slice)?;
472
641
  crc.update(slice);
473
- for &b in slice {
474
- adler_a = (adler_a + b as u32) % 65521;
475
- adler_b = (adler_b + adler_a) % 65521;
476
- }
642
+ adler.write(slice);
477
643
  fl_pos += take;
478
644
  flat_pos += take;
479
645
  chunk_written += take;
@@ -490,9 +656,7 @@ fn write_idat_streaming<W: Write, R: Read>(
490
656
  if take == 0 { break; }
491
657
  w.write_all(&zero_buf[..take])?;
492
658
  crc.update(&zero_buf[..take]);
493
- for _ in 0..take {
494
- adler_b = (adler_b + adler_a) % 65521;
495
- }
659
+ adler.write(&zero_buf[..take]);
496
660
  zero_remaining -= take;
497
661
  flat_pos += take;
498
662
  chunk_written += take;
@@ -502,10 +666,18 @@ fn write_idat_streaming<W: Write, R: Read>(
502
666
  }
503
667
  }
504
668
  }
669
+
670
+ if let Some(ref cb) = progress {
671
+ let pct = 90 + ((row_idx as u64 + 1) * 9 / height as u64).min(9);
672
+ if pct > last_png_pct {
673
+ last_png_pct = pct;
674
+ cb(pct, 100, "writing_png");
675
+ }
676
+ }
505
677
  }
506
678
 
507
- let adler = (adler_b << 16) | adler_a;
508
- let adler_bytes = adler.to_be_bytes();
679
+ let adler_val = adler.finish();
680
+ let adler_bytes = adler_val.to_be_bytes();
509
681
  w.write_all(&adler_bytes)?;
510
682
  crc.update(&adler_bytes);
511
683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxify",
3
- "version": "1.13.2",
3
+ "version": "1.13.4",
4
4
  "type": "module",
5
5
  "description": "Ultra-lightweight PNG steganography with native Rust acceleration. Encode binary data into PNG images with zstd compression.",
6
6
  "main": "dist/index.js",