The whole post in one strip. Every box is a section below; the green one is borrowed straight from the Reed-Solomon post, and the dashed pink one is the reason it's needed.
prerequisite

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 :

H(X)=ipilog2pi (1)

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:

LH(X) (2)

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.
the idea

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 .

0 1 0 1 1 − p 1 − p p p
The binary symmetric channel. Solid green: the bit survives. Dashed pink: it flips, with probability p.

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:

R<CPerror0 (3)

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.

the catch

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 :

  1. Source coding (theorem 1): remove the inherent redundancy of the data and push it down toward entropy.
  2. Channel coding (theorem 2): now add the carefully designed redundancy back - parity created mathematically to make it survive through this specific channel's noise.
  3. 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.

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:

Seven branches.
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 magic bytes doubles as a fast check to see if this is even a FrameCrypt video - check before decoding half an hour of frames.

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.

the idea

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.

Same 6 MB payload, two block sizes, both round-tripped through the real YouTube transcoder.
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.

the idea

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-size expand at 1080p, same 6 MB payload, all lossless locally - so every error below is YouTube's.
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 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.

Repeatability across three files at 1080p.
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.

the decision

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.

The same two 1080p uploads, re-downloaded. (No 480p rung was generated for these.)
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 scaling law

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.

Minimum bare (near-zero-error) logical block by upload tier, and the capacity it unlocks.
tier min viable block bytes / frame vs the old 720p/8×8
720p 4×4 7,200
1080p 4×4 16,200
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):

  1. Compress & encrypt: The document is packed into an AES-256 encrypted .zip (with compression being done before encryption according to theorem 1).
  2. 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⁸).
  3. Frame: Add FCR1 and 8-byte length field. Now the container knows about itself.
  4. 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.
  5. Transmission: H.264-encode, optionally upload. YouTube re-encodes it and it will adds its noise according to theorem 3.
  6. 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.
  7. Decoding: Check magic, read length of payload, cut the payload exactly. Read the marker byte; if armor is present, call RSCodec.decode to find and repairs up to 8 wrong bytes out of 255. Unzip with the password. Byte-for-byte identical file outputted.
1 bit = one 4×4 block · read back at the centre dot only 1101 0010 1101 …
Modulation and demodulation in one picture. The codec may try to corrupt the edges; the centre pixel is the last place the damage reaches.

Here's how the above map back to theory. Each step above corresponds to exactly one theorem:

The pipeline vs. the theorems.
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

  1. 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.
  2. FrameCrypt the seven experimental branches: master, refactor/minimal-fast-core, experiment/h264-and-ecc, revamp/v2, experiment/4x4, experiment/1080p-blocksizes, experiment/1440p.
  3. reedsolo — the pure-Python Reed-Solomon codec doing the armor in fc_ecc.py.
  4. 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.