Generating DASH and HLS with Shaka Packager
Encode every rung to a plain MP4 with aligned keyframes, then run packager once with one in=…,stream=… descriptor per track plus --mpd_output and --hls_master_playlist_output: it writes one set of CMAF segments that both a DASH MPD and an HLS master playlist reference.
Doing encode and package in one FFmpeg command is convenient, but it welds two jobs with very different failure modes together. Encoding is slow, CPU-bound and worth retrying per rung; packaging is fast, I/O-bound and needs every rung to finish first. Separating them lets you parallelise encodes across workers, re-package without re-encoding when you add encryption or change segment duration, and publish DASH and HLS without storing the video twice. This page is part of adaptive bitrate video streaming in media processing and delivery pipelines; the one-process alternative is packaging HLS with FFmpeg and fMP4 segments.
When to use this approach
- You need both DASH (for Android, smart TVs, Chromecast, or a DRM stack built on Widevine) and HLS (for Safari and iOS), and you do not want two copies of every segment.
- Encodes run in parallel on separate workers — one message per rung through a queue, as in orchestrating transcode steps with AWS Step Functions — and something has to stitch the outputs together.
- You expect to re-package later: adding CENC encryption, switching from 4 s to 6 s segments, or adding a subtitle track should not cost a re-encode.
Prerequisites
- Shaka Packager 3.x (
packager --versionprintspackager version v3.x). The static binaries on the project’s releases page run on any glibc Linux; thegoogle/shaka-packagercontainer image works too. - FFmpeg 6.x with
libx264for the per-rung encodes. - Node 20+ to drive both from TypeScript.
- A ladder — the per-upload approach in designing an encoding ladder for user-uploaded video produces exactly the
Rung[]shape used below.
The two-stage pipeline
Implementation
Stage one encodes a single rung. Run it once per rung, in parallel, anywhere. Stage two runs after every encode has reported success.
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
export interface Rung { name: string; width: number; height: number; videoKbps: number; maxKbps: number }
const SEGMENT_SECONDS = 4;
function run(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
let tail = "";
p.stderr.on("data", (d: Buffer) => { tail = (tail + d.toString()).slice(-3000); });
p.on("error", reject);
p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}\n${tail}`))));
});
}
/** Stage 1a: one video-only rung. Keyframes forced on the segment grid so rungs align. */
export async function encodeVideoRung(source: string, outDir: string, r: Rung): Promise<string> {
const out = join(outDir, `v_${r.name}.mp4`);
await run("ffmpeg", [
"-hide_banner", "-y", "-i", source,
"-map", "0:v:0", "-an",
"-vf", `scale=${r.width}:${r.height}:flags=lanczos,format=yuv420p`,
"-c:v", "libx264", "-profile:v", "high", "-preset", "medium",
"-b:v", `${r.videoKbps}k`, "-maxrate", `${r.maxKbps}k`, "-bufsize", `${r.maxKbps * 2}k`,
"-force_key_frames", `expr:gte(t,n_forced*${SEGMENT_SECONDS})`, "-sc_threshold", "0",
"-movflags", "+faststart",
out,
]);
return out;
}
/** Stage 1b: one audio-only track, shared by every video rung. */
export async function encodeAudio(source: string, outDir: string): Promise<string | null> {
const out = join(outDir, "a_128k.mp4");
try {
await run("ffmpeg", ["-hide_banner", "-y", "-i", source, "-map", "0:a:0", "-vn",
"-c:a", "aac", "-b:a", "128k", "-ac", "2", out]);
return out;
} catch (err) {
// A silent source has no 0:a:0; package video-only instead of failing the whole job.
if (String(err).includes("matches no streams")) return null;
throw err;
}
}
/** Stage 2: one packager run → CMAF segments + DASH MPD + HLS master. */
export async function packageBoth(
videos: { file: string; rung: Rung }[],
audio: string | null,
outDir: string,
): Promise<{ mpd: string; m3u8: string }> {
await mkdir(outDir, { recursive: true });
const descriptors: string[] = [];
for (const { file, rung } of videos) {
const dir = join(outDir, `video_${rung.name}`);
await mkdir(dir, { recursive: true });
descriptors.push([
`in=${file}`, "stream=video",
`init_segment=${join(dir, "init.mp4")}`,
`segment_template=${join(dir, "$Number%05d$.m4s")}`,
`playlist_name=video_${rung.name}.m3u8`,
].join(","));
}
if (audio) {
const dir = join(outDir, "audio");
await mkdir(dir, { recursive: true });
descriptors.push([
`in=${audio}`, "stream=audio",
`init_segment=${join(dir, "init.mp4")}`,
`segment_template=${join(dir, "$Number%05d$.m4s")}`,
"playlist_name=audio.m3u8", "hls_group_id=audio", "hls_name=Main", "language=und",
].join(","));
}
const mpd = join(outDir, "manifest.mpd");
const m3u8 = join(outDir, "master.m3u8");
await run("packager", [
...descriptors,
"--segment_duration", String(SEGMENT_SECONDS),
"--generate_static_live_mpd=false",
"--mpd_output", mpd,
"--hls_master_playlist_output", m3u8,
"--hls_playlist_type", "VOD",
]);
return { mpd, m3u8 };
}
// Usage: node --experimental-strip-types shaka.ts in.mov ./work ./out
const [, , src, work, out] = process.argv;
if (src && work && out) {
await mkdir(work, { recursive: true });
const ladder: Rung[] = [
{ name: "1080", width: 1920, height: 1080, videoKbps: 5000, maxKbps: 5350 },
{ name: "720", width: 1280, height: 720, videoKbps: 2800, maxKbps: 3000 },
{ name: "360", width: 640, height: 360, videoKbps: 800, maxKbps: 856 },
];
const [audio, ...files] = await Promise.all([
encodeAudio(src, work),
...ladder.map((r) => encodeVideoRung(src, work, r)),
]);
const videos = files.map((file, i) => ({ file: file as string, rung: ladder[i] }));
console.log(await packageBoth(videos, audio as string | null, out));
}
Line-by-line on the parameters that matter
- Video and audio in separate files. Shaka treats every
stream=descriptor as one track. Muxing audio into each video rung would give the packager four identical audio tracks to dedupe, and DASH would list them as four adaptation sets. One audio file becomes one audio adaptation set and oneEXT-X-MEDIAgroup. -force_key_framesin every rung encode. The packager does not re-encode, so it cannot fix misaligned keyframes; it cuts segments at the keyframes it finds. If rung encodes ran on different workers with scene-cut detection on, their segments will not line up, and Shaka will warnSegment duration mismatchrather than fail.segment_templatewith$Number%05d$. The same template syntax as DASHSegmentTemplate. Shaka writes the DASH manifest with aSegmentTemplatepointing at these names and the HLS playlists with explicit URIs, so both formats resolve to the same files on disk.hls_group_id=audiomakes the audio a rendition group that each video variant references withAUDIO="audio". Leave it off and HLS clients get video-only variants plus an orphaned audio playlist.--generate_static_live_mpd=falsekeeps the MPDtype="static"without the live-profile attributes. For VOD built from uploads, you want the plain static profile every DASH player accepts.--hls_playlist_type VODwrites#EXT-X-PLAYLIST-TYPE:VODand#EXT-X-ENDLIST, which lets players show a seek bar immediately.
What the two manifests say about the same bytes
The MPD and the master playlist describe the same segments in different vocabularies. Seeing them side by side is the quickest way to debug a player that works for one format and not the other.
Configuration gotchas
Unable to find stream: video in in=…,stream=video. The input file has no video track — usually because an encode wrote audio into the video file or failed silently and left a zero-byte output. Check every stage-one output with ffprobe -v error -show_entries stream=codec_type before starting the packager, and fail the job at that point rather than inside it.
Segment duration mismatch warnings, then stuttering on bitrate switch. Different rungs have keyframes in different places. Every rung must use the same -force_key_frames expression and -sc_threshold 0. The packager’s --segment_duration is a target it rounds to the nearest keyframe; it cannot create alignment that the encodes do not have.
HLS plays in Safari but the MPD fails in dash.js with MEDIA_ERR_SRC_NOT_SUPPORTED. The MPD uses relative paths computed from the --mpd_output location. If you upload manifest.mpd to a different prefix from the segment directories, the relative media= paths no longer resolve. Keep the tree exactly as written, or pass --base_urls to make them absolute.
Absolute local paths in the playlists. Passing absolute segment_template paths makes the packager write paths relative to the manifest only when both live under the same directory. Run the packager with cwd set to the output directory and use relative templates if your work directory is elsewhere.
Re-packaging without re-encoding
The payoff of the split appears the first time the delivery requirements change. Adding encryption, for example, only re-runs stage two:
That argues for storing the per-rung MP4s, not just the packaged output. They are roughly the same size as one copy of the segments, and a lifecycle rule can move them to an infrequent-access class after thirty days — see transitioning media to cheaper storage classes.
Verification
# 1. Both manifests exist and reference the same segment directories.
grep -o 'video_[0-9]*/' out/manifest.mpd | sort -u
grep -o 'video_[0-9]*\.m3u8' out/master.m3u8 | sort -u
# 2. The MPD is static and declares one video and one audio adaptation set.
grep -o 'type="static"' out/manifest.mpd
grep -c '<AdaptationSet' out/manifest.mpd # expect 2
# 3. The HLS master references the audio group from every variant.
grep -c 'AUDIO="audio"' out/master.m3u8 # expect one per video rung
# 4. Segment counts are equal across video rungs (alignment check).
for d in out/video_*/; do printf "%s %s\n" "$d" "$(ls "$d"*.m4s | wc -l)"; done
Finally, serve out/ over HTTP with permissive CORS and load manifest.mpd in the Shaka Player demo page and master.m3u8 in Safari. Both should play, and switching quality manually in either should be seamless at segment boundaries.
Frequently Asked Questions
Is Shaka Packager better than Bento4 or MP4Box for this?
All three produce valid CMAF. Shaka’s advantages are one binary for DASH and HLS, first-class Widevine and PlayReady key-server integration, and a descriptor syntax that is easy to generate from code. Bento4’s mp4dash and GPAC’s MP4Box are equally capable; pick whichever your team already knows, because the encode-once, package-once split is what matters.
Can I add DRM later without re-encoding?
Yes, and it is the strongest argument for the split. Re-run stage two with --enable_raw_key_encryption (or a key server) and --protection_scheme cbcs, which both FairPlay-era Apple devices and Widevine accept. The pixels do not change, so the mezzanine MP4s are reused as-is.
Why not let the packager do the scaling too?
It cannot — Shaka Packager never decodes or encodes, it only rewrites containers. That is a feature: it runs in seconds, and the expensive, failure-prone work stays in stage one where it can be retried per rung.