Encode any file or folder into a lossless video. Optional Brotli compression and RSA-2048 + AES-256-GCM hybrid encryption, all from the command line. Built on Bun and FFmpeg.
input file ───► [ brotli? ──► RSA+AES? ──► video ] ───► output.mkv
↓ ↓
optional optional
The video is the file. Decoding is the exact inverse. The result is bit-exact — verified by SHA-256 round-trip in CI.
These platforms re-encode every uploaded video. The result is not bit-exact — frame data is lossy-transcoded and your original file cannot be recovered. Use the output for:
- Local archival (reliable — the file stays a video on your disk)
- Sharing via file transfer (Dropbox, S3, rsync, email attachment, USB)
- Hosting on platforms that preserve the original codec (own S3 +
<video>tag, GitHub releases, IPFS with?pin=raw)
The output is a normal .mkv — it plays in any video player, but treat it like a .zip: a container, not a streamable video.
# 1. install
git clone <repo-url> && cd mirr
bun install
# 2. (optional) generate encryption keys — skip if you don't need --encrypt
openssl genrsa -out priv.pem 2048
openssl rsa -in priv.pem -pubout -out pub.pem
# 3. encode (with both flags on — recommended)
export MIRR_PUBLIC_KEY="$(cat pub.pem)" # anyone with this can ENCODE for you
export MIRR_PRIVATE_KEY="$(cat priv.pem)" # only you can DECODE
bun run encode ./my-folder ./out --encrypt --compress
# 4. decode
bun run decode ./out ./restoredSecurity note: never commit
priv.pem. It is already in.gitignore.
mirr uses RSA-2048 + AES-256-GCM (hybrid encryption — RSA wraps a fresh AES key per file).
# private key (KEEP SECRET — needed to decode)
openssl genrsa -out priv.pem 2048
# public key (safe to share — needed to encode for you)
openssl rsa -in priv.pem -pubout -out pub.pemSet the env vars when running mirr:
# PowerShell
$env:MIRR_PUBLIC_KEY = Get-Content pub.pem -Raw
$env:MIRR_PRIVATE_KEY = Get-Content priv.pem -Raw
# bash / zsh
export MIRR_PUBLIC_KEY="$(cat pub.pem)"
export MIRR_PRIVATE_KEY="$(cat priv.pem)"Or use a .env file (see .env.example):
MIRR_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
MIRR_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
The keys must be PEM-encoded PKCS#1 or PKCS#8 RSA-2048 — exactly what
openssl genrsaproduces. Other sizes (e.g., 4096) are not supported.
bun run encode <input> <output-dir> [flags]| Flag | Effect |
|---|---|
| (none) | Encode the bytes as-is. Smart fallback skips compression if it would grow the data. |
--compress |
Compress with Brotli before encoding. Smart fallback: if Brotli would grow the data, the encoder stores the raw bytes and sets compressed: false in the header. |
--encrypt |
Encrypt with RSA-2048 + AES-256-GCM. Requires MIRR_PUBLIC_KEY env var. |
--encrypt --compress |
Recommended default. Same size as --compress when compression helps, ~300 B of RSA overhead. |
--fps <n> |
Frames per second (default: 30). |
--frame <WxH> |
Frame size (default: 1920×1080). |
--block-size <n> |
Block size in bytes (default: 4). |
--no-progress |
Hide the progress bar. |
bun run decode <input-dir> <output-path> [flags]| Flag | Effect |
|---|---|
| (none) | Decrypt (if encrypted), decompress (if compressed), and write the result. Files in the archive are extracted to output-path/. |
--keep-compressed |
Stop after decryption. Writes the still-compressed bytes to output-path. Useful for inspecting how much compression saved. |
--no-extract |
Write the inner file as a single blob instead of extracting. |
The decoder reads the compressed and encrypted flags from the header — you don't need to remember which flags were used at encode time.
| input folder | plain | --compress |
--encrypt |
--encrypt --compress |
best | saved |
|---|---|---|---|---|---|---|
| 1 MB (synthetic) | 0.73 MB | 0.61 MB | 1.15 MB | 0.60 MB | 0.60 MB | 40% |
| 5 MB (synthetic) | 3.31 MB | 2.77 MB | 5.49 MB | 2.77 MB | 2.77 MB | 45% |
| 20 MB (synthetic) | 13.19 MB | 10.97 MB | 21.93 MB | 10.97 MB | 10.97 MB | 45% |
| 100 MB (synthetic) | 64.73 MB | 54.76 MB | 109.48 MB | 54.76 MB | 54.76 MB | 45% |
| 500 MB (synthetic) | 323.35 MB | 273.72 MB | 547.35 MB | 273.70 MB | 273.70 MB | 45% |
| 1.1 GB (real zip + exe + jpg) | 1156.46 MB | — | 1157.21 MB | 1137.36 MB | 1137.36 MB | -8%¹ |
¹ For the 1.1 GB real-world case, --compress was skipped (smart fallback would yield the same output as plain because all files are already-compressed). Surprisingly, --encrypt --compress is the smallest of the three because Brotli gives a small gain on the archive format overhead (entry-count header + path lengths + isDir flags) — the 1.1 GB of compressed data itself is unchanged.
All rows are bit-exact round-trip verified by SHA-256 (the 1.1 GB row included —
decoded → 1057.15 MB ✅ bit-exact).
--compresssaves ~35–45% on a 50/50 mix of text and random data, and far more on pure text (logs, JSON, source code can hit 99%+).--encryptalone is always worse thanplain— it adds ~10% overhead (RSA-2048 wrapped key + AES-GCM auth tag). Use it only when confidentiality is worth the cost.--encrypt --compressis only ~300 bytes larger than--compress(one RSA-wrapped 256-bit AES key). Use it by default: same size as plain compression when compression helps, privacy for free.- Smart fallback: when Brotli can't shrink the data (already-compressed input like JPG / MP4 / random), the encoder keeps the raw bytes and sets
compressed: falsein the header — no per-byte inflation, no decode-time decompression. The 1.1 GB row above demonstrates this on real data.
| input | plain | --compress |
--encrypt |
--encrypt --compress |
|---|---|---|---|---|
| 1 MB | 359 ms | 2.0 s | 368 ms | 1.9 s |
| 5 MB | 489 ms | 3.6 s | 516 ms | 3.4 s |
| 20 MB | 865 ms | 14.7 s | 917 ms | 14.8 s |
| 100 MB | 2.5 s | 57.1 s | 3.1 s | 53.4 s |
| 500 MB | 11.3 s | 288.1 s | 16.5 s | 285.1 s |
| 1.1 GB (real) | 42.5 s | — | 39.7 s | 2500.0 s |
Compression is the dominant cost. It is single-threaded Brotli over the entire input — for multi-GB files, expect a few minutes. Encryption is just RSA on a 32-byte key + AES-GCM (fast).
| Your data looks like | Use |
|---|---|
| Logs, JSON, source code, any text | --encrypt --compress (≥40% saved) |
| Mixed binary (typical folder) | --encrypt --compress (~30–45% saved) |
| Already-compressed (zip, mp4, jpg, png) | plain (no savings possible; saves 30 min encode time) |
| Don't care about size, only security | --encrypt (~10% overhead) |
Disk space: the variant matrix test copies inputs and outputs under the system temp dir. For the 1.1 GB case the script needs ~3 GB of free space (input copy + 3 outputs). On Windows the test auto-prefers
D:/tmpif your C: drive is low on space.
- Archive the input (if a directory) into a single binary blob with our custom archive format.
- Compress (Brotli) if
--compressis set and the output is smaller than the input. - Encrypt (RSA-2048 wraps a fresh AES-256 key, then AES-256-GCM encrypts the payload) if
--encryptis set. - Encode the resulting payload as 24-bit RGB pixels in an FFV1-lossless MKV video, one frame at a time.
- Write the protocol header in the first frame so the decoder knows the layout.
The reverse on decode: read frames → parse header → slice dataLength → decrypt → decompress → extract.
For files larger than 1.5 GB, the encoder automatically splits into multiple .mkv parts, each with its own header and (if --encrypt) its own AES key.
# 8-case round-trip matrix (single/dir × plain/encrypt/encrypt+compress + key errors)
bash scripts/test-matrix.sh
# Compression smart-fallback (proves Brotli is skipped when it grows the data)
bun run scripts/test-compression.ts
# Encode variant matrix (size comparison across all 4 flags × 5 input sizes)
bun run scripts/test-matrix-variants.tsAll tests are hermetic (no hardcoded paths, no network, no FFmpeg installation step) and finish in under 12 minutes on a 4-core machine.
- Bun ≥ 1.2
- FFmpeg with the
ffv1codec (in nearly every distro's ffmpeg package) - (For encryption) OpenSSL — to generate keys
MIT