h5p-offline-player

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.

Shaded: what has to cross the network before the video can play at the marker.
How the video is packedFirst frameSeekingBytes 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

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:

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

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.