Adaptive bitrate video inside a SCORM package — no CDN
Period: 2024 – present Stack: JavaScript, FFmpeg, SCORM 1.2 Tested against: self-hosted Moodle on Oracle Cloud Free Tier (Ubuntu ARM)
The problem
K-12 SCORM modules ship to schools across India, including bandwidth-constrained ones. A 1080p H.264 file inside a SCORM package buffers indefinitely on those networks or fails to load.
The naive fix — compress to a single low-resolution master — solves the buffering and ruins the experience for schools on good connections.
The proper fix is adaptive bitrate. Every video site does this with HLS or DASH. But HLS inside a SCORM package, served from someone else's LMS, without a CDN, is not a path the documentation covers.
Constraints
- No LMS configuration. I'm not convincing every school's IT to tweak Apache or nginx for our package.
- No external CDN. Internet from the LMS to a CDN is often blocked or unreliable in target environments. Everything ships in the zip.
- One source video per slide. Authors don't manage variants.
- Storyline-compatible. Still has to publish from Storyline and play in any SCORM 1.2 LMS.
The build
A pre-publish step that runs after Storyline export, before SCORM packaging:
- Transcode pass. For every
.mp4in the package, FFmpeg produces three variants: 320p (~250 kbps), 480p (~600 kbps), 720p (~1.5 Mbps). - Manifest. A small
bitrate-map.jsonlives next to the variants, listing source → variant mapping. - Bandwidth probe. A script injected into the SCORM player measures effective bandwidth via a small pilot fetch at course launch. Picks a tier.
- Source rewrite. At video-element creation time, the script substitutes the
<video>srcwith the tier-appropriate variant. Storyline thinks it's playing the file the author specified.
The decision itself is one line: tier = bw < 400 ? "320p" : bw < 1200 ? "480p" : "720p".
Why this works
- The LMS serves the files; the package picks which one. Decision moves from infrastructure to runtime.
- No HLS, no DASH, no MSE. Each variant is a standalone progressive-download MP4. Native playback in every browser. No video-player library.
- Probe fires before any video element. By the time the first video slide loads, the tier is already chosen. No mid-stream switching, but no buffering either.
- Cost: roughly 3× the package size. For video-heavy modules, a zip goes from 80MB to 240MB. For most of our content that's acceptable.
What didn't work
- MSE-based adaptive playback. Mid-stream switching with MediaSource Extensions would have been more elegant. Inside Storyline's video container it required rewriting Storyline's own player wrapper. Not worth it.
- A single master plus client-side downscaling. Browsers don't downscale on decode in any standard way. Aborted.
- An HLS variant. Worked beautifully in isolation. Failed on three of our target LMSs that served
.m3u8with the wrong MIME type and wouldn't let us change it.
What I learned
The "right" solution and the "right for this context" solution are different problems. Pre-encoded variants plus one bandwidth check at launch covered most of the value at a small fraction of the engineering cost. The constraints — no CDN, no LMS config — forced a simpler design than I'd have arrived at otherwise.
Roadmap
- Open-source
scorm-adapt— a clean-room equivalent of the Kidvento-internal packager, runnable against any SCORM 1.2 zip. - Optional WebM/AV1 variants for browsers that support them.
- Range-request-based probing — drop the dedicated probe file.