Normalize
Why some video won't stream
Most packages play their video the moment a learner presses play. Some make them wait for
the whole file to download first, or pull every byte of it before anyone presses anything.
The player is the same in both cases. The difference is how the video was put into the
.h5p, and it can be fixed once, in the package.
How a video plays from inside a package
An .h5p is a zip file. The player never unpacks it: it reads the files it needs
straight out of the archive and serves them to the H5P runtime from inside the browser. A
<video> element does not ask for a whole file. It asks for the bytes it
needs right now — the header first, then the next few seconds, then wherever the learner
seeks — with Range requests.
A zip can hold each file in one of two ways. Stored means the bytes sit in the archive exactly as they are in the original file. Deflated means they are compressed. For text and scripts that is the right choice. For video and audio it is the wrong one, because they are already compressed: deflating an mp4 saves almost nothing, and it takes away the one property streaming depends on.
Stored — the video is a plain slice of the archive
A seek to the marker asks for a few hundred kilobytes at that point, and only those cross the network.
Deflated — the video can only be read from its start
Deflate has no random access. To produce the bytes at the marker, everything in front of them has to be downloaded and decompressed first.
Deflated, index at the end — nothing plays until the last byte
An mp4 needs its index, the moov box, before it can show a single frame.
Many encoders write it at the end. Stored, that costs one small request; deflated, it
costs the whole file.
| How the video is packed | First frame | Seeking | Bytes pulled |
|---|---|---|---|
| Stored | After the first few hundred kilobytes | Instant, anywhere | Only what is watched |
| Deflated, index at the front | After the first bytes of the stream | Forward: waits for everything in between | The whole file, once anything touches it |
| Deflated, index at the end | After the last byte | Only once the whole file is there | The whole file, before the first frame |
Why the player can't work around it
It is tempting to think a cleverer player could jump into the middle of a compressed video. It cannot. A deflate stream only makes sense read from its start: each block refers back to the bytes before it, and nothing in the stream marks a place where decoding could begin again. Decompressing is not the slow part either — the browser's own decompressor handles hundreds of megabytes a second. The wait is the download, and every compressed byte in front of the one you want has to arrive.
So once anything asks for a deflated video, the player has to pull all of it. H5P's video player asks for the header as soon as the content appears, whether or not anyone presses play, so a deflated video is downloaded in full on every first visit. Measured with an 80 MB deflated video: 26 MB came down in the first fifteen seconds with the video paused, competing with the icons and fonts the rest of the content was still waiting for.
What the player can do is start sooner. With preload="auto" it begins pulling
large compressed media as soon as the content is up, instead of when the learner presses
play. That shortens the wait. It does not remove it.
Real packages
- A 237 MB package with a 220 MB video, deflated and with its index at the end. Compressing it saved 0.9%. For that 0.9%, nothing can play until all 220 MB have arrived: about two minutes on a typical connection before the first frame.
- An Interactive Book with six deflated videos. H5P creates a video element for every chapter, so all six were requested within the first second, and the first chapter's video took 26 s to start instead of the 16 s it takes on its own.
- The 80 MB video above, normalized: the same first visit pulled 1.4 MB instead of 81.
Exports differ by tool. Of the exports measured here, one from h5p.com stored its media and two from other platforms deflated everything.
Is my package affected?
Any zip tool shows how each file is packed. On macOS or Linux, list the archive and look at the Method column for your video and audio files:
unzip -v course.h5p
Length Method Size Cmpr Date Time CRC-32 Name
-------- ------ ------- ---- ---------- ----- -------- ----
812 Defl:N 391 52% 09-23-2026 10:14 5e0c1a7b h5p.json
…
93418722 Defl:N 85402231 9% 09-23-2026 10:14 3c2a9f10 content/videos/lesson.mp4
Stored is fine. Defl:N on a media file means it will not stream,
and a compression figure in single digits means it was never worth compressing. Whether
an mp4's index is at the end cannot be seen here; the normalizer's dry run reports it.
Fix it once: normalize the package
@missing-elements/h5p-normalize is a command that rewrites a package so it
streams. Run it once, wherever the package is published from, and every learner after that
gets the fast path. It needs Node 20 or later and nothing else.
npx @missing-elements/h5p-normalize course.h5p --dry-run # report only, write nothing npx @missing-elements/h5p-normalize course.h5p # writes course.normalized.h5p npx @missing-elements/h5p-normalize https://host.example/course.h5p -o course.h5p
What it changes, all of it in the zip container:
- Media is stored, not deflated — video, audio, images, fonts and PDFs, which are already compressed. A
Rangerequest can then reach any byte directly. - An mp4 whose index is at the end gets it moved to the front. That is a remux, not a re-encode: the picture and sound are untouched, only the index's pointers move. A file it does not fully understand is left exactly as it was.
- Files are reordered so the libraries come first and content media last. On a host that does not support
Range, the content then starts before the media has finished downloading. - Scripts, styles and JSON left stored are deflated, which is where compression does pay off.
The content itself — text, questions, settings, the media's picture and sound — is not changed. The result is usually a few percent larger than the original, because media that was compressed for a 1% saving is now stored. One package measured here went from 38.1 to 38.8 MB.
Audio too
Everything above applies to audio. An mp3 or m4a deflated into a package has to be pulled from its start just like a video, and an m4a is an mp4 container with the same index problem. The normalizer stores audio the same way and moves an m4a's index like a video's.
What normalizing cannot do
-
On a host that ignores
Range, the archive still has to download in full. A normalized package boots earlier there and its video can seek, but a video still waits for its own bytes to arrive. -
It needs someone who can re-publish the package. For a package you can only link to,
preload="auto"is the one lever left.
The details — what was measured, and why each rule is the way it is — are in the repository's working notes, under the normalizer and the invariants about deflated video.