TL;DR
Your bitrate ladder is a price list, and ABR buys the most expensive rung it can. We'll write a ~60-line Node script that fetches any HLS master playlist and prints what each rendition costs per viewer-hour, then look at two fixes: reshaping the ladder with FFmpeg, and moving delivery to per-minute pricing where bitrate drift can't inflate the bill.
Delivery bills usually grow for a boring reason: the average delivered bitrate crept up and nobody noticed, because nobody prices the ladder. Let's price the ladder.
Tools for this build: Node 22 (any Node 18+ works, we only need global fetch), FFmpeg 7.0+, and any HLS stream you can reach, including your own.
1. Grab a master playlist 🎯
Every HLS stream starts from a master playlist that declares each rendition and its BANDWIDTH in bits per second. That attribute is the whole audit surface:
curl -s https://your-cdn.example.com/v1/master.m3u8 | head -20
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=6221600,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p_high/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4521600,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3021600,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1521600,RESOLUTION=854x480,CODECS="avc1.64001e,mp4a.40.2"
480p/index.m3u8
BANDWIDTH is the peak declared rate, so this audit is a worst-case price. That's the right case to price, because on good networks ABR sits on your top rung all day.
2. The audit script 🛠️
// ladder-audit.js
// Usage: node ladder-audit.js <master.m3u8 URL> <cost per GB in USD>
const [, , url, costPerGBArg] = process.argv;
const COST_PER_GB = Number(costPerGBArg ?? 0.085); // your CDN's blended $/GB
const res = await fetch(url);
if (!res.ok) {
console.error(`Fetch failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const manifest = await res.text();
const rungs = [];
const lines = manifest.split("\n");
for (let i = 0; i < lines.length; i++) {
if (!lines[i].startsWith("#EXT-X-STREAM-INF:")) continue;
const attrs = lines[i].slice("#EXT-X-STREAM-INF:".length);
const bandwidth = Number(/BANDWIDTH=(\d+)/.exec(attrs)?.[1] ?? 0);
const resolution = /RESOLUTION=([\dx]+)/.exec(attrs)?.[1] ?? "audio-only";
rungs.push({ bandwidth, resolution, uri: lines[i + 1]?.trim() });
}
console.log(`\nLadder audit for ${url}`);
console.log(`Assumed delivery cost: $${COST_PER_GB}/GB\n`);
console.table(
rungs
.sort((a, b) => b.bandwidth - a.bandwidth)
.map((r) => {
const gbPerHour = (r.bandwidth * 3600) / 8 / 1e9;
return {
resolution: r.resolution,
mbps: (r.bandwidth / 1e6).toFixed(2),
"GB/viewer-hour": gbPerHour.toFixed(2),
"$/viewer-hour": (gbPerHour * COST_PER_GB).toFixed(3),
};
})
);
Run it against your stream with the per-GB rate from your last invoice:
node ladder-audit.js https://your-cdn.example.com/v1/master.m3u8 0.085
Ladder audit for https://your-cdn.example.com/v1/master.m3u8
Assumed delivery cost: $0.085/GB
┌─────────┬────────────┬────────┬────────────────┬────────────────┐
│ (index) │ resolution │ mbps │ GB/viewer-hour │ $/viewer-hour │
├─────────┼────────────┼────────┼────────────────┼────────────────┤
│ 0 │ '1920x1080'│ '6.22' │ '2.80' │ '0.238' │
│ 1 │ '1920x1080'│ '4.52' │ '2.03' │ '0.173' │
│ 2 │ '1280x720' │ '3.02' │ '1.36' │ '0.116' │
│ 3 │ '854x480' │ '1.52' │ '0.68' │ '0.058' │
└─────────┴────────────┴────────┴────────────────┴────────────────┘
💡 Tip: to turn this into a blended number, weight each row by the share of watch time it serves. Most player analytics (hls.js
LEVEL_SWITCHEDevents, or your QoE tool's rendition report) can give you that distribution.
3. Read the output like a bill 💸
Two questions to ask of the table:
- Why does the top rung exist? If 6.2 Mbps and 4.5 Mbps 1080p rungs look identical for your content, the top one costs you 37 percent more per viewer-hour for nothing. Someone should be able to defend every rung in one sentence.
- Is one ladder serving very different content? A screencast and a sports clip through the same ladder means one of them is overpaying. Fixed ladders price for your most complex content and bill you for it on your simplest.
4. Fix A: reshape the ladder with FFmpeg
The DIY fix is per-title thinking: cap each rung by quality, not just bitrate. CRF encoding with a maxrate ceiling gets you most of the way without a full VMAF pipeline:
# 1080p rung: quality-targeted, bitrate-capped
ffmpeg -i input.mp4 \
-c:v libx264 -crf 21 -preset slow \
-maxrate 4500k -bufsize 9000k \
-vf scale=1920:1080 \
-c:a aac -b:a 128k \
1080p.mp4
Simple content lands well under the cap and you pocket the difference; complex content hits maxrate and keeps its headroom. The honest cost: doing this properly across a catalog means trial encodes, quality scoring, a queue, and monitoring. It's a pipeline, not a flag. Budget maintenance time for it like any other service you run.
5. Fix B: change the pricing model
The other fix is structural. Managed platforms mostly bill delivery per minute watched, not per GB, which makes bitrate drift financially impossible: the meter is watch time. Cloudflare Stream is $1 per 1,000 minutes delivered at any resolution. FastPix lists about $0.00096 per minute at 1080p, roughly 5.8 cents per viewer-hour, and does context-aware encoding by default, so the per-title work from Fix A is the encoder's job instead of yours. Encoding itself is free on its standard plan. Mux and api.video follow the same per-minute delivery pattern, so this section's flow works across all of them.
Getting a video into a context-aware pipeline is one call:
# Create a media from a URL (Basic auth: Access Token ID / Secret Key)
curl -X POST https://api.fastpix.com/v1/on-demand \
-u "$FASTPIX_TOKEN_ID:$FASTPIX_SECRET" \
-H "Content-Type: application/json" \
-d '{
"inputs": [{ "type": "video", "url": "https://static.fastpix.com/fp-sample-video.mp4" }],
"accessPolicy": "public"
}'
{
"success": true,
"data": {
"id": "0a8e6d7f-...",
"status": "Created",
"playbackIds": [{ "id": "9f2b1c4d-...", "accessPolicy": "public" }]
}
}
Wait for the video.media.ready webhook, then play https://stream.fastpix.com/<playbackId>.m3u8 and point the audit script from step 2 at the master playlist it serves. Comparing the rendition bitrates you get against the fixed ladder you had is the whole evaluation, on your own content, in an afternoon. The VOD API reference covers the create-media options if you want to pin maxResolution or quality.
For calibration: in one published benchmark on a 177 MB file over 4G, FastPix's context-aware output averaged around 700 Kbps where fixed-ladder platforms delivered 1.8 to 2.5 Mbps, with zero rebuffering across all platforms tested; the same report scored Mux best on viewer experience thanks to faster cold startup. Run your own numbers; that's what the script is for.
⚠️ Note: FastPix is API-first. There's no drag-and-drop CMS included, so a non-developer content team will need a front-end built on the API.
What's next
- Wire the watch-time distribution into the script and compute a real blended $/viewer-hour, then put it on a dashboard next to watch time. It should have an owner.
- If you go the DIY route, the next problem is quality scoring at scale: VMAF in your encode CI, per-title thresholds, and a regression suite for encoder upgrades.
The number the script prints is rarely the number people expect. That surprise is the point.
Top comments (0)