This is part three of an best trilogy. Hamming codes found the one bit that lied; Reed-Solomon rebuilt whole missing chunks. You don't strictly need either to follow along, but the Reed-Solomon post is where the error-correcting machinery used here gets built from first principles.
Try it yourself like a pro in one minute
If you just want to use FrameCrypt before reading how it works, this box is the whole thing. One command installs it, asks whether you want the terminal or browser version, and launches it:
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/domabyte/Framecrypt/master/install.sh | bash
# Windows (PowerShell)
irm https://raw.githubusercontent.com/domabyte/Framecrypt/master/install.ps1 | iex
That drops FrameCrypt in ~/framecrypt with its own
Python environment. From there every action is a plain command -
hide a file, read it back, or (optionally) upload it on YouTube:
# hide a file → video in encoded_videos/ (asks for a password)
python3 framecrypt.py encode secret.pdf --preset dense
# read it back → file in decoded_files/ (asks for the password)
python3 framecrypt.py decode encoded_video.mp4
# optional: keep the video on YouTube (unlisted) and pull it back later
python3 framecrypt.py upload encoded_video.mp4 --title "backup"
python3 framecrypt.py download <url-or-id>
Prefer buttons to flags? python3 framecrypt.py
opens a full terminal app and
python3 framecrypt.py web a local drag-and-drop page
bound to 127.0.0.1. There's also a secondary, more safe
configuration (standard) as well as the option for
YouTube uploading/downloading.
That's the entire user experience: two commands to hide and recover a file. The rest of this post is everything happening behind them — why a file survives being turned into video and shoved through YouTube's compressor, starting from the theory and ending at the exact block size. Let's go deeper.
1. Introduction
The last two posts were about codes — clever ideas to fix corrupted data. This one starts with the man who made all of them inevitable. In 1948, published one paper that invented information theory whole: what information is, how small it can be squeezed, and how fast it can be pushed through a noisy wire without errors. Hamming (1950) and Reed-Solomon (1960) are engineers answering a challenge Shannon threw down two years before Hamming even started.
So the first half of this post connects the dots: the source coding theorem and the noisy-channel coding theorem explaining why the previous two posts exist at all.
The second half is where it gets personal. FrameCrypt is my project that hides any file inside a black-and-white video and (optionally) uploads it to YouTube as free private storage. It turns out to be a working model of Shannon's entire pipeline — compression, encryption, channel coding, modulation, and passing through a noisy channel (YouTube's re-encoder). I expirimented with it a lot each time fixing a different problem. We'll walk them in order: what went wrong, why, and what the fix looked like — full algorithm, no hand-waving.
2. First, a unit for surprise — entropy
Before he could prove anything, Shannon had to pin down a number: how much information does a message actually carry? His answer sounds almost like a riddle — information is surprise. Tell me something I already know and you've told me nothing. Flip a fair coin, you've handed me exactly one bit, because right up until it lands I genuinely can't call it. Flip a coin that comes up heads 99 times out of 100 and you've barely told me a thing. I'd have guessed heads anyway.
Take a source that keeps spitting out symbols, each with its own probability p1, p2, …, and average that surprise across all of them. That average is what Shannon called the :
Claude ShannonClaude Elwood Shannon was an American mathematician, electrical engineer, and cryptographer known as the "father of information theory". He is best known for his work on information theory, which laid the foundation for modern cryptography, computer science and artificial intelligence. Wikipedia
Anthropic even named its Claude models after Claude Shannon himself.
The average number of bits of genuine surprise per symbol from a source. A fair coin: 1 bit. A fair die: log₂6 ≈ 2.58 bits. A source that always says "A": 0 bits. It is the hard floor for lossless compression. Wikipedia
Just plug in some values and it holds. The fair coin turns out to be H = −(½ log₂½ + ½ log₂½) = 1 bit, just like predicted, and the 99% coin drops down to 0.08 bits or so. It's English that always takes people by surprise - it conveys 1 to 1.5 bits of information per character, although each character is stored using all 8 bits of ASCII. And this difference between bits of storage and bits of actual surprise is what makes compression possible.
3. Theorem one — source coding, or why zip works and then stops working
Shannon's source coding theorem takes the idea of "compression" and puts a hard limit on it. Squeeze a source with entropy H as cleverly as you want, the average code length L, in bits per symbol, of any lossless scheme still can't go below:
As much as you try and as far as you get from it, L will never go under it. Which leads to two facts which are rather straightforward:
- Compression is just redundancy removal — nothing fancier. zip, gzip, zstd: all of them are trying to close the gap between 8 bits taken by a byte and the few bits of information hidden in it.
- You can't compress the same thing twice. Once data has been squeezed down to its entropy it looks statistically identical to random noise, so there's simply no pattern left to grab. It's the same reason encrypted data won't compress further either.
It makes the order of operations quite clear for everything that tries to do both: first compression, then encryption. Otherwise, the algorithm trying to compress the data will be presented with an apparently random data set (ciphertext), will find nothing to compress and return an empty result. This needs to be kept in mind as FrameCrypt's whole workflow depends on it.
4. Theorem two — the noisy-channel coding theorem
Now the weird one. Consider the simplest noisiest wire: you pass bits through it, and each one of those bits is independently flipped with probability p. This is our beloved binary symmetric channel. That's the .
A Binary Symmetric Channel (BSC) is a fundamental communications model in information theory representing a noisy medium. It takes a binary input (0 or 1) and outputs a binary bit, but the bit is accidentally "flipped" with a specific crossover probability, p, regardless of whether the original bit was a 0 or a 1.
Before 1948, everyone "knew" how this went: if the wire is noisy, your only real defence is to repeat yourself. Say everything twice, three times, five times, and in order to make the error rate go to zero, you would have to repeat an infinite number of times. Kind of a Reliability vs speed, eternal tug-of-war. Haha...
Shannon showed that's just wrong. Every channel comes with one magic number. Its C and the theorem says:
You send at a rate R under capacity and there is a guarantee that there will be a code that guarantees low error probability. Rate goes beyond C and everything is lost. For the binary symmetric channel there is an explicit expression for the capacity.
Channel capacityThe maximum rate (bits of information per channel use) at which data can cross a noisy channel with error probability approaching zero. It is a property of the channel, not of any particular code.
Shannon's proof is basically a magic trick. The ideas says that there is a code that works, but it doesn't say how to find it. Hamming (1950) and Reed-Solomon (1960) were among the first to search for the ideal code. It took until the 1990s-2000s (turbo codes, then LDPC, then polar codes) when we finally got codes that came close to the boundary. The 5G modem in your smartphone is a 70-year proof of the theorem.
5. Connecting the dots — Shannon's pipeline
Place the two theorems side by side, add the modulation step to physically transmit the bits, and boom! We have a complete communication system, neatly divided into functions, each one earning its own special name, the :
- Source coding (theorem 1): remove the inherent redundancy of the data and push it down toward entropy.
- Channel coding (theorem 2): now add the carefully designed redundancy back - parity created mathematically to make it survive through this specific channel's noise.
- Modulation: map those coded bits onto an actual physical signal that can be carried through the channel.
The cool thing about this is that we take redundancy away, and put it right back in. But they are two very different kinds of redundancy. One is the accidental and not good for much against noise, while another is surgically inserted so that the decoder can directly pinpoint where the noise hit. They are two sides of the same coin, and separation theorem guarantees that the two tasks do not conflict and you can optimize each separately.
Source-channel separation theoremShannon's result that compressing the source and protecting against channel noise can be done as two independent stages. It's why zip and Reed-Solomon can be separate libraries instead of one unholy mess.
6. Enter FrameCrypt
The idea to convert data bits into pixels and back is not mine. I
was heavily inspired by someone else's idea, and I took it further,
maybe a bit too far:
YouTube hands you unlimited private video storage. Video is just
pixels. Pixels can hold bits. So YouTube is a free, unlimited hard
drive.
FrameCrypt takes any file, paints it as a grid of
black-and-white squares across the frames of an mp4, and decodes it
back into the original file.
The catch and the reason this turns into a Shannon story is that: YouTube doesn't actually keep your pixels. It re-encodes every upload with its own lossy compression: your bits go in, a transcoder processes them, and they come back out slightly wrong. That isn't a file transfer, it's a noisy channel and every decision in this project is secretly a bet on one of the theorems above. The process went through seven experiments to find one that works better than the others:
| branch | era | what it fixed |
|---|---|---|
| master | 2023 | nothing but the glorious shitshow |
| refactor/minimal-fast-core | 2026 | correctness + ~100× speed |
| experiment/h264-and-ecc | 2026 | rate (H.264) + noise (Reed-Solomon) |
| revamp/v2 | 2026 | real crypto + actually usable (TUI/web/YouTube) |
| experiment/4x4 | 2026 | 4× density at 720p - survives barely |
| experiment/1080p-blocksizes | 2026 | 1080p block-size experiment - ships 1080p/4×4, 9× the rate |
| experiment/1440p | 2026 | 1440p block-size experiment - with ~2-pixel and finding scaling law |
7. Branch one: master, or how everything went wrong
The 2023 code worked - files went in, files came back out - and yet almost every line of it was a mistake I can now name with some precision. The whole loop was just to read the file - one byte at a time, turn each byte into a binary string via a scenic detour through hexadecimal, and draw every 1-bit as a little white rectangle with PIL:
# encode_to_frames.py (master, 2023) — the crime scene
while byte := f.read(1):
binary = "{0:08b}".format(int(hex(byte[0])[2:], 16)) # byte → hex → int → string 🙃
for a in range(len(binary)):
if binary[a] == "1":
ImageDraw.Draw(img).rectangle(
(current_x, current_y,
current_x + pixel_density - 1, current_y + pixel_density - 1),
fill="white", outline=None, width=1)
current_x += pixel_density
...
img.save("cache.png") # write frame to DISK
video.write(cv2.imread("cache.png")) # read it BACK from disk
Here's the genuinely interesting part - i drew the squares one bit at a time, and for every single frame it saved a picture to the hard drive and re-opened it. Imagine painting a wall by dipping a single hair of a brush, one dot at a time, and walking to the sink between every dot. Exhausting and slow!
8. Branch two: refactor/minimal-fast-core
The 2026 rewrite threw out ~1,600 lines and rebuilt the core in about 90. There are two revisions that are particularly important, one for speed and one for correctness.
Vectorise everything. The for-loops that were
working with bytes, bits, and pixels of Python code have been
reduced to just four numpy statements. Unpack all the bits from the
message in one go and convert them into frames and then expand them
into blocks using repeat twice without using PIL, no
cache.png, no per-pixel calls:
# fc_video.py (the entire modulation step)
bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
frames = bits.reshape(-1, rows, cols) # one row per video frame
...
block_img = np.repeat(np.repeat(fb, block, 0), block, 1) * np.uint8(255)
writer.write(cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR))
The return journey does the same thing: instead of asking each pixel how it's doing, decode grabs one pixel from the centre of every block with a single strided slice and thresholds the whole lot in one vectorised comparison:
centers = gray[block//2 :: block, block//2 :: block] # one pixel per block
bits = (centers > 127).astype(np.uint8) # hard-decision demodulation
The other big fix was correctness and it starts with a question the
old decoder couldn't answer: where does the real data end? Encode
Hello and the video might decode as
HelloXsj81@aK…. How do you know where to stop? You
don't. The original basically relied on luck.
The
refactor prepends the tiny header above: FCR1 to
identify the format, then an 8-byte big-endian length say
128347 bytes. Decoding becomes mechanical: read the
header, read exactly that many bytes, stop. No guessing.
9. Branch three: experiment/h264-and-ecc — walking
toward the noise
Both v1 and the refactor relied upon an ancient
mp4v codec from OpenCV. Changing the fourcc to
avc1 (H.264) reduced the output filesize by
~4.5× smaller which is huge, considering the video
is already ~26× bigger than the size of the file it contains, and
the exact same blocks were used. So where did all those
bytes go?
Turns out, our frames are perfect inputs for the H.264's codec's
compression algorithm whose entire goal is one -
can I avoid storing this at all? and it has three ways to
answer yes.
Prediction: a block of uniform color is like its
neighboring blocks, and like the same part in the previous frame, so
H.264 stores "copy that" instead of actual pixels.
The transform: DCT of a flat block is a single
coefficient followed by zeroes.
Entropy coding (H.264's CABAC): it gives that
zero-valued run a zero-sized code and spends bits on rare non-zero.
mp4v did cruder versions of all three of those. None of those knew
anything about our data; just being black-and-white is enough to
make a grid look exactly like a flat, repetitive sequence H.264 was
made to squish.
The other side is the one thing that neither of those can predict or flatten the sharp edge between the black and the white. That quantized aggressively - blurring away precisely the high-frequency detail that our bits live in. Channel-wise, The signal and the noise are becoming indistinguishable.
That's precisely why the
Reed-Solomon post was written.
This branch added fc_ecc.py, a wrapper around
reedsolo so small I can show you the entire thing:
# fc_ecc.py - the whole file, minus docstrings
def protect(data: bytes, nsym: int = 0) -> bytes:
if nsym <= 0:
return b"\x00" + data # marker: no armor
return bytes([nsym]) + bytes(RSCodec(nsym).encode(data))
def recover(wrapped: bytes) -> bytes:
nsym, body = wrapped[0], wrapped[1:]
if nsym == 0:
return body
return bytes(RSCodec(nsym).decode(body)[0])
A single marker byte stores nsym, the number of parity bytes in each 255-byte Reed-Solomon block, so that decoder knows whether there's any ECC layer in use and how many parity bytes to decode. With nsym = 16 each block is RS(255, 239) — 239 data bytes and 16 parity. This comes at the cost of 16/239 ≈ 6.7% in file size.
So why block size 8, and not, say, 10? Because H.264 (and JPEG,
and the rest) compress using
8×8-pixel DCT tiles. Setting
block = 8 guarantees that each FrameCrypt block falls
entirely inside one codec tile.
10. Branch four & five: how small can the block get?
There was one more thing that keep bugging me since I started this
project: an 8×8 block consumes 64 pixels to carry a single bit, so a
reduction to 4×4 should increase the throughput by a factor
of four. It seemed obvious that the downside would come from error
correction because smaller blocks would be closer to the noise floor
of the codec. Branch experiment/4x4 decided to check
whether that was really true or not and the same 6 MB payload,
encoded once at 8×8 and once at 4×4, each one passed through the
Youtube transcoder and compared bit-for-bit against the original.
The prediction was wrong - in the most interesting way possible.
| measurement | 4×4 (experiment) | 8×8 (720p default) |
|---|---|---|
| video out | 18 MB · 834 frames · 28 s | 16 MB · 3,334 frames · 111 s |
| local H.264 BER | 0 | 0 |
| post-YouTube BER | 0 / 48 M bits | 0 / 48 M bits |
| bitrate YouTube served | 5.5 Mbps | 1.58 Mbps |
Zero bit errors at 4×4. Not "recoverable with parity" but literally zero wrong bits out of 48 million of them without any error correction enabled.
So why did this design pass through? This happend because YouTube protected it. Rate control looked at the high-frequency 4×4 frames, detected them as the content resistant to compression and allocated 5.5 Mbps to their quality, which was 3.5× more than the 1.58 Mbps of the 8×8 video.
This is the 8×8/DCT alignment story from branch three, run in reverse. An 8×8 block lands on exactly one DCT tile, so the codec sees one flat colour - a single coefficient, almost free to store. A 4×4 block contains four payload bits per 8×8 tile, so the image may alternate the color of its pixels every fouth one: it produces a high-frequency, grid-misaligned pattern with a dense DCT filled with many non-zero coefficients. The 4×4 block size is closer to the codec's efficiency floor which is exactly why YouTube couldn't squish it, and exactly why it survived.
Zero at 720p, on one sample not something to ship on. That's why i
created a new branch off revamp/v2,
experiment/1080p-blocksizes, in order to push the
limits in two directions at once: increased the canvas to full
1920×1080 pixels and tested all possible block
sizes: 2×2, 4×4, 8×8 until YouTube finally got one.
| block | bytes / frame | frames (length) | post-YouTube BER | ECC needed? |
|---|---|---|---|---|
| 2×2 | 64,800 | 93 (3.1 s) | 6.4×10⁻⁵ - worst block has 21 bad bytes | yes - nsym ≥ 42 |
| 4×4 | 16,200 | 371 (12.4 s) | 0 | no |
| 8×8 | 4,050 | 1,482 (49.4 s) | 0 | no |
1080p + 4×4 is the sweet spot. Compared to the previous 720p/8×8 default configuration, it provides 9× more data per frame - 16,200 bytes vs. 1,800 bytes - but retains the zero error survival rate. Both the 8×8 and 4×4 blocks at 1080p produces a zero error rate, but latter carries 4x more bits per frame, so no reason to waste those pixels. The 8×8 is the safe but wasteful choice at 1080p. The 2×2 where the transcoder finally bites: the errors are small (6.4×10⁻⁵) but clustered, one 255-byte Reed-Solomon block taking 21 hits, so 2×2 is only viable armored.
Of course, one sample still proves nothing, so I repeated the two block sizes that matter - 4×4 (the winner) and 2×2 (the loser) on three different files: two random payloads (what real AES ciphertext looks like) and one structured, low-entropy text file, in order to vary the spatial statistics.
| file | 4×4 BER | 2×2 BER | 2×2 worst block → nsym |
|---|---|---|---|
| randA · 6 MB random | 0 | 6.7×10⁻⁵ | 21 → nsym 42 |
| randB · 7 MB random | 0 | 1.1×10⁻⁵ | 13 → nsym 26 |
| struct · 5 MB text | 0 | 0 | — |
4×4 holds exactly zero in all of them - the most-tested claim in the whole project now, not luck. But the 2×2 is unstable and it is the repeats that revealed that: the structured file alone came back clean, which would have looked safe on its own - but both random payloads are full of errors, and the worst-case parity they demanded is varying b/w nsym 26 to 42 per frame.
So i picked the winner. 1080p + 4×4 becomes the live modulation: 9× the capacity of the old 720p/8×8, zero errors consistently, and the best payload-per-downloaded-MB of the three. 8×8 drops to a paranoid-only option.
11. The rendition ladder and a 4-pixel law
But all the results so far assumed the viewer downloads the native stream. However, YouTube does not deliver one file. It delivers a ladder of renditions (1080p → 720p → 480p → 360p), where each subsequent step is a more compressed version of the video file. What the decoder sees is not "is the format 4×4?" - it is "which rendition of 4×4?" No new upload was necessary to verify. I just downloaded again all 1080p videos.
| logical block | download rung | px per block | BER | verdict |
|---|---|---|---|---|
| 4×4 | 1080p (native) | 4.0 | 0 | clean |
| 4×4 | 720p | 2.67 | 5.0×10⁻⁴ | recoverable (nsym ~46) |
| 4×4 | 360p | 1.33 | 1.7×10⁻² | dead |
| 2×2 | 1080p (native) | 2.0 | 6.7×10⁻⁵ | marginal |
| 2×2 | 720p | 1.33 | 6.5×10⁻² | dead |
| 2×2 | 360p | 0.67 | 3.1×10⁻¹ | destroyed |
There is only one single parameter explaining this entire table. As it turns out, every previous result as well. Block survival rate depends on neither resolution nor block size, but on the number of physical pixels of a logical block at the rendition downloaded:
- ≥ 4 px → zero errors (720p/8×8 and 1080p/4×4 both fall into this category)
- ~2.7 px → recoverable with ECC
- ~2 px → marginal
- ≤ 1.3 px → degrading to dead
A single ~4-pixel threshold explains all he observations of the last three experiments. It changes the whole focus of the project - the limitation of FrameCrypt is not resolution, nor block size, but the product of those two at the actual delivered rung.
The minimum number of logical pixels for a survivable block decreases with the increase of upload resolution - not because 2 px suddenly becomes 200 px, but because of extra bitrate and imrpoved codec used by higher tiers.
| tier | min viable block | bytes / frame | vs the old 720p/8×8 |
|---|---|---|---|
| 720p | 4×4 | 7,200 | 4× |
| 1080p | 4×4 | 16,200 | 9× |
| 1440p | 2×2 (trivial ECC) | 115,200 | 64× |
Therefore, the map is detailed enough to name operating points. Safe default is 1080p + 4×4 - its own downloader always requests native, and it sits comfortably above the 4-px floor. Max density is 1440p + 2×2 with light ECC ( nsym ~16–24 budget for run-to-run variance, not just nsym 2) - it provides 64× the density of the default. It is gated by the resize-on-decode fix and by the acutal downloading of the native stream. There is nothing surviving bare on any rungs lower than its own: 4×4 dies at 360p, 2×2 dies at 720p. That's the true limit, and the 1080p default clears it only because it is requesting the native stream - an assumption that when pushed one tier up, breaks in a way worth exposing.
12. The full algorithm, end to end
Here's everything above collapsed into the exact byte-journey of a
single encode. Say you want to convert thesis.pdf (1
MB):
- Compress & encrypt: The document is packed into an AES-256 encrypted .zip (with compression being done before encryption according to theorem 1).
-
Channel-code:
fc_ecc.protect(data, 16):The marker byte is added to the data. Each chunk of 239 bytes to 255 with Reed-Solomon parity code over GF(2⁸). -
Frame: Add
FCR1and 8-byte length field. Now the container knows about itself. -
Modulation: With
np.unpackbits, converts bytes into a bit-stream; each bit becomes a 4×4 block (0 → black, 1 → white) on a 1920×1080 canvas: 480 × 270 = 129,600 bits = 16,200 bytes per frame. A 1 MB file ≈ 65 frames ≈ 2 seconds of video at 30 fps. - Transmission: H.264-encode, optionally upload. YouTube re-encodes it and it will adds its noise according to theorem 3.
- Demodulation: Download video, for each frame get the centre pixel of each block, and threshold it at 127. Even for bad blocks, it will preserve the center value on the right side of mid-grey.
-
Decoding: Check magic, read length of payload,
cut the payload exactly. Read the marker byte; if armor is
present, call
RSCodec.decodeto find and repairs up to 8 wrong bytes out of 255. Unzip with the password. Byte-for-byte identical file outputted.
Here's how the above map back to theory. Each step above corresponds to exactly one theorem:
| FrameCrypt stage | Shannon concept |
|---|---|
| deflate algorithm with the zip | source coding - approaches the entropy limit |
| AES-256 | ciphertext ≈ maximum-entropy source (and why it has to happen after compression) |
| Reed-Solomon parity bits | noisy channel theorem - engineered redundancy at rate < C |
| 4×4 B/W blocks at 1080p, threshold at 127 | modulation & hard-decision decoding; block size balances data rate vs noise tolerance |
| YouTube's transcoder | the noisy channel itself |
13. Closing thought
Three posts back, this whole thing began with one parity bit. It ends up, atleast for now, as a pipeline with compression, encryption, parity, modulation all there because 32-yr-old guy at Bell Labs deciced in 1948 that "information" deserved both a unit and a speed limit. The 2023 FrameCrypt was almost wrong except the physics, and it still worked, because it accidentally paid the pre-Shannon price: lots of redundancy, and a poor rate. .
14. References
- A Mathematical Theory of Communication — C. E. Shannon, Bell System Technical Journal, 1948. The paper. Both the source coding and noisy-channel theorems live here.
-
FrameCrypt the seven experimental branches:
master,refactor/minimal-fast-core,experiment/h264-and-ecc,revamp/v2,experiment/4x4,experiment/1080p-blocksizes,experiment/1440p. -
reedsolo
— the pure-Python Reed-Solomon codec doing the armor in
fc_ecc.py. - Hamming codes: finding the one bit that lied and Reed-Solomon codes: the polynomial that survives a scratch - the first two parts of this trilogy, here and here.
note: a few blocks here rephrased with an AI assistant for finding the right word, purely for clearer wording and explanation. the theorems, the byte-level walkthroughs, and the git archaeology are the real deal — every code snippet is lifted from the actual branches.