<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mason K</title>
    <description>The latest articles on DEV Community by Mason K (@masonwritescode).</description>
    <link>https://dev.to/masonwritescode</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3154097%2F0e3d2367-7ed3-4a0b-8975-4226af0f35a3.png</url>
      <title>DEV Community: Mason K</title>
      <link>https://dev.to/masonwritescode</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/masonwritescode"/>
    <language>en</language>
    <item>
      <title>Build a video watch-time heatmap: interval tracker, beacon endpoint, canvas render</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:17:47 +0000</pubDate>
      <link>https://dev.to/masonwritescode/build-a-video-watch-time-heatmap-interval-tracker-beacon-endpoint-canvas-render-2l1k</link>
      <guid>https://dev.to/masonwritescode/build-a-video-watch-time-heatmap-interval-tracker-beacon-endpoint-canvas-render-2l1k</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F928zciz9bur8ex16efha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F928zciz9bur8ex16efha.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We're building the "which parts of this video do people actually watch" chart from scratch: a client-side interval tracker (~80 lines, zero dependencies), a beacon endpoint with SQLite per-second counters, and a canvas renderer. No analytics vendor, no SDK, data stays yours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Completion rate tells you &lt;em&gt;how much&lt;/em&gt; of a video people watched. The heatmap tells you &lt;em&gt;where&lt;/em&gt;: the intro everyone skips, the section a quarter of viewers rewatch, the cliff where they leave. Hosted platforms sell this chart as a premium analytics feature, but the mechanics are simple: watched intervals in, per-second counters out.&lt;/p&gt;

&lt;p&gt;The one rule that makes it work: &lt;strong&gt;track segments of media time, not events&lt;/strong&gt;. Events lie the moment someone seeks or rewatches. Intervals don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The interval tracker 🎯
&lt;/h2&gt;

&lt;p&gt;A viewing session is a list of spans: &lt;code&gt;{start, end}&lt;/code&gt; in media time. We open a span when playback advances and close it on anything that breaks continuity (pause, seek, ended, tab hidden). A rewatch just produces overlapping spans, which is exactly the signal we want.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/heatmap-tracker.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WatchTracker&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;close&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeSpan&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pause&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;seeking&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// close at departure point&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ended&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeSpan&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pagehide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeSpan&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seeking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// guard: timeupdate cadence is load-dependent (spec: ~4Hz to 66Hz).&lt;/span&gt;
    &lt;span class="c1"&gt;// A jump far beyond one tick means we missed a seek; close defensively.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeSpan&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;closeSpan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c1"&gt;// ignore sub-500ms noise&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// survives tab close&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it to any &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; (works the same under hls.js or video.js, since they drive a media element):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/app.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WatchTracker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./heatmap-tracker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WatchTracker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onboarding-demo-v3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/collect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: &lt;code&gt;sendBeacon&lt;/code&gt; is the whole reason this data survives. It's a fire-and-forget POST the browser completes even as the page unloads. &lt;code&gt;fetch(..., { keepalive: true })&lt;/code&gt; works too if you need headers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. The collect endpoint + per-second counters 🗄️
&lt;/h2&gt;

&lt;p&gt;Server side, we slice each video into one-second bins and increment every bin a span covers. SQLite is plenty; one row per &lt;code&gt;(video, second)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js  (node 20.x, better-sqlite3 ^11)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Database&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;better-sqlite3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heatmap.db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`CREATE TABLE IF NOT EXISTS bins (
  video_id TEXT NOT NULL,
  second   INTEGER NOT NULL,
  views    INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (video_id, second)
)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bump&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  INSERT INTO bins (video_id, second, views) VALUES (?, ?, 1)
  ON CONFLICT(video_id, second) DO UPDATE SET views = views + 1
`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;       &lt;span class="c1"&gt;// beacons arrive as text&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/collect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spans&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;insertAll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;insertAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/heatmap/:videoId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT second, views FROM bins WHERE video_id = ? ORDER BY second&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;collector on :3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sanity-check it before touching the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:3000/collect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"videoId":"demo","sessionId":"t1","spans":[{"start":0,"end":12.4},{"start":8,"end":12.4}]}'&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/heatmap/demo | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'.[0:4]'&lt;/span&gt;
&lt;span class="c"&gt;# [{"second":0,"views":1},{"second":1,"views":1},...]&lt;/span&gt;
&lt;span class="c"&gt;# seconds 8-12 should show views=2 (the rewatch overlap)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: don't store PII. A random &lt;code&gt;sessionId&lt;/code&gt; per playback is enough for de-duping later, and the aggregate table contains no user data at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Draw it 🎨
&lt;/h2&gt;

&lt;p&gt;One canvas, one bar per second, color by intensity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/render.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;drawHeatmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/heatmap/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// 0..1&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`hsl(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;heat&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;190&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 85%, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;heat&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;heat&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// floor so gaps stay visible&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;second&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop it under the player and you'll immediately see the three signatures every heatmap shows: the intro cliff (cold bars after second ~10), the slow bleed, and (on tutorial content) hot rewatch spikes. A spike is ambiguous by nature: it's either your best moment or your most confusing one, and only watching that section tells you which.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Production notes 📋
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bin size:&lt;/strong&gt; per-second is fine up to feature length. For hour-plus content, 2 to 5 second bins keep the table and the chart sane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cohorts:&lt;/strong&gt; add a &lt;code&gt;source&lt;/code&gt; column (email, landing page, organic) and keep separate counters. Blended cohorts produce a heatmap of nobody.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playback rate:&lt;/strong&gt; spans are in media time, so 2x watchers are counted correctly by construction. That's the quiet advantage of intervals over "ping every N wall-clock seconds".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total plays vs unique viewers:&lt;/strong&gt; the &lt;code&gt;views&lt;/code&gt; counter counts coverage, so one viewer looping a section three times adds three. That's the right default for a "most replayed" signal. If you also want "how many distinct sessions reached this second", keep a second counter and increment it at most once per &lt;code&gt;sessionId&lt;/code&gt; per bin (dedupe the bins per beacon batch before writing).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volume:&lt;/strong&gt; one row write per viewed second per session. For most product videos that's nothing; if you're at real scale, buffer beacons into a queue and batch the upserts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live streams:&lt;/strong&gt; this design is VOD-shaped. For live you'd bin by stream clock instead; different article.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next 🚀
&lt;/h2&gt;

&lt;p&gt;Two upgrades fall out of owning this data. First, "most replayed": you already have the array, so rendering the peak like the big platforms do is pure UI. Second, feed the peaks into your pipeline: auto-pick thumbnails from the hottest second, or place chapter markers at attention spikes. And if you want the player-side signals to go deeper (startup time, rebuffering, bitrate switches), that's the QoE half of analytics; the interval model here composes cleanly with those event streams too.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>tutorial</category>
      <category>video</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turn on AV1 film grain synthesis and measure what it saves on your own footage</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:15:51 +0000</pubDate>
      <link>https://dev.to/masonwritescode/turn-on-av1-film-grain-synthesis-and-measure-what-it-saves-on-your-own-footage-37bb</link>
      <guid>https://dev.to/masonwritescode/turn-on-av1-film-grain-synthesis-and-measure-what-it-saves-on-your-own-footage-37bb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9x3awl3mdm1m6piar1da.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9x3awl3mdm1m6piar1da.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Film grain is random, codecs can't predict randomness, so grainy sources cost a fortune in bitrate. AV1 can strip the grain, encode the clean image, and have the decoder synthesize matching grain at playback. We'll enable it with one FFmpeg parameter, build a small compare script, and check the results the only way that's valid: with your eyes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Grain and sensor noise change every frame, everywhere, which means motion prediction fails on every block. The encoder either burns bits reproducing noise or smears it into that waxy denoised look. AV1's film grain synthesis (FGS) offers a third path: the grain is modeled at encode time, the parameters ride along in the bitstream (a few bytes), and the decoder repaints statistically matched grain as a post-processing step. dav1d, the decoder in your browser, does this in production today.&lt;/p&gt;

&lt;p&gt;Let's actually run it instead of reading about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Check your toolchain 🛠️
&lt;/h2&gt;

&lt;p&gt;You need FFmpeg built with &lt;code&gt;libsvtav1&lt;/code&gt;. FFmpeg 5.1+ passes SVT-AV1 parameters through; anything current (FFmpeg 7.x or the 8.x line, which is on 8.1 as of March 2026) is fine, ideally with a recent SVT-AV1 (4.x shipped early this year):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="c"&gt;# ffmpeg version 8.1 ...&lt;/span&gt;

ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-encoders&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;svtav1
&lt;span class="c"&gt;#  V....D libsvtav1            SVT-AV1(Scalable Video Technology for AV1) encoder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;libsvtav1&lt;/code&gt; line? Grab a static build or &lt;code&gt;brew install ffmpeg&lt;/code&gt; / your distro's current package.&lt;/p&gt;

&lt;p&gt;Pick a genuinely grainy test clip: a film scan, low-light footage, a concert recording. FGS on clean screen-capture content will show you nothing, because there's no grain to model. Cut a 60 second sample so iterations are fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-ss&lt;/span&gt; 00:05:00 &lt;span class="nt"&gt;-i&lt;/span&gt; source-master.mov &lt;span class="nt"&gt;-t&lt;/span&gt; 60 &lt;span class="nt"&gt;-c&lt;/span&gt; copy sample.mov
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Encode the baseline 📼
&lt;/h2&gt;

&lt;p&gt;First, a normal SVT-AV1 encode, no grain handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; sample.mov &lt;span class="nt"&gt;-c&lt;/span&gt;:v libsvtav1 &lt;span class="nt"&gt;-preset&lt;/span&gt; 5 &lt;span class="nt"&gt;-crf&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p10le &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-svtav1-params&lt;/span&gt; &lt;span class="nv"&gt;tune&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; baseline.mkv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on the flags: &lt;code&gt;preset 5&lt;/code&gt; is a reasonable quality/speed midpoint, &lt;code&gt;crf 32&lt;/code&gt; is a sane starting quality target for 1080p, &lt;code&gt;tune=0&lt;/code&gt; selects the perceptual tuning, and 10-bit (&lt;code&gt;yuv420p10le&lt;/code&gt;) is standard practice for AV1 even with 8-bit sources since it reduces banding.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Encode with film grain synthesis 🎞️
&lt;/h2&gt;

&lt;p&gt;One parameter changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; sample.mov &lt;span class="nt"&gt;-c&lt;/span&gt;:v libsvtav1 &lt;span class="nt"&gt;-preset&lt;/span&gt; 5 &lt;span class="nt"&gt;-crf&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p10le &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-svtav1-params&lt;/span&gt; &lt;span class="nv"&gt;tune&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0:film-grain&lt;span class="o"&gt;=&lt;/span&gt;8:film-grain-denoise&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; grain-synth.mkv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the two new knobs do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;film-grain=8&lt;/code&gt; sets the grain modeling level (0 to 50). Community guidance that holds up in practice: around 8 for normally grainy live action, around 4 for animation or clean digital sources. Crank it only for genuinely noisy scans.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;film-grain-denoise=1&lt;/code&gt; tells the encoder to actually remove the modeled grain from the frames before encoding them. This is where large savings come from on noisy sources, because the encoder now sees a clean, predictable image. With it off, you're layering synthetic grain over grain that's still (expensively) in the encode.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: FGS is off by default in SVT-AV1. If you didn't ask for it, you don't have it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the encode dies immediately with something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Svt[error]: Error parsing svtav1-params: unrecognized option film-grain-denoise&lt;/span&gt;
&lt;span class="c"&gt;# Error setting option svtav1-params to value ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;your &lt;code&gt;libsvtav1&lt;/code&gt; predates the option spelling your build expects, or your FFmpeg predates 5.1's &lt;code&gt;svtav1-params&lt;/code&gt; passthrough entirely. Update the FFmpeg build rather than fighting it; current static builds bundle a recent SVT-AV1.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Compare the numbers 📊
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# compare.sh&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;baseline.mkv grain-synth.mkv&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-select_streams&lt;/span&gt; v:0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;size,bit_rate,duration &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;noprint_wrappers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s2"&gt;"s/^/&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;  /"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./compare.sh
&lt;span class="c"&gt;# baseline.mkv     duration=60.000000&lt;/span&gt;
&lt;span class="c"&gt;# baseline.mkv     size=&amp;lt;yours here&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;# baseline.mkv     bit_rate=&amp;lt;yours here&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;# grain-synth.mkv  duration=60.000000&lt;/span&gt;
&lt;span class="c"&gt;# grain-synth.mkv  size=&amp;lt;yours here&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;# grain-synth.mkv  bit_rate=&amp;lt;yours here&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm deliberately not printing my numbers, and I'd side-eye any article that quotes one as if it were general. The delta depends entirely on how much of your bitrate was grain. Noisy film scans can shrink dramatically; lightly grainy content shifts modestly; clean content barely moves. Run it on your library sample, not mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify with your eyes, not a metric 👀
&lt;/h2&gt;

&lt;p&gt;Here's the part most write-ups skip: full-reference quality metrics are structurally unreliable on FGS output. The synthesized grain is intentionally not the same pixels as the source grain, so pixel-comparing metrics can punish a frame that looks right and reward a smeared one compared against a denoised reference. The community docs are blunt that heavy grain remains an AV1 weak point even with FGS. So we A-B it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# play both in sync-ish loops, switch between windows&lt;/span&gt;
mpv &lt;span class="nt"&gt;--loop&lt;/span&gt; baseline.mkv &amp;amp;
mpv &lt;span class="nt"&gt;--loop&lt;/span&gt; grain-synth.mkv &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for the three classic FGS failure smells:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Uniformity.&lt;/strong&gt; Real grain varies with brightness; synthetic grain can look same-everywhere, especially in shadows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twinkle.&lt;/strong&gt; Grain that reads as animated static rather than texture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plastic mid-tones.&lt;/strong&gt; If &lt;code&gt;film-grain-denoise&lt;/code&gt; was too aggressive for the source, faces go waxy underneath the synthetic layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If level 8 shows artifacts, step down to 6 or 4 and re-run; the sweep takes minutes on a 60 second sample. For archival or authorial grain (a director chose that film stock), keep a human review in the loop before committing a whole catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Ladder notes 🪜
&lt;/h2&gt;

&lt;p&gt;Two practical integration details before you wire this into a pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apply FGS on the renditions where grain survives anyway. Your 240p rung has no grain worth preserving after downscaling; spend the analysis on the top rungs.&lt;/li&gt;
&lt;li&gt;Decoder-side cost is real but small on modern devices (dav1d handles synthesis efficiently). If your audience includes very old or very cheap hardware, put one such device on the test matrix before rollout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Automate the sweep 🔁
&lt;/h2&gt;

&lt;p&gt;Once the two-encode version works, the per-title version is a loop. This emits a size table so the grain level becomes a data-driven, per-content decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# sweep.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;g &lt;span class="k"&gt;in &lt;/span&gt;0 4 8 12&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-i&lt;/span&gt; sample.mov &lt;span class="nt"&gt;-c&lt;/span&gt;:v libsvtav1 &lt;span class="nt"&gt;-preset&lt;/span&gt; 5 &lt;span class="nt"&gt;-crf&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p10le &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-svtav1-params&lt;/span&gt; &lt;span class="s2"&gt;"tune=0:film-grain=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;g&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:film-grain-denoise=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="s2"&gt;"out-fg&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;g&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mkv"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"film-grain=%-3s %s bytes&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$g&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; out-fg&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;g&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.mkv&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it overnight against one representative sample per title category (film scan, talking head, animation, screen capture) and you'll have an encoding policy instead of a default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next 🚀
&lt;/h2&gt;

&lt;p&gt;Two directions from here. First, read the SVT-AV1 film grain appendix (in the repo's &lt;code&gt;Docs/&lt;/code&gt; folder) to understand the underlying noise model; it's short and it explains why the synthesis behaves the way it does. Second, if you're already doing per-title encoding, FGS slots naturally into the same "let the content decide" philosophy, and the sweep script above is the seed of exactly that pipeline.&lt;/p&gt;

</description>
      <category>ffmpeg</category>
      <category>video</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Ship your first HLS interstitial: one DATERANGE tag, an asset list, and hls.js 1.6</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:12:56 +0000</pubDate>
      <link>https://dev.to/masonwritescode/ship-your-first-hls-interstitial-one-daterange-tag-an-asset-list-and-hlsjs-16-53h0</link>
      <guid>https://dev.to/masonwritescode/ship-your-first-hls-interstitial-one-daterange-tag-an-asset-list-and-hlsjs-16-53h0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9uy74stk703xdymsgtc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9uy74stk703xdymsgtc.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We're going to insert a bumper into an existing VOD stream without touching a single media segment. One &lt;code&gt;EXT-X-DATERANGE&lt;/code&gt; tag in the playlist, one JSON endpoint for the asset list, and hls.js 1.6+ handles scheduling, playback, and resume. You'll also get the event wiring for a real "Ad playing" UI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The old way to put an ad or slate inside an HLS stream was to splice its segments into the media playlist behind an &lt;code&gt;EXT-X-DISCONTINUITY&lt;/code&gt; tag. It works, but your playlist stops being cacheable, your timeline math gets weird, and every player handles the seam slightly differently.&lt;/p&gt;

&lt;p&gt;HLS interstitials flip the model: the primary playlist stays untouched, and a date-range tag tells the player "at this point, go play this other thing, then come back." Apple players have supported this natively for a while. On the open web it became practical when hls.js shipped interstitials support in v1.6.0. Let's wire it up end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What you need 🛠️
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Any working VOD HLS stream (a &lt;code&gt;.m3u8&lt;/code&gt; you control)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hls.js&lt;/code&gt; 1.6.0 or newer (we'll use the latest 1.6.x)&lt;/li&gt;
&lt;li&gt;Node 20.x for the tiny asset-list server&lt;/li&gt;
&lt;li&gt;A short bumper clip, already packaged as HLS (5 to 15 seconds is perfect)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check your hls.js version first; interstitials do nothing on 1.5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;ls &lt;/span&gt;hls.js
&lt;span class="c"&gt;# └── hls.js@1.6.7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Schedule the interstitial in the playlist 📼
&lt;/h2&gt;

&lt;p&gt;Interstitials are scheduled with &lt;code&gt;EXT-X-DATERANGE&lt;/code&gt; and keyed to wall-clock time, so the playlist needs a &lt;code&gt;PROGRAM-DATE-TIME&lt;/code&gt; anchor. For VOD, you pick an arbitrary epoch and offset from it. Here's a primary media playlist with a bumper scheduled 10 seconds in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z
#EXT-X-DATERANGE:ID="mid-1",CLASS="com.apple.hls.interstitial",START-DATE="2026-01-01T00:00:10.000Z",X-ASSET-LIST="https://localhost:3000/asset-list?break=mid-1",X-RESUME-OFFSET=0,X-RESTRICT="SKIP,JUMP"
#EXTINF:6.0,
segment0.ts
#EXTINF:6.0,
segment1.ts
... rest of your segments unchanged ...
#EXT-X-ENDLIST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three attributes do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CLASS="com.apple.hls.interstitial"&lt;/code&gt; marks this DATERANGE as an interstitial.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-ASSET-LIST&lt;/code&gt; points at a JSON endpoint (we build it next). For a fixed slate you could use &lt;code&gt;X-ASSET-URI="https://cdn.example.com/bumper/index.m3u8"&lt;/code&gt; directly instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-RESUME-OFFSET=0&lt;/code&gt; means "resume the primary exactly where it paused", which is what a VOD ad break wants. Leave it out entirely and the player advances the primary by the interstitial's duration instead; that default is designed for live (keeps you at a constant delay from the edge) and for content replacement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;X-RESTRICT="SKIP,JUMP"&lt;/code&gt; stops viewers from seeking past the break. Skip it while debugging, add it back for ads.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Tip: the primary playlist never changes per viewer. Ad decisioning happens at the asset-list URL, so the playlist stays a static, cacheable file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Serve the asset list 🧾
&lt;/h2&gt;

&lt;p&gt;The asset list is a JSON document with an &lt;code&gt;ASSETS&lt;/code&gt; array. Each asset has a &lt;code&gt;URI&lt;/code&gt; and a &lt;code&gt;DURATION&lt;/code&gt; (seconds). This is where you'd call your ad decision server; we'll return a fixed bumper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/asset-list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// real life: pick assets per user/break here&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;URI&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.example.com/bumper/index.m3u8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;DURATION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;asset list on :3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js
&lt;span class="c"&gt;# asset list on :3000&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:3000/asset-list?break=mid-1"&lt;/span&gt; | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# {&lt;/span&gt;
&lt;span class="c"&gt;#   "ASSETS": [&lt;/span&gt;
&lt;span class="c"&gt;#     { "URI": "https://cdn.example.com/bumper/index.m3u8", "DURATION": 6 }&lt;/span&gt;
&lt;span class="c"&gt;#   ]&lt;/span&gt;
&lt;span class="c"&gt;# }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Return two objects in &lt;code&gt;ASSETS&lt;/code&gt; and you have an ad pod; the player plays them back to back before resuming.&lt;/p&gt;

&lt;p&gt;Pre-rolls and post-rolls don't need a timeline position at all. Add &lt;code&gt;CUE="PRE"&lt;/code&gt; (or &lt;code&gt;"POST"&lt;/code&gt;) to the DATERANGE and the break anchors to the start or end of the presentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#EXT-X-DATERANGE:ID="preroll",CLASS="com.apple.hls.interstitial",START-DATE="2026-01-01T00:00:00.000Z",CUE="PRE",X-ASSET-URI="https://cdn.example.com/preroll/index.m3u8"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CUE="ONCE"&lt;/code&gt; marks a break that plays a single time per session, which is also where the subtler player edge cases live (more on that in section 5).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: serve this endpoint with CORS headers the player can use (&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;), same as your playlists, or the asset list fetch will fail silently in dev.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Wire up hls.js 🎬
&lt;/h2&gt;

&lt;p&gt;Playback needs no special code; interstitials are on by default in 1.6 when the manifest contains them. The events are where you build UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// player.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Hls&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hls.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://localhost:8080/primary/index.m3u8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// the full interstitial schedule (fires on manifest parse + updates)&lt;/span&gt;
&lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERSTITIALS_UPDATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;breaks:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// asset list fetched for a break&lt;/span&gt;
&lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSET_LIST_LOADED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pod for&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assetList&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// an interstitial asset player was created (asset about to preload/play)&lt;/span&gt;
&lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERSTITIAL_ASSET_PLAYER_CREATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showAdBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// "Ad" chip, disable your seek bar&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// primary content resumed after the break&lt;/span&gt;
&lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INTERSTITIALS_PRIMARY_RESUMED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showAdBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// restore controls&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two debugging affordances worth knowing. The config exposes &lt;code&gt;enableInterstitialPlayback&lt;/code&gt;; set it to &lt;code&gt;false&lt;/code&gt; and the player ignores interstitial DATERANGEs entirely, which gives you a clean A/B while you bring the feature up (and a kill switch in production). There's also &lt;code&gt;hls.interstitialsManager&lt;/code&gt;, which exposes the schedule and playback state so you can render "Ad 1 of 2" and a countdown. Asset list failures surface as non-fatal &lt;code&gt;Hls.Events.ERROR&lt;/code&gt; with &lt;code&gt;ASSET_LIST_LOAD_ERROR&lt;/code&gt; / &lt;code&gt;ASSET_LIST_PARSING_ERROR&lt;/code&gt; details; handle them by letting content continue, which is exactly what the player does by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test the seams, not the happy path ✅
&lt;/h2&gt;

&lt;p&gt;The happy path will work on the first try, which is a trap. Interstitials moved the complexity into player state around the break boundaries, and that's where hls.js has been landing fixes through late 2025 and early 2026 (resume offsets with &lt;code&gt;CUE="ONCE"&lt;/code&gt;, seeking across a scheduled break, and similar edge cases in the issue tracker). Budget a real QA pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Pause during the interstitial, wait 30 seconds, resume&lt;/li&gt;
&lt;li&gt;[ ] Seek from before the break to after it (should the break play? check your &lt;code&gt;X-RESTRICT&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Background the tab mid-break, return after it should have ended&lt;/li&gt;
&lt;li&gt;[ ] Kill the asset-list endpoint and confirm content plays through&lt;/li&gt;
&lt;li&gt;[ ] Watch the break twice in one session with &lt;code&gt;CUE="ONCE"&lt;/code&gt; set (it shouldn't replay)&lt;/li&gt;
&lt;li&gt;[ ] Compare on Safari/AVPlayer if you ship to Apple devices; behavior is native there&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run through that list on desktop Chrome, one mobile browser, and Safari, and you've covered the paths that actually break.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next 🚀
&lt;/h2&gt;

&lt;p&gt;Two follow-ups worth your time. First, integrated timelines: Apple's spec includes &lt;code&gt;X-TIMELINE-OCCUPIES&lt;/code&gt; so scrubbers can render ad blocks as visible slots; if you build custom controls, that's your next feature. Second, live: schedule the DATERANGE ahead of the live edge, drop &lt;code&gt;X-RESUME-OFFSET&lt;/code&gt; so viewers stay at a constant delay, and you have ad breaks in a live channel with the same three pieces you built today. The Apple "Getting Started with HLS Interstitials" PDF and the hls.js API docs cover both in depth.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>video</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Build an AI dubbing pipeline: faster-whisper + XTTS-v2 + FFmpeg</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:29:58 +0000</pubDate>
      <link>https://dev.to/masonwritescode/build-an-ai-dubbing-pipeline-faster-whisper-xtts-v2-ffmpeg-114h</link>
      <guid>https://dev.to/masonwritescode/build-an-ai-dubbing-pipeline-faster-whisper-xtts-v2-ffmpeg-114h</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We're building a script that takes a video in English and produces the same video narrated in Spanish, in a cloned version of the original speaker's voice. Stack: faster-whisper for timestamped transcription, an LLM (or any MT engine) for translation, XTTS-v2 for voice-cloned synthesis, FFmpeg for surgery. We'll also handle the problem every demo skips: translated audio that doesn't fit its time slot.&lt;/p&gt;

&lt;p&gt;📦 Code: github.com/USER/repo (replace before publishing)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you'd rather start from a finished system, Softcatala's &lt;a href="https://github.com/softcatala/open-dubbing" rel="noopener noreferrer"&gt;open-dubbing&lt;/a&gt; and &lt;a href="https://github.com/krillinai/KrillinAI" rel="noopener noreferrer"&gt;KrillinAI&lt;/a&gt; are full pipelines behind one CLI. This post builds the minimal version by hand so you understand what those tools are doing, and where they break.&lt;/p&gt;

&lt;h2&gt;
  
  
  0. Setup and a licensing warning ⚠️
&lt;/h2&gt;

&lt;p&gt;Python 3.10–3.12. The original Coqui company shut down in early 2024; the maintained fork of their TTS library is published by Idiap as &lt;code&gt;coqui-tts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv dub &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source &lt;/span&gt;dub/bin/activate
&lt;span class="nv"&gt;$ &lt;/span&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;faster-whisper coqui-tts
&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;   &lt;span class="c"&gt;# 6.0+ is fine, 8.x current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: the XTTS-v2 &lt;strong&gt;model weights&lt;/strong&gt; ship under the Coqui Public Model License, which restricts commercial use. Prototype freely, but before dubbed videos ship to paying customers, someone must read that license and possibly swap the synthesis step for a commercially licensed model or paid API. Voice cloning also requires the speaker's consent. Get it in writing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Extract audio and transcribe with word timestamps 🎙️
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pull mono 16k audio for the ASR step&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mp4 &lt;span class="nt"&gt;-vn&lt;/span&gt; &lt;span class="nt"&gt;-ac&lt;/span&gt; 1 &lt;span class="nt"&gt;-ar&lt;/span&gt; 16000 &lt;span class="nt"&gt;-y&lt;/span&gt; source.wav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# dub/transcribe.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;faster_whisper&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WhisperModel&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WhisperModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;large-v3-turbo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compute_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;word_timestamps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; segments=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timestamps are the skeleton of the whole pipeline. Every downstream step preserves &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; per segment, because that's where the translated speech has to fit back.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Translate with a length budget 🌍
&lt;/h2&gt;

&lt;p&gt;Per-segment MT gives you sentences that are individually fine and collectively wrong (inconsistent terminology, drifting register). Feed the whole transcript to your translation step with context, and, crucially, give it a length constraint per segment. This is the single biggest lever against sync drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# dub/translate.py (engine-agnostic sketch)
&lt;/span&gt;&lt;span class="n"&gt;PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Translate this video narration from English to Spanish.
Rules:
- Keep terminology consistent (glossary: {glossary})
- Each numbered line must be speakable within its duration.
  Line 3 has 2.8s. Line 7 has 4.1s. Prefer shorter phrasings.
- Return the same numbered lines, translated.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether the engine is an LLM, a local NLLB/M2M model, or a cloud MT API matters less than the contract: same segments in, same segments out, lengths respected. Have a native speaker skim the output. One reviewer-hour here prevents most of the embarrassment this pipeline can produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Clone the voice and synthesize 🗣️
&lt;/h2&gt;

&lt;p&gt;XTTS-v2 supports 17 languages and clones a voice from a few seconds of clean reference audio. Cut a reference clip of the original narrator (no music, no crosstalk):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; source.wav &lt;span class="nt"&gt;-ss&lt;/span&gt; 00:00:12 &lt;span class="nt"&gt;-t&lt;/span&gt; 8 &lt;span class="nt"&gt;-y&lt;/span&gt; reference.wav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# dub/synthesize.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;TTS.api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TTS&lt;/span&gt;

&lt;span class="n"&gt;tts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tts_models/multilingual/multi-dataset/xtts_v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;translated_segments&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tts_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text_es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;speaker_wav&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reference.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;segments/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First run downloads the weights; after that it's local. GPU strongly recommended; CPU works for short content if you're patient.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The boss fight: fitting audio back into time slots ⏱️
&lt;/h2&gt;

&lt;p&gt;Spanish runs longer than English as a rule. Some synthesized segments will overflow their slots, and naive concatenation drifts out of sync within minutes. Measure first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# dub/align.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;soundfile&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sf&lt;/span&gt;

&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;translated_segments&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;segments/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;
    &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⚠️ OVERFLOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seg &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  slot=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s  synth=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s  ratio=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;seg 0007  &lt;span class="nv"&gt;slot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.10s  &lt;span class="nv"&gt;synth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5.23s  &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.28  ⚠️ OVERFLOW
seg 0012  &lt;span class="nv"&gt;slot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2.80s  &lt;span class="nv"&gt;synth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2.91s  &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.04  ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply fixes in escalating order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Absorb into silence.&lt;/strong&gt; If the next segment starts late, let the audio spill into the gap. Free and inaudible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-stretch gently.&lt;/strong&gt; FFmpeg's &lt;code&gt;atempo&lt;/code&gt; up to ~1.1 is usually imperceptible on speech; beyond that it sounds rushed:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; segments/0007.wav &lt;span class="nt"&gt;-filter&lt;/span&gt;:a &lt;span class="s2"&gt;"atempo=1.12"&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; segments/0007_fit.wav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-translate the outliers.&lt;/strong&gt; Anything still over ratio ~1.2 goes back to step 2 with a tighter length budget. Retranslating five bad segments beats stretching fifty.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Build the final track by placing each segment at its original &lt;code&gt;start&lt;/code&gt; on a silent canvas, then remux against the untouched video stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# assemble placed segments into one track (adelay per segment, amix), then:&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; dubbed_es.wav &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-map&lt;/span&gt; 0:v &lt;span class="nt"&gt;-map&lt;/span&gt; 1:a &lt;span class="nt"&gt;-c&lt;/span&gt;:v copy &lt;span class="nt"&gt;-shortest&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; output_es.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-c:v copy&lt;/code&gt; matters: the video stream is never re-encoded, so the dub costs nothing in visual quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ship it as a track, not a fork 📦
&lt;/h2&gt;

&lt;p&gt;Don't create &lt;code&gt;tutorial_es_final_v2.mp4&lt;/code&gt; files. Mux the dub as an additional audio track and let the player expose a language menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; dubbed_es.wav &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-map&lt;/span&gt; 0 &lt;span class="nt"&gt;-map&lt;/span&gt; 1:a &lt;span class="nt"&gt;-c&lt;/span&gt; copy &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-metadata&lt;/span&gt;:s:a:0 &lt;span class="nv"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eng &lt;span class="nt"&gt;-metadata&lt;/span&gt;:s:a:1 &lt;span class="nv"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;spa &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-y&lt;/span&gt; output_multilang.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For HLS delivery, each language becomes an audio rendition in the master playlist; one video ladder, N audio tracks, and the player switches without a second stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that will bite you 🧾
&lt;/h2&gt;

&lt;p&gt;A short list from the failure modes this kind of pipeline reliably produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbers, units, and code.&lt;/strong&gt; TTS models mangle "0.00096" and "&lt;code&gt;av1_vulkan&lt;/code&gt;" in every language. Pre-process the script: expand numbers to words in the target language, and decide whether code identifiers stay English (they should).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background music.&lt;/strong&gt; If the source audio has music under the voice, your extracted track carries it into transcription fine, but your dubbed track loses it entirely. Either separate stems first (Demucs works) or accept music-free dubs; mixing the original music bed back under the synthesized voice is the professional-sounding middle path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speaker changes.&lt;/strong&gt; One reference clip means one voice. Interviews and multi-presenter webinars need diarization (who speaks when) before synthesis, which is where the prebuilt pipelines earn their keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acronyms and product names.&lt;/strong&gt; Add them to the glossary with explicit pronunciation guidance, or enjoy hearing your product's name pronounced five different ways across one video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silence is load-bearing.&lt;/strong&gt; Don't trim inter-segment gaps to make room; viewers use those pauses to process what's on screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Automate the pipeline against your upload flow and keep a per-language glossary file under version control; translation consistency is what makes a library feel professionally localized.&lt;/li&gt;
&lt;li&gt;The overflow report from step 4 is your QA dashboard. Track the overflow rate per language over time; it tells you when your translation prompt or TTS pacing regressed.&lt;/li&gt;
&lt;li&gt;When you outgrow the DIY version: open-dubbing and KrillinAI cover more edge cases (multi-speaker, subtitle sync), and lip-sync models exist as a heavier stage for talking-head content.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>tutorial</category>
      <category>video</category>
      <category>ai</category>
    </item>
    <item>
      <title>Probing FFmpeg's av1_vulkan encoder: does your GPU actually support it?</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:29:44 +0000</pubDate>
      <link>https://dev.to/masonwritescode/probing-ffmpegs-av1vulkan-encoder-does-your-gpu-actually-support-it-2h07</link>
      <guid>https://dev.to/masonwritescode/probing-ffmpegs-av1vulkan-encoder-does-your-gpu-actually-support-it-2h07</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;FFmpeg 8.x includes &lt;code&gt;av1_vulkan&lt;/code&gt;, the first cross-vendor GPU AV1 encoder in mainline FFmpeg. We'll probe whether your GPU + driver actually expose AV1 encode, run a first working encode, benchmark it against SVT-AV1 on your own content, and talk about which jobs deserve it.&lt;/p&gt;

&lt;p&gt;📦 Code: github.com/USER/repo (replace before publishing)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Until FFmpeg 8.0 ("Huffman", released August 2025), GPU AV1 encoding meant picking a vendor: &lt;code&gt;av1_nvenc&lt;/code&gt; for NVIDIA RTX 40+, &lt;code&gt;av1_amf&lt;/code&gt; for AMD, &lt;code&gt;av1_qsv&lt;/code&gt; for Intel Arc. Three code paths, three sets of flags, three driver stacks. The Vulkan Video encode work gives FFmpeg one encoder that reaches all three vendors through the standard &lt;code&gt;VK_KHR_video_encode_av1&lt;/code&gt; extension.&lt;/p&gt;

&lt;p&gt;The catch: driver support is a lottery. Plenty of capable hardware sits behind drivers that don't expose the encode extension yet. So before any pipeline decisions, we probe.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Check what you're running
&lt;/h2&gt;

&lt;p&gt;You want FFmpeg 8.x (8.1.2 is current as of late June 2026) built with Vulkan support, plus the &lt;code&gt;vulkaninfo&lt;/code&gt; tool from the Vulkan SDK / &lt;code&gt;vulkan-tools&lt;/code&gt; package.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
ffmpeg version 8.1.2 Copyright &lt;span class="o"&gt;(&lt;/span&gt;c&lt;span class="o"&gt;)&lt;/span&gt; 2000-2026 the FFmpeg developers

&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-encoders&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;vulkan
 V....D av1_vulkan           AV1 &lt;span class="o"&gt;(&lt;/span&gt;Vulkan&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;codec av1&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;av1_vulkan&lt;/code&gt; doesn't appear, your build wasn't compiled with &lt;code&gt;--enable-vulkan&lt;/code&gt; (distro packages vary; the BtbN static builds and most 8.x distro packages include it).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Probe the driver for AV1 encode 🔍
&lt;/h2&gt;

&lt;p&gt;The encoder existing in FFmpeg means nothing if the driver doesn't expose the extension. This is the step that separates "should work" from "works":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vulkaninfo | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"video_encode_(av1|queue)"&lt;/span&gt;
    VK_KHR_video_encode_av1    : extension revision 1
    VK_KHR_video_encode_queue  : extension revision 12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You see&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Both extensions listed&lt;/td&gt;
&lt;td&gt;You can encode AV1 via Vulkan 🎉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only &lt;code&gt;video_encode_queue&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Driver does Vulkan encode, but not AV1 (maybe H.264/H.265 only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neither&lt;/td&gt;
&lt;td&gt;Driver too old, or GPU lacks an AV1-capable video engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rough hardware floor: the last couple of GPU generations from each vendor have AV1 encode silicon. On Linux, Mesa/RADV support for AMD has been the pacesetter; NVIDIA and Intel arrived on their own schedules, and Windows driver branches differ again. When in doubt, update the driver first and re-probe; that fixes more failures than any FFmpeg flag.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: there are documented cases of reasonable hardware + wrong driver branch producing zero frames. If step 3 fails, the error message usually names the missing extension. It's a driver problem, not your command line.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. First encode: synthetic, then real
&lt;/h2&gt;

&lt;p&gt;Start with a generated source so file quirks can't confuse the diagnosis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-init_hw_device&lt;/span&gt; &lt;span class="nv"&gt;vulkan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpu &lt;span class="nt"&gt;-filter_hw_device&lt;/span&gt; gpu &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-f&lt;/span&gt; lavfi &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;testsrc2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10:size&lt;span class="o"&gt;=&lt;/span&gt;1920x1080:rate&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"format=nv12,hwupload"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-c&lt;/span&gt;:v av1_vulkan &lt;span class="nt"&gt;-y&lt;/span&gt; /tmp/probe.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output tail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;frame&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;  300 &lt;span class="nv"&gt;fps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;142 &lt;span class="nv"&gt;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-0&lt;/span&gt;.0 &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;    1843KiB &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;00:00:10.00 &lt;span class="nv"&gt;bitrate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1509.6kbits/s &lt;span class="nv"&gt;speed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.7x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that works, swap in a real file. Keep the decode on the GPU too, so frames never cross the PCIe bus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-init_hw_device&lt;/span&gt; &lt;span class="nv"&gt;vulkan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpu &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; vulkan &lt;span class="nt"&gt;-hwaccel_output_format&lt;/span&gt; vulkan &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-i&lt;/span&gt; input_1080p.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-c&lt;/span&gt;:v av1_vulkan &lt;span class="nt"&gt;-b&lt;/span&gt;:v 2M &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 2.5M &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 4M &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-c&lt;/span&gt;:a copy &lt;span class="nt"&gt;-y&lt;/span&gt; out_av1.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That decode → encode residency is half the point of Vulkan here: FFmpeg 8's Vulkan work includes GPU-side processing, so a multi-rendition transcode can stay on-device instead of bouncing frames to system RAM for every filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Benchmark against SVT-AV1 on YOUR content 📊
&lt;/h2&gt;

&lt;p&gt;Codec verdicts on someone else's clips are astrology. Take a representative sample of your real content and run a three-way bake-off at matched bitrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# software baseline&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;time &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; sample.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libsvtav1 &lt;span class="nt"&gt;-preset&lt;/span&gt; 8 &lt;span class="nt"&gt;-b&lt;/span&gt;:v 2M &lt;span class="nt"&gt;-y&lt;/span&gt; svt.mp4

&lt;span class="c"&gt;# vulkan hardware path&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;time &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-init_hw_device&lt;/span&gt; &lt;span class="nv"&gt;vulkan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpu &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; vulkan &lt;span class="nt"&gt;-hwaccel_output_format&lt;/span&gt; vulkan &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-i&lt;/span&gt; sample.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v av1_vulkan &lt;span class="nt"&gt;-b&lt;/span&gt;:v 2M &lt;span class="nt"&gt;-y&lt;/span&gt; vk.mp4

&lt;span class="c"&gt;# score both with VMAF&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; vk.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; sample.mp4 &lt;span class="nt"&gt;-lavfi&lt;/span&gt; libvmaf &lt;span class="nt"&gt;-f&lt;/span&gt; null -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you should expect, without me inventing numbers for hardware I'm not running: the software encoder wins quality-per-bit, the hardware path wins throughput and watts. That's been the hardware-encoder trade since forever and Vulkan routes to the same silicon through a standard door. The decision input is your VMAF delta at your target bitrate versus your compute bill, so measure both on your content and your GPUs.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Which ladder rungs actually move
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Recommended path&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Archival VOD top renditions&lt;/td&gt;
&lt;td&gt;SVT-AV1 (CPU)&lt;/td&gt;
&lt;td&gt;Every saved kilobit multiplies across storage + delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live transcode ladders&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;av1_vulkan&lt;/code&gt; / hw&lt;/td&gt;
&lt;td&gt;Realtime throughput is the constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UGC bulk ingest&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;av1_vulkan&lt;/code&gt; / hw&lt;/td&gt;
&lt;td&gt;Most uploads get few views; economics favor throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Previews, proxies, scrub assets&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;av1_vulkan&lt;/code&gt; / hw&lt;/td&gt;
&lt;td&gt;Nobody A/Bs the grain on a preview&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor-mixed fleet&lt;/td&gt;
&lt;td&gt;&lt;code&gt;av1_vulkan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One code path across NVIDIA/AMD/Intel is the whole pitch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cross-vendor part is easy to underrate. If your pipeline speaks Vulkan instead of NVENC, the day cheaper spot capacity shows up on a different vendor's silicon, you follow the price with a config change instead of a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it in containers 🐳
&lt;/h2&gt;

&lt;p&gt;Your workstation probe passing means little if production is containerized. Vulkan in a container needs three things: the GPU device passed through, the vendor ICD (installable client driver) JSON present in the image, and a userspace driver that matches the host kernel driver.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NVIDIA (with nvidia-container-toolkit)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--gpus&lt;/span&gt; all &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;NVIDIA_DRIVER_CAPABILITIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="se"&gt;\&lt;/span&gt;
    my-ffmpeg:8.1 vulkaninfo | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; encode_av1

&lt;span class="c"&gt;# AMD/Intel (Mesa): pass the render node&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/dri &lt;span class="se"&gt;\&lt;/span&gt;
    my-ffmpeg:8.1 vulkaninfo | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; encode_av1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: &lt;code&gt;NVIDIA_DRIVER_CAPABILITIES=all&lt;/code&gt; (or at least &lt;code&gt;video,compute,graphics&lt;/code&gt;) matters; the default capability set doesn't expose the video engines, and the resulting "No Vulkan device found" error looks identical to missing hardware.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run the same probe inside the exact image your orchestrator schedules. Host/image driver mismatches produce failures that no amount of FFmpeg flag-tweaking fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failures and fixes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# "Device does not support the requested video codec profile"&lt;/span&gt;
→ driver exposes encode_queue but not encode_av1&lt;span class="p"&gt;;&lt;/span&gt; update driver or use av1_nvenc/amf/qsv

&lt;span class="c"&gt;# "No Vulkan device found"&lt;/span&gt;
→ missing ICD loader or running &lt;span class="k"&gt;in &lt;/span&gt;a container without &lt;span class="nt"&gt;--device&lt;/span&gt;/gpu passthrough

&lt;span class="c"&gt;# encode runs but output is garbage/black&lt;/span&gt;
→ pixel format mismatch&lt;span class="p"&gt;;&lt;/span&gt; force &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nv12 before hwupload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wire the probe into CI: a nightly &lt;code&gt;vulkaninfo | grep&lt;/code&gt; + testsrc encode across your fleet tells you when a driver update flips a machine from software to hardware eligible.&lt;/li&gt;
&lt;li&gt;Re-run your bake-off quarterly. The 8.1.x cycle has been landing driver-facing fixes steadily, and results from January are stale by summer.&lt;/li&gt;
&lt;li&gt;If you want the decode-side story (Vulkan AV1/H.264/HEVC decode is much further along), that pairs well with this as the other half of a GPU-resident pipeline.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ffmpeg</category>
      <category>video</category>
      <category>tutorial</category>
      <category>devops</category>
    </item>
    <item>
      <title>Ship a 'Go Live' button: OBS in, LL-HLS out, webhooks in between</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:29:34 +0000</pubDate>
      <link>https://dev.to/masonwritescode/ship-a-go-live-button-obs-in-ll-hls-out-webhooks-in-between-7dn</link>
      <guid>https://dev.to/masonwritescode/ship-a-go-live-button-obs-in-ll-hls-out-webhooks-in-between-7dn</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We're adding live streaming to a SaaS dashboard: a backend endpoint that creates a stream, OBS as the broadcaster over RTMPS, LL-HLS playback with hls.js, and a webhook handler that keeps the UI honest. Working "go live" flow in an afternoon.&lt;/p&gt;

&lt;p&gt;📦 Code: github.com/USER/repo (replace before publishing)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Webinars, coaching sessions, company town halls: sooner or later your product gets the "can users go live?" ticket. The hard parts (ingest servers, transcoding, CDN delivery) are exactly the parts you should not build. We'll use FastPix as the managed layer here; the same flow works nearly line-for-line on Mux, Cloudflare Stream, or api.video.&lt;/p&gt;

&lt;p&gt;What we're building:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A backend endpoint that creates a live stream and returns a stream key&lt;/li&gt;
&lt;li&gt;An OBS setup broadcasters can follow in two minutes&lt;/li&gt;
&lt;li&gt;A viewer page playing LL-HLS with hls.js&lt;/li&gt;
&lt;li&gt;A webhook handler that flips the webinar between &lt;code&gt;scheduled → live → ended&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Create the stream server-side 🛠️
&lt;/h2&gt;

&lt;p&gt;You need API credentials (Access Token ID + Secret Key). FastPix uses Basic auth on the server API. Node 20.x, plain &lt;code&gt;fetch&lt;/code&gt;, no SDK required (though official Node.js/Python/Go/Ruby/PHP/Java/C# SDKs exist if you prefer).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server/routes/streams.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Router&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Basic &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FP_TOKEN_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FP_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webinars/:id/stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.fastpix.io/v1/live/streams&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AUTH&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;playbackSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accessPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream create failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// persist against your webinar row:&lt;/span&gt;
  &lt;span class="c1"&gt;// streamId, streamKey (SECRET!), playbackId&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webinar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;streamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;streamKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streamKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;playbackId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackIds&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scheduled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: the &lt;strong&gt;stream key is a credential&lt;/strong&gt;. Anyone who has it can broadcast as your customer. Show it once in the UI, store it encrypted, and offer a "reset key" button.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two IDs come back and they have opposite audiences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Who gets it&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;streamKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The broadcaster only&lt;/td&gt;
&lt;td&gt;Authenticates ingest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;playbackId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every viewer&lt;/td&gt;
&lt;td&gt;Builds the playback URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  2. Point OBS at it 🎥
&lt;/h2&gt;

&lt;p&gt;Your broadcasters will use OBS or something that behaves like it. RTMPS is the default ingest protocol (SRT is there too if your users broadcast from unreliable networks). The setup you'll paste into your help docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Settings&lt;/span&gt; → &lt;span class="n"&gt;Stream&lt;/span&gt;
  &lt;span class="n"&gt;Service&lt;/span&gt;:    &lt;span class="n"&gt;Custom&lt;/span&gt;
  &lt;span class="n"&gt;Server&lt;/span&gt;:     &lt;span class="n"&gt;rtmps&lt;/span&gt;://&amp;lt;&lt;span class="n"&gt;ingest&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;dashboard&lt;/span&gt;/&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&amp;gt;
  &lt;span class="n"&gt;Stream&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;: &amp;lt;&lt;span class="n"&gt;streamKey&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit "Start Streaming" in OBS. Within a few seconds the platform starts transcoding into an adaptive ladder. You don't configure renditions; ABR generation is automatic.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Play it with hls.js 📺
&lt;/h2&gt;

&lt;p&gt;Playback is a standard LL-HLS manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://stream.fastpix.io/&amp;lt;playbackId&amp;gt;.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Safari plays HLS natively. Everything else needs MSE, which is what hls.js (1.6.16 at the time of writing) is for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/components/LivePlayer.jsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Hls&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hls.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LivePlayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;playbackId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://stream.fastpix.io/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;playbackId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.m3u8`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.apple.mpegurl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Safari&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;lowLatencyMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;videoRef&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;controls&lt;/span&gt; &lt;span class="nx"&gt;playsInline&lt;/span&gt; &lt;span class="nx"&gt;muted&lt;/span&gt; &lt;span class="nx"&gt;autoPlay&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastPix also ships a prebuilt web/iOS/Android player if you'd rather not own the player surface. For paid content, switch &lt;code&gt;accessPolicy&lt;/code&gt; off public and mint short-lived JWTs server-side; don't ship permanent public URLs for gated streams.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Tip: LL-HLS gets you latency in the low seconds. If you need sub-second conversational latency (auctions, telehealth), that's WebRTC territory and a different architecture.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Webhooks drive the UI, not polling 🔔
&lt;/h2&gt;

&lt;p&gt;The stream has a lifecycle your app needs to mirror: the encoder connects, the stream goes active, the encoder drops, maybe reconnects, the broadcast ends, the recording becomes ready. Every managed platform emits webhooks for these.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server/routes/webhooks.js&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video.live_stream.active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webinarByStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streamId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video.live_stream.disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// encoder dropped; reconnect window keeps the session alive&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webinarByStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streamId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reconnecting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video.live_stream.idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webinarByStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streamId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ended&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video.asset.ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// live-to-VOD recording is ready: attach the replay&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webinarByStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streamId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;replay_ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;replayPlaybackId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ack fast, do heavy work async&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Event names vary slightly per platform; check the webhook reference for exact types and payloads.&lt;/p&gt;

&lt;p&gt;Two rules that save you from 2 AM pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency.&lt;/strong&gt; Webhooks arrive duplicated and out of order. Make every transition safe to replay (e.g., ignore &lt;code&gt;active&lt;/code&gt; if you're already &lt;code&gt;ended&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish "disconnected" from "ended".&lt;/strong&gt; Presenters drop off hotel Wi-Fi constantly. The platform's reconnect window keeps the stream session alive through short drops; your UI should show "reconnecting", not kill the room.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;video.asset.ready&lt;/code&gt; branch is the sleeper feature: live-to-VOD recording means every broadcast automatically becomes an on-demand replay. Wire it to the same webinar page and your event content compounds instead of evaporating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the whole loop end to end ✅
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:3000/webinars/42/stream | jq
&lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"ok"&lt;/span&gt;: &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# start OBS → watch your webhook log&lt;/span&gt;
POST /webhooks/video  &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;video.live_stream.active
&lt;span class="c"&gt;# viewer page flips to LIVE&lt;/span&gt;

&lt;span class="c"&gt;# stop OBS, wait out the reconnect window&lt;/span&gt;
POST /webhooks/video  &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;video.live_stream.idle
POST /webhooks/video  &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;video.asset.ready
&lt;span class="c"&gt;# webinar page now shows the replay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the viewer page never flips to LIVE, it's almost always one of: webhook endpoint not publicly reachable (use a tunnel in dev), wrong stream key in OBS, or your handler acking before persisting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist before real customers 📋
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Stream key shown once, stored encrypted, reset button wired&lt;/li&gt;
&lt;li&gt;[ ] Webhook endpoint verified with the platform's signing secret (don't accept unsigned POSTs)&lt;/li&gt;
&lt;li&gt;[ ] Idempotent transitions tested by replaying the same webhook twice&lt;/li&gt;
&lt;li&gt;[ ] Kill-the-encoder drill: stop OBS mid-stream, confirm UI shows "reconnecting", restart OBS, confirm recovery&lt;/li&gt;
&lt;li&gt;[ ] Replay page renders from &lt;code&gt;video.asset.ready&lt;/code&gt; without manual steps&lt;/li&gt;
&lt;li&gt;[ ] Signed playback (JWT) for anything gated; public URLs only for genuinely public streams&lt;/li&gt;
&lt;li&gt;[ ] A "stream health" admin view (even just raw webhook log) so support can answer "is it us or them"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The kill-the-encoder drill is the one teams skip and the one that matters most. Presenters lose connectivity during real events constantly; if your UI slams to "ended" on a blip, the audience leaves and doesn't come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Signed playback with JWTs for paid/gated streams, and simulcast if your users also want to hit YouTube/Twitch simultaneously; both are API-level features, no re-architecture.&lt;/li&gt;
&lt;li&gt;The full API surface used here is in the &lt;a href="https://docs.fastpix.io/reference" rel="noopener noreferrer"&gt;FastPix API reference&lt;/a&gt;. New accounts get $25 in credits without a card, which is plenty for this build. And if you're on Mux, Cloudflare Stream, or api.video instead: the create-stream/webhook/playback pattern in this post maps over almost 1:1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disclosure: this tutorial was prepared for FastPix, whose team reviews and publishes it; the patterns shown transfer to any managed video platform.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>video</category>
      <category>node</category>
    </item>
    <item>
      <title>Playing HLS through Managed Media Source on iPhone with hls.js</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:21:27 +0000</pubDate>
      <link>https://dev.to/masonwritescode/playing-hls-through-managed-media-source-on-iphone-with-hlsjs-3cn8</link>
      <guid>https://dev.to/masonwritescode/playing-hls-through-managed-media-source-on-iphone-with-hlsjs-3cn8</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;iPhone Safari never supported Media Source Extensions, so JS-driven players fell back to native HLS. Since iOS 17.1, Apple ships Managed Media Source (MMS), an MSE variant where the browser controls when you fetch. We'll wire up hls.js to use it, then handle the one behavior that bites people: the browser can evict your buffer.&lt;/p&gt;

&lt;p&gt;📦 Code: github.com/USER/mms-hls-demo (replace before publishing)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A minimal HLS player page that works through Managed Media Source on iPhone (iOS 17.1+) and falls back cleanly everywhere else. We'll do it twice: first with hls.js doing the heavy lifting (what you'll actually ship), then a stripped-down raw MMS example so you understand what the library is doing under the hood.&lt;/p&gt;

&lt;p&gt;Versions used: &lt;code&gt;hls.js 1.6.13&lt;/code&gt;, tested on iOS 17.1+ Safari and desktop Chrome/Firefox.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why the old approach fell short
&lt;/h2&gt;

&lt;p&gt;On every platform except iPhone, you'd attach a &lt;code&gt;MediaSource&lt;/code&gt; and feed it segments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classic MSE: works on desktop, NOT on iPhone Safari (pre-17.1)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mediaSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On iPhone, &lt;code&gt;window.MediaSource&lt;/code&gt; was simply &lt;code&gt;undefined&lt;/code&gt;, so libraries detected that and fell back to native HLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the fallback every player shipped&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.apple.mpegurl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/playlist.m3u8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// browser owns everything&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works, but you lose JS control over buffering, ABR, DVR UI, and quality menus. MMS is the way back in.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Feature-detect Managed Media Source
&lt;/h2&gt;

&lt;p&gt;The new global is &lt;code&gt;ManagedMediaSource&lt;/code&gt;. Detect it alongside the classic one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/detect.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getMediaSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ManagedMediaSource&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MediaSource&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasManagedMediaSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ManagedMediaSource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: &lt;code&gt;self&lt;/code&gt; works in both window and worker contexts, which matters if you ever move media handling into a Web Worker.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. The hls.js path (what you'll ship)
&lt;/h2&gt;

&lt;p&gt;Here's the good news: hls.js 1.6.x already uses the &lt;code&gt;ManagedMediaSource&lt;/code&gt; global by default when it's present. For most teams the "port to MMS" task is "upgrade hls.js and fix two attributes."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;hls.js@1.6.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- index.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"player"&lt;/span&gt; &lt;span class="na"&gt;controls&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/player.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Hls&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hls.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;player&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Two attributes that matter on iOS with MMS:&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disableRemotePlayback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keeps AirPlay from hijacking the managed buffer&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSupported&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Covers desktop MSE AND iPhone Managed Media Source, hls.js picks the right one.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;enableWorker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MANIFEST_PARSED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.apple.mpegurl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Very old iOS or browsers without MSE/MMS: native HLS.&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it for the common case. The reason it's anticlimactic is the whole point: hls.js subscribes to the MMS streaming events and handles buffer eviction so you don't have to. But you &lt;em&gt;should&lt;/em&gt; understand what it's doing, because debugging it without that mental model is miserable.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The raw MMS example (so you understand the library)
&lt;/h2&gt;

&lt;p&gt;If you're building DASH-on-iOS or your own player, here's the shape of MMS by hand. Three things differ from classic MSE.&lt;/p&gt;

&lt;h3&gt;
  
  
  Difference 1: attach via a child &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt;, not an object URL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/raw-mms.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ManagedMediaSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mediaSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MMS&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disableRemotePlayback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// iOS wants a child &amp;lt;source&amp;gt; element, not video.src = URL.createObjectURL(...)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video/mp4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// your fMP4 mime&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Difference 2: only fetch between startstreaming and endstreaming
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sourceBuffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sourceopen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sourceBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addSourceBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video/mp4; codecs="avc1.4d401f"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// The browser tells YOU when it wants data. Respect it or you lose the battery win.&lt;/span&gt;
&lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;startstreaming&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streaming&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;moreSegments&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchNextSegment&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;appendChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;mediaSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;endstreaming&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Stop fetching. The UA has enough buffered for now.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Difference 3: the buffer can be evicted under you
&lt;/h3&gt;

&lt;p&gt;This is the gotcha. With classic MSE, whatever you appended stayed until you removed it. With MMS, the browser can purge buffered ranges under memory pressure and fire &lt;code&gt;bufferedchange&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;sourceBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bufferedchange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// e.addedRanges and e.removedRanges are TimeRanges-like.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removedRanges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removedRanges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removedRanges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`UA evicted buffered range &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// If the user seeks back into an evicted range, you must re-fetch it.&lt;/span&gt;
    &lt;span class="nf"&gt;markRangeForRefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: if you assume appended data is permanent (the MSE habit), seeking backward into an evicted range will look like a frozen player on iPhone and work fine everywhere else. This is the single most common MMS bug. hls.js handles it; hand-rolled players must.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Test it on a real device
&lt;/h2&gt;

&lt;p&gt;Simulators lie about media. Use a physical iPhone on iOS 17.1+.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# serve over HTTPS-ish locally; iOS media needs a secure-ish context&lt;/span&gt;
npx vite &lt;span class="nt"&gt;--host&lt;/span&gt;
&lt;span class="c"&gt;# then open the LAN URL on the iPhone, on cellular if you can&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Playback starts on the iPhone with the hls.js path (not the native fallback). Log which branch ran.&lt;/li&gt;
&lt;li&gt;[ ] AirPlay to an Apple TV hands off cleanly with &lt;code&gt;disableRemotePlayback = true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Watch a few minutes, scrub backward a long way, confirm no freeze (the eviction case).&lt;/li&gt;
&lt;li&gt;[ ] Throttle to a slow profile and confirm ABR still down-switches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Confirm which path actually ran
&lt;/h2&gt;

&lt;p&gt;The most confusing MMS bug is thinking you're on Managed Media Source when you quietly fell back to native HLS (or vice versa). Add an explicit log so you're never guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/which-path.js&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportPlaybackPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ManagedMediaSource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSupported&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[playback] hls.js via ManagedMediaSource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSupported&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[playback] hls.js via classic MediaSource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.apple.mpegurl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[playback] native HLS fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[playback] no supported path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a real iPhone running iOS 17.1+, you want to see the first line, not the native fallback. If you see the fallback, check that your hls.js version is recent enough to use the &lt;code&gt;ManagedMediaSource&lt;/code&gt; global and that you didn't accidentally short-circuit to &lt;code&gt;video.src&lt;/code&gt; somewhere.&lt;/p&gt;

&lt;p&gt;You can also inspect it live from Safari's Web Inspector (connect the iPhone to a Mac, open Develop menu) and confirm &lt;code&gt;'ManagedMediaSource' in window&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# in the Web Inspector console, attached to the iPhone tab&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ManagedMediaSource'&lt;/span&gt; &lt;span class="k"&gt;in &lt;/span&gt;window
&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: ship that &lt;code&gt;reportPlaybackPath&lt;/code&gt; log behind a debug flag in production too. "Which playback path did this session use?" is the first question you'll ask when an iPhone-only bug report lands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When you should NOT bother
&lt;/h2&gt;

&lt;p&gt;Honest take: if you only target Apple devices and you're happy with native HLS, native HLS is still Apple's recommendation and it's less code. Reach for MMS when you need MSE-only powers on iPhone: MPEG-DASH on iOS, a custom DVR/quality UI, or one unified player codebase instead of a forked iOS path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Move segment fetching into a Web Worker and drive it from the streaming events for smoother main-thread behavior.&lt;/li&gt;
&lt;li&gt;If you use DASH, look at dash.js / Shaka MMS support to drop your separate iOS HLS packaging step.&lt;/li&gt;
&lt;li&gt;Read the WebKit AirPlay-with-MSE post for the child-&lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; handoff details.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your player library is current, you may already be on MMS and not know it. Add a one-line log for which code path runs on iOS and go check.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>video</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Force keyframe-aligned GOPs across an ABR ladder with FFmpeg</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:20:47 +0000</pubDate>
      <link>https://dev.to/masonwritescode/force-keyframe-aligned-gops-across-an-abr-ladder-with-ffmpeg-4cpl</link>
      <guid>https://dev.to/masonwritescode/force-keyframe-aligned-gops-across-an-abr-ladder-with-ffmpeg-4cpl</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If your stream hitches on quality switches but shows zero rebuffering, your ABR renditions probably have keyframes in different places. We'll encode a ladder with identical, forced, closed GOPs in FFmpeg, then verify alignment with ffprobe and MP4Box so it can't regress.&lt;/p&gt;

&lt;p&gt;📦 Code: github.com/USER/abr-keyframe-align (replace before publishing)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;A clean ABR down-switch needs both renditions to have a keyframe (IDR) at the same media time. If 1080p has a keyframe at 4.0s and 720p's nearest is at 4.3s, the player can't splice cleanly and you get a micro-freeze. Your QoE dashboard says zero rebuffers because, technically, nothing rebuffered. The seam just collided.&lt;/p&gt;

&lt;p&gt;Versions: &lt;code&gt;ffmpeg 7.x&lt;/code&gt;, &lt;code&gt;MP4Box&lt;/code&gt; (GPAC), tested with x264 and NVENC.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Pick one cadence for the whole ladder
&lt;/h2&gt;

&lt;p&gt;Keyframe interval should equal &lt;code&gt;framerate × segment_seconds&lt;/code&gt;. Lock it once and apply it to every rung.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 30 fps source, 2-second segments =&amp;gt; keyint = 60&lt;/span&gt;
&lt;span class="nv"&gt;FPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30
&lt;span class="nv"&gt;SEG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;span class="nv"&gt;KEYINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; FPS &lt;span class="o"&gt;*&lt;/span&gt; SEG &lt;span class="k"&gt;))&lt;/span&gt;   &lt;span class="c"&gt;# 60&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"keyint=&lt;/span&gt;&lt;span class="nv"&gt;$KEYINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rungs vary in resolution and bitrate. The keyframe cadence does &lt;strong&gt;not&lt;/strong&gt; vary. That's the whole trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The naive command that causes the bug
&lt;/h2&gt;

&lt;p&gt;This looks fine and ships broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DON'T do this for an ABR ladder&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; source.mov &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-b&lt;/span&gt;:v 5000k &lt;span class="nt"&gt;-hls_time&lt;/span&gt; 2 out.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scene-cut detection inserts keyframes at content-driven moments, not your grid, and different rungs make different decisions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min-keyint&lt;/code&gt; defaults low, so the encoder can drop in early keyframes whenever it likes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Force fixed, closed GOPs (x264)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# encode-1080p.sh&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; source.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-b&lt;/span&gt;:v 5000k &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 5350k &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 7500k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-x264opts&lt;/span&gt; &lt;span class="s2"&gt;"keyint=60:min-keyint=60:no-scenecut"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-force_key_frames&lt;/span&gt; &lt;span class="s2"&gt;"expr:gte(t,n_forced*2)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 128k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_time&lt;/span&gt; 2 &lt;span class="nt"&gt;-hls_playlist_type&lt;/span&gt; vod &lt;span class="nt"&gt;-hls_segment_type&lt;/span&gt; fmp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_segment_filename&lt;/span&gt; &lt;span class="s2"&gt;"1080p_%03d.m4s"&lt;/span&gt; 1080p.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keyint=60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GOP size = 60 frames (2s at 30fps)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;min-keyint=60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;min == max =&amp;gt; closed, fixed GOPs, no early keyframes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-scenecut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stop the encoder adding content-driven keyframes that break alignment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-force_key_frames expr:gte(t,n_forced*2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;belt-and-suspenders: a keyframe every 2s by &lt;strong&gt;time&lt;/strong&gt;, robust to VFR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-hls_time 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;segment duration matches the GOP boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run the &lt;strong&gt;same&lt;/strong&gt; keyframe flags for every rung, changing only resolution/bitrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# encode-720p.sh: identical keyframe settings, different bitrate/scale&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; source.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"scale=-2:720"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-b&lt;/span&gt;:v 2800k &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 3000k &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 4200k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-x264opts&lt;/span&gt; &lt;span class="s2"&gt;"keyint=60:min-keyint=60:no-scenecut"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-force_key_frames&lt;/span&gt; &lt;span class="s2"&gt;"expr:gte(t,n_forced*2)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 128k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_time&lt;/span&gt; 2 &lt;span class="nt"&gt;-hls_playlist_type&lt;/span&gt; vod &lt;span class="nt"&gt;-hls_segment_type&lt;/span&gt; fmp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_segment_filename&lt;/span&gt; &lt;span class="s2"&gt;"720p_%03d.m4s"&lt;/span&gt; 720p.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. NVENC is different
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;x264opts&lt;/code&gt; string doesn't apply to hardware encoders. Use &lt;code&gt;-g&lt;/code&gt; and keep the &lt;code&gt;force_key_frames&lt;/code&gt; expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# GPU path&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v h264_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p5 &lt;span class="nt"&gt;-b&lt;/span&gt;:v 5000k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; 60 &lt;span class="nt"&gt;-force_key_frames&lt;/span&gt; &lt;span class="s2"&gt;"expr:gte(t,n_forced*2)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_time&lt;/span&gt; 2 &lt;span class="nt"&gt;-hls_segment_type&lt;/span&gt; fmp4 1080p_gpu.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: NVENC honors &lt;code&gt;-g&lt;/code&gt; for GOP size and &lt;code&gt;-force_key_frames&lt;/code&gt; for exact placement. There's no &lt;code&gt;no-scenecut&lt;/code&gt; knob the same way, so the forced-keyframe expression does the heavy lifting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Verify with ffprobe
&lt;/h2&gt;

&lt;p&gt;Don't trust it, check it. List keyframe timestamps for each rendition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# print PTS of every keyframe in a rendition&lt;/span&gt;
ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-select_streams&lt;/span&gt; v:0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;packet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pts_time,flags &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;csv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;print_section&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 1080p.m3u8 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'$2 ~ /K/ { print $1 }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Realistic output for both 1080p and 720p should match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0.000000
2.000000
4.000000
6.000000
8.000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If 720p shows &lt;code&gt;0, 2, 3.4, 5.4, ...&lt;/code&gt; instead, scene-cut detection leaked in. Re-check your flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Verify with MP4Box and wire it into CI
&lt;/h2&gt;

&lt;p&gt;GPAC's MP4Box has a keyframe-alignment check across renditions. Wrap it in a script that fails the build on misalignment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ci/check-alignment.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="c"&gt;# extract keyframe times for two rungs, diff them&lt;/span&gt;
kf &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-select_streams&lt;/span&gt; v:0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;packet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pts_time,flags &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;csv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;print_section&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'$2 ~ /K/ { printf "%.3f\n", $1 }'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;kf 1080p.m3u8&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;kf 720p.m3u8&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Keyframes aligned ✅"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Keyframes MISALIGNED ❌"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./ci/check-alignment.sh
Keyframes aligned ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: an encoder upgrade can silently change a default. A CI check is the only thing that stops a future ladder change from quietly reintroducing the hitch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  7. Audio is the other half of alignment
&lt;/h2&gt;

&lt;p&gt;Video keyframes are only half the story. If your segments are muxed (audio plus video together), the audio frame boundaries also have to fall on segment edges, or you reintroduce the same splice problem on the audio track. Two things bite here.&lt;/p&gt;

&lt;p&gt;First, AAC has encoder priming (a few hundred samples of silence at the start) that shifts the audio timeline relative to video. Second, audio frames are a fixed sample count (1024 samples for AAC-LC), so a segment duration that isn't a clean multiple of the audio frame duration will never line up perfectly.&lt;/p&gt;

&lt;p&gt;The robust fix is to keep audio in a separate rendition (demuxed) so the player can align it independently, which is also what most HLS ladders do anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# audio-only rendition, separate from video&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; source.mov &lt;span class="nt"&gt;-vn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 128k &lt;span class="nt"&gt;-ar&lt;/span&gt; 48000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_time&lt;/span&gt; 2 &lt;span class="nt"&gt;-hls_playlist_type&lt;/span&gt; vod &lt;span class="nt"&gt;-hls_segment_type&lt;/span&gt; fmp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-hls_segment_filename&lt;/span&gt; &lt;span class="s2"&gt;"audio_%03d.m4s"&lt;/span&gt; audio.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# confirm the audio segment count matches the video segment count&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'\.m4s'&lt;/span&gt; audio.m3u8 1080p.m3u8
audio.m3u8:31
1080p.m3u8:31
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: matching segment counts across audio and every video rung is a fast sanity check. A mismatch means a duration or framerate assumption is off somewhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The cost (be honest)
&lt;/h2&gt;

&lt;p&gt;Forcing keyframes and disabling scene-cut detection sacrifices a little compression efficiency, since you're inserting IDR frames the encoder would rather skip. For ABR delivery that trade is almost always worth it: a marginally bigger file that switches cleanly beats a smaller one that stutters every time the network dips.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Align audio segment duration with video so muxed segments don't drift; watch encoder/priming delay.&lt;/li&gt;
&lt;li&gt;If you run per-title / content-aware encoding, keep the forced keyframe cadence even when bitrates vary per title.&lt;/li&gt;
&lt;li&gt;Add the alignment check to your transcoding pipeline's test suite, not just local runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time a switch hitches and the dashboard says all-clear, dump the keyframe times for two rungs before you touch the CDN.&lt;/p&gt;

</description>
      <category>ffmpeg</category>
      <category>video</category>
      <category>tutorial</category>
      <category>devops</category>
    </item>
    <item>
      <title>Build resumable video uploads with tus, Node, and tus-js-client</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:20:27 +0000</pubDate>
      <link>https://dev.to/masonwritescode/build-resumable-video-uploads-with-tus-node-and-tus-js-client-2c62</link>
      <guid>https://dev.to/masonwritescode/build-resumable-video-uploads-with-tus-node-and-tus-js-client-2c62</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;A single multipart POST throws away the whole upload when the connection blinks. The tus protocol fixes that with HTTP-based resumable uploads. We'll stand up a Node &lt;code&gt;@tus/server&lt;/code&gt; endpoint, wire a &lt;code&gt;tus-js-client&lt;/code&gt; browser uploader, then kill the network mid-upload and watch it resume from the exact offset.&lt;/p&gt;

&lt;p&gt;📦 Code: github.com/USER/tus-video-upload (replace before publishing)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A tiny upload app: a Node server that accepts resumable uploads and stores them (disk first, then S3), and a browser page that uploads a large video file and survives an interrupted connection. By the end you'll understand the three HTTP requests that make tus work, and you'll have a demo that resumes instead of restarting.&lt;/p&gt;

&lt;p&gt;Versions: &lt;code&gt;@tus/server 1.x&lt;/code&gt;, &lt;code&gt;@tus/file-store&lt;/code&gt;, &lt;code&gt;tus-js-client 4.x&lt;/code&gt;, &lt;code&gt;Node 22.x&lt;/code&gt;. (tusd v2 exists in Go too; we'll use the Node server here.)&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The 30-second protocol primer
&lt;/h2&gt;

&lt;p&gt;tus is plain HTTP (built on RFC 9110), stable since v1.0. Three requests do everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create an upload, declare &lt;code&gt;Upload-Length&lt;/code&gt;, get back a URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PATCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send a chunk at a given &lt;code&gt;Upload-Offset&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HEAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ask "how many bytes do you already have?" before resuming&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code&gt;HEAD&lt;/code&gt; is the magic. After a drop, the client asks the server for the current offset and continues from there instead of guessing or restarting.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Stand up the Node server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;tus-video-upload &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;tus-video-upload
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @tus/server @tus/file-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tus/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FileStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tus/file-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tusServer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/files&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;datastore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileStore&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./uploads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="c1"&gt;// basic guardrails&lt;/span&gt;
  &lt;span class="na"&gt;maxSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 5 GB cap&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onUploadCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// hook for auth + validation; throw to reject&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;filetype&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;415&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Only video uploads allowed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onUploadFinish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`✅ upload complete: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/files&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tusServer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tus server on http://localhost:1080/files&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;node server.js
tus server on http://localhost:1080/files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: &lt;code&gt;onUploadCreate&lt;/code&gt; is where auth lives. Check a session token from &lt;code&gt;req.headers&lt;/code&gt; and throw to reject before a single byte is stored.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Wire the browser client
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;tus-js-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- index.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;accept=&lt;/span&gt;&lt;span class="s"&gt;"video/*"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;progress&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"bar"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;max=&lt;/span&gt;&lt;span class="s"&gt;"100"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/progress&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;pre&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"log"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/pre&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// upload.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;tus&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tus-js-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;tus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:1080/files&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;retryDelays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// auto-retry on failure&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filetype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`progress: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// resume a previous upload of this file if one exists&lt;/span&gt;
  &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findPreviousUploads&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumeFromPreviousUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;retryDelays&lt;/code&gt; array is what makes a transient drop invisible: tus-js-client backs off and retries automatically. &lt;code&gt;findPreviousUploads&lt;/code&gt; is what lets a page reload pick up where it left off.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Prove it resumes
&lt;/h2&gt;

&lt;p&gt;Start a big upload, then kill connectivity mid-flight and bring it back. You'll see it continue, not restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;progress: 0.0%
progress: 41.7%
progress: 62.3%
failed: Error: tus: failed to upload chunk ... &lt;span class="o"&gt;(&lt;/span&gt;network&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ... retryDelays kicks in ...&lt;/span&gt;
progress: 62.3%   &amp;lt;- HEAD asked the server&lt;span class="p"&gt;;&lt;/span&gt; resumed from the stored offset
progress: 88.9%
progress: 100.0%
&lt;span class="k"&gt;done&lt;/span&gt;: http://localhost:1080/files/9f3c...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can watch the underlying requests in the network tab: a &lt;code&gt;HEAD&lt;/code&gt; to the upload URL returns &lt;code&gt;Upload-Offset: &amp;lt;bytes already stored&amp;gt;&lt;/code&gt;, then &lt;code&gt;PATCH&lt;/code&gt; continues from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Move the store to S3 for production
&lt;/h2&gt;

&lt;p&gt;Disk is fine for a demo. In production you want durable, multi-instance storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @tus/s3-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// swap FileStore for S3Store&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Store&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tus/s3-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;datastore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Store&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;partSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 8 MB parts&lt;/span&gt;
  &lt;span class="na"&gt;s3ClientConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: resumable state must be durable. With &lt;code&gt;FileStore&lt;/code&gt;, a server restart with &lt;code&gt;./uploads&lt;/code&gt; on ephemeral disk loses partial uploads. S3 (or a shared volume) fixes that and lets multiple server instances share upload state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. Lock it down: auth and expiry
&lt;/h2&gt;

&lt;p&gt;An open upload endpoint is an open invitation to fill your storage with junk. The &lt;code&gt;onUploadCreate&lt;/code&gt; hook runs before any bytes land, so it's the right place to check a token and reject early.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in the Server({...}) config&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onUploadCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^Bearer&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifySession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your logic&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not authorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// stamp the owner onto the upload metadata for later&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass the token from the client through the &lt;code&gt;headers&lt;/code&gt; option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;tus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:1080/files&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...rest as before&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For cleanup, tus tracks an &lt;code&gt;Upload-Expires&lt;/code&gt; value. Abandoned partials (someone closed the tab at 40%) otherwise sit in your store forever. The &lt;code&gt;@tus/server&lt;/code&gt; exposes an expiration extension and a &lt;code&gt;cleanUpExpiredUploads()&lt;/code&gt; call you can run on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// cron.js: run periodically&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tusServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./server.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tusServer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cleanUpExpiredUploads&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`cleaned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; expired uploads`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: without expiry cleanup, a public upload form becomes a slow storage leak. Schedule the cleanup before you launch, not after the bill arrives.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  7. Things to know before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Set &lt;code&gt;maxSize&lt;/code&gt; and validate &lt;code&gt;filetype&lt;/code&gt; in &lt;code&gt;onUploadCreate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Handle &lt;code&gt;Upload-Expires&lt;/code&gt; and garbage-collect abandoned partials.&lt;/li&gt;
&lt;li&gt;[ ] Put the tus route behind auth (token in headers, checked in the create hook).&lt;/li&gt;
&lt;li&gt;[ ] Pin versions: the Go reference server &lt;code&gt;tusd&lt;/code&gt; moved to &lt;strong&gt;v2&lt;/strong&gt; with breaking changes vs 1.13.0; the Node packages version independently.&lt;/li&gt;
&lt;li&gt;[ ] Several managed platforms (Cloudflare Stream, Vimeo, Supabase) expose tus endpoints, so this same client code largely ports to them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add &lt;a href="https://uppy.io/docs/tus/" rel="noopener noreferrer"&gt;Uppy&lt;/a&gt; on top of tus-js-client for a drag-and-drop UI with progress and retry built in.&lt;/li&gt;
&lt;li&gt;Trigger your transcoding pipeline from &lt;code&gt;onUploadFinish&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read the tus 1.0 spec; the protocol is small enough to read in one sitting and it'll demystify the network tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next time you build an upload box for anything large, don't reach for a single POST. Reach for the protocol that already solved the dropped-connection problem.&lt;/p&gt;

</description>
      <category>video</category>
      <category>node</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Ship multi-language audio in HLS: author the manifest, wire the hls.js switcher</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 06 Jul 2026 06:42:48 +0000</pubDate>
      <link>https://dev.to/masonwritescode/ship-multi-language-audio-in-hls-author-the-manifest-wire-the-hlsjs-switcher-c7</link>
      <guid>https://dev.to/masonwritescode/ship-multi-language-audio-in-hls-author-the-manifest-wire-the-hlsjs-switcher-c7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📦 Code: github.com/USER/hls-multi-audio - replace before publishing&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We'll add a working language picker to an HLS player. The hard part isn't the dropdown, it's the manifest. We'll author alternate audio with EXT-X-MEDIA audio groups, package it correctly, debug the classic "zero audio tracks" bug, and wire a switcher on &lt;strong&gt;hls.js v1.7&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Adaptive video, captions, the whole pipeline already works. Now someone wants an English/Spanish audio toggle. In HLS, "which audio can the viewer pick" is decided at packaging time and written into the master playlist. The player just displays it. Let's build it in that order.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Understand the structure (audio groups)
&lt;/h2&gt;

&lt;p&gt;HLS decouples video variants from audio renditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each audio rendition is an &lt;code&gt;#EXT-X-MEDIA:TYPE=AUDIO&lt;/code&gt; entry pointing at its own media playlist.&lt;/li&gt;
&lt;li&gt;Renditions are bundled into a named &lt;strong&gt;audio group&lt;/strong&gt; via &lt;code&gt;GROUP-ID&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each video variant (&lt;code&gt;#EXT-X-STREAM-INF&lt;/code&gt;) references a group with &lt;code&gt;AUDIO="..."&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A correct master playlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="audio/en.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Espanol",LANGUAGE="es",DEFAULT=NO,AUTOSELECT=YES,CHANNELS="2",URI="audio/es.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=2128000,CODECS="avc1.640028,mp4a.40.2",AUDIO="aud"
video/720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1128000,CODECS="avc1.640020,mp4a.40.2",AUDIO="aud"
video/480p.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every attribute earns its place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LANGUAGE&lt;/code&gt; - BCP-47 code, used for the label.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DEFAULT&lt;/code&gt; - plays when the viewer has no preference.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AUTOSELECT&lt;/code&gt; - may be auto-picked from the OS language.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CHANNELS&lt;/code&gt; - needed so the player can reason about stereo vs surround.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BANDWIDTH&lt;/code&gt; on each video variant &lt;strong&gt;must include the audio group's bitrate&lt;/strong&gt;, or your ABR logic works from a wrong total.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Author the renditions with FFmpeg
&lt;/h2&gt;

&lt;p&gt;Extract/encode each language's audio, then package. First, encode video-only and audio-only renditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# video only (no audio), two ladder rungs&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; master_en.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-b&lt;/span&gt;:v 2000k &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-2&lt;/span&gt;:720 video_720.mp4
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; master_en.mov &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-b&lt;/span&gt;:v 1000k &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-2&lt;/span&gt;:480 video_480.mp4

&lt;span class="c"&gt;# audio only, per language (AAC stereo, aligned settings)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; master_en.mov &lt;span class="nt"&gt;-vn&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 128k &lt;span class="nt"&gt;-ac&lt;/span&gt; 2 audio_en.m4a
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; dub_es.mov    &lt;span class="nt"&gt;-vn&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 128k &lt;span class="nt"&gt;-ac&lt;/span&gt; 2 audio_es.m4a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: keep the &lt;strong&gt;same segment duration&lt;/strong&gt; across video and every audio rendition. Misaligned boundaries cause gaps and slow desync that won't show up in a 10-second test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then segment with a packager that emits grouped audio. Bento4's &lt;code&gt;mp4-dash&lt;/code&gt;/&lt;code&gt;mp4hls&lt;/code&gt; or Shaka Packager handle this cleanly. Example with Shaka Packager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;packager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;video_720.mp4,stream&lt;span class="o"&gt;=&lt;/span&gt;video,init_segment&lt;span class="o"&gt;=&lt;/span&gt;v720/init.mp4,segment_template&lt;span class="o"&gt;=&lt;/span&gt;v720/&lt;span class="nv"&gt;$Number$.&lt;/span&gt;m4s,playlist_name&lt;span class="o"&gt;=&lt;/span&gt;video/720p.m3u8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;video_480.mp4,stream&lt;span class="o"&gt;=&lt;/span&gt;video,init_segment&lt;span class="o"&gt;=&lt;/span&gt;v480/init.mp4,segment_template&lt;span class="o"&gt;=&lt;/span&gt;v480/&lt;span class="nv"&gt;$Number$.&lt;/span&gt;m4s,playlist_name&lt;span class="o"&gt;=&lt;/span&gt;video/480p.m3u8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;audio_en.m4a,stream&lt;span class="o"&gt;=&lt;/span&gt;audio,hls_group_id&lt;span class="o"&gt;=&lt;/span&gt;aud,hls_name&lt;span class="o"&gt;=&lt;/span&gt;English,language&lt;span class="o"&gt;=&lt;/span&gt;en,init_segment&lt;span class="o"&gt;=&lt;/span&gt;aen/init.mp4,segment_template&lt;span class="o"&gt;=&lt;/span&gt;aen/&lt;span class="nv"&gt;$Number$.&lt;/span&gt;m4s,playlist_name&lt;span class="o"&gt;=&lt;/span&gt;audio/en.m3u8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;audio_es.m4a,stream&lt;span class="o"&gt;=&lt;/span&gt;audio,hls_group_id&lt;span class="o"&gt;=&lt;/span&gt;aud,hls_name&lt;span class="o"&gt;=&lt;/span&gt;Espanol,language&lt;span class="o"&gt;=&lt;/span&gt;es,init_segment&lt;span class="o"&gt;=&lt;/span&gt;aes/init.mp4,segment_template&lt;span class="o"&gt;=&lt;/span&gt;aes/&lt;span class="nv"&gt;$Number$.&lt;/span&gt;m4s,playlist_name&lt;span class="o"&gt;=&lt;/span&gt;audio/es.m3u8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hls_master_playlist_output&lt;/span&gt; master.m3u8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--segment_duration&lt;/span&gt; 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Debug the "zero audio tracks" bug
&lt;/h2&gt;

&lt;p&gt;The most common failure: player loads, video plays with sound, but the switcher is empty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hls.audioTracks → &lt;span class="o"&gt;[]&lt;/span&gt;   &lt;span class="c"&gt;# 😞&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is almost always the manifest, not the player. Open &lt;code&gt;master.m3u8&lt;/code&gt; and check, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are there &lt;code&gt;#EXT-X-MEDIA:TYPE=AUDIO&lt;/code&gt; lines at all? (Packager misconfig drops them.)&lt;/li&gt;
&lt;li&gt;Does every &lt;code&gt;#EXT-X-STREAM-INF&lt;/code&gt; have &lt;code&gt;AUDIO="aud"&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Do the &lt;code&gt;GROUP-ID&lt;/code&gt; and the variant's &lt;code&gt;AUDIO&lt;/code&gt; value match &lt;strong&gt;exactly&lt;/strong&gt;, including case?&lt;/li&gt;
&lt;li&gt;Is the version sane (&lt;code&gt;#EXT-X-VERSION:6&lt;/code&gt; for grouped audio)?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fix the manifest, and the tracks appear. You almost never fix this in JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Wire the switcher (hls.js v1.7)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// player.js - hls.js 1.7.x&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Hls&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hls.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;select&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#audio-picker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSupported&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/stream/master.m3u8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUDIO_TRACKS_UPDATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;select&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;audioTracks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;opt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;option&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`Track &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;select&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUDIO_TRACK_SWITCHED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;now playing audio track&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;select&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;audioTrack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// triggers the switch&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.apple.mpegurl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Safari / native HLS: the OS audio menu is driven by EXT-X-MEDIA. No JS needed.&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/stream/master.m3u8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole player side. On Safari you write zero switching code, the native menu reads your EXT-X-MEDIA tags directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Same segment duration across video and all audio renditions.&lt;/li&gt;
&lt;li&gt;✅ One codec per audio group. Mixing AAC + EC-3? Use &lt;strong&gt;separate&lt;/strong&gt; groups + matching variants.&lt;/li&gt;
&lt;li&gt;✅ Video &lt;code&gt;BANDWIDTH&lt;/code&gt; includes audio bitrate.&lt;/li&gt;
&lt;li&gt;✅ &lt;code&gt;LANGUAGE&lt;/code&gt; is BCP-47 (&lt;code&gt;en&lt;/code&gt;, &lt;code&gt;es&lt;/code&gt;, &lt;code&gt;pt-BR&lt;/code&gt;), not freeform.&lt;/li&gt;
&lt;li&gt;✅ Exactly one &lt;code&gt;DEFAULT=YES&lt;/code&gt; per group.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Validate the manifest in CI
&lt;/h2&gt;

&lt;p&gt;The "zero audio tracks" bug ships when a packaging change silently drops a group reference and no human reads the manifest. Catch it with a tiny parser in CI so a bad master playlist fails the build instead of the player.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// validate-audio-groups.mjs - node 20+&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;master&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;master&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#EXT-X-MEDIA:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TYPE=AUDIO&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/GROUP-ID="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#EXT-X-STREAM-INF:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/AUDIO="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`variant missing AUDIO=: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`AUDIO="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;" has no matching group`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Audio group validation failed:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`OK: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; audio group(s), all variants reference a real group`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node validate-audio-groups.mjs dist/master.m3u8
&lt;span class="c"&gt;# OK: 1 audio group(s), all variants reference a real group&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on every packaged output. It is twenty lines and it prevents the single most common multi-audio outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. fMP4 vs TS, and default-track behavior
&lt;/h2&gt;

&lt;p&gt;Two things that trip people once the basics work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container:&lt;/strong&gt; prefer &lt;strong&gt;fMP4&lt;/strong&gt; (CMAF) audio segments over legacy MPEG-TS. fMP4 plays cleaner across MSE browsers, is required for some codecs, and lets you share segments with a DASH manifest later. The Shaka command above already emits fMP4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default selection across browsers:&lt;/strong&gt; &lt;code&gt;DEFAULT=YES&lt;/code&gt; plus &lt;code&gt;AUTOSELECT=YES&lt;/code&gt; is what most players honor, but Safari weighs the OS language against &lt;code&gt;AUTOSELECT&lt;/code&gt; tracks first. If a Spanish-locale device keeps starting on Spanish even when you expect English, that is &lt;code&gt;AUTOSELECT&lt;/code&gt; doing its job. Set &lt;code&gt;AUTOSELECT=NO&lt;/code&gt; on tracks you never want auto-picked.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Tip: persist the viewer's last chosen track (in app state, not the manifest) and re-apply it on the next load by setting &lt;code&gt;hls.audioTrack&lt;/code&gt; after &lt;code&gt;MANIFEST_PARSED&lt;/code&gt;. HLS has no concept of "remember my language"; that is your job.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  7. Troubleshooting table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;audioTracks&lt;/code&gt; is empty&lt;/td&gt;
&lt;td&gt;Variant missing &lt;code&gt;AUDIO=&lt;/code&gt; or group-id mismatch&lt;/td&gt;
&lt;td&gt;Re-read master playlist; run the CI validator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio drifts out of sync after minutes&lt;/td&gt;
&lt;td&gt;Audio/video segment durations differ&lt;/td&gt;
&lt;td&gt;Re-segment all renditions with one &lt;code&gt;--segment_duration&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switch works but audio cuts out briefly&lt;/td&gt;
&lt;td&gt;Init segment reload on switch&lt;/td&gt;
&lt;td&gt;Update to hls.js 1.7+ (smoother audio-track switching)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Track shows but won't play in one browser&lt;/td&gt;
&lt;td&gt;Mixed codecs in one group&lt;/td&gt;
&lt;td&gt;Split codecs into separate groups + matching variants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong default language on some devices&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AUTOSELECT=YES&lt;/code&gt; + OS locale&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;AUTOSELECT=NO&lt;/code&gt; on non-default tracks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add a surround track as a second group (&lt;code&gt;hls_group_id=aud-surround&lt;/code&gt;, &lt;code&gt;CHANNELS="6"&lt;/code&gt;) and reference it from higher-bitrate variants.&lt;/li&gt;
&lt;li&gt;Validate manifests in CI with a parser so a packaging change can't ship an empty audio list.&lt;/li&gt;
&lt;li&gt;Layer WebVTT subtitle groups the same way (&lt;code&gt;TYPE=SUBTITLES&lt;/code&gt;), the grouping model is identical.&lt;/li&gt;
&lt;li&gt;Bump &lt;code&gt;#EXT-X-VERSION&lt;/code&gt; to at least 6 once you use fMP4 segments; older version numbers with modern features are a common source of "plays in Safari, breaks in hls.js" reports.&lt;/li&gt;
&lt;li&gt;If you serve the same content as DASH too, generate the audio &lt;code&gt;AdaptationSet&lt;/code&gt;s from the same fMP4 segments so you maintain one set of media files for both manifests.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>video</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Benchmark NVENC vs CPU transcoding (and find your real break-even) with FFmpeg</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 06 Jul 2026 06:42:30 +0000</pubDate>
      <link>https://dev.to/masonwritescode/benchmark-nvenc-vs-cpu-transcoding-and-find-your-real-break-even-with-ffmpeg-5gg5</link>
      <guid>https://dev.to/masonwritescode/benchmark-nvenc-vs-cpu-transcoding-and-find-your-real-break-even-with-ffmpeg-5gg5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📦 Code: github.com/USER/nvenc-vs-cpu-bench - replace before publishing&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;A GPU encodes faster than a CPU, but "faster" and "cheaper" are different claims. We'll build a small FFmpeg + VMAF harness that times software (libx264/SVT-AV1) against hardware (h264_nvenc/av1_nvenc), then plug the results into a dollars-per-encoded-minute formula so you find &lt;em&gt;your&lt;/em&gt; break-even instead of trusting a benchmark blog.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We're using &lt;strong&gt;FFmpeg 7.1.x&lt;/strong&gt; (current stable line) and an NVIDIA GPU with NVENC. Same approach works for Intel QSV (&lt;code&gt;*_qsv&lt;/code&gt;) and AMD AMF (&lt;code&gt;*_amf&lt;/code&gt;) if you swap the encoder names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't obvious
&lt;/h2&gt;

&lt;p&gt;NVENC is a fixed-function hardware block, not "the GPU doing x264 in parallel." It's extremely fast and barely touches the CPU, but it exposes fewer rate-control knobs and gives up a little compression efficiency versus a slow software preset. The gap has narrowed a lot, but it's still there at the quality-obsessed end.&lt;/p&gt;

&lt;p&gt;So the decision is per-job, and it comes down to one number: &lt;strong&gt;dollars per encoded minute = (instance $/hr) ÷ (minutes encoded/hr)&lt;/strong&gt;. GPU instances cost more per hour but encode many streams in parallel, so the answer depends on whether you can keep the encoder saturated.&lt;/p&gt;

&lt;p&gt;Let's measure instead of argue.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Set up the encoders
&lt;/h2&gt;

&lt;p&gt;Three contenders. One representative source file (use real footage, not a synthetic clip).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# software H.264, quality-leaning preset&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; slow &lt;span class="nt"&gt;-crf&lt;/span&gt; 21 &lt;span class="nt"&gt;-an&lt;/span&gt; out_cpu.mp4

&lt;span class="c"&gt;# NVENC H.264, quality-tuned&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v h264_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p6 &lt;span class="nt"&gt;-tune&lt;/span&gt; hq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-rc&lt;/span&gt; vbr &lt;span class="nt"&gt;-cq&lt;/span&gt; 23 &lt;span class="nt"&gt;-an&lt;/span&gt; out_gpu.mp4

&lt;span class="c"&gt;# AV1: software (SVT-AV1) vs hardware (needs Ada / RTX 40+)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libsvtav1 &lt;span class="nt"&gt;-preset&lt;/span&gt; 6 &lt;span class="nt"&gt;-crf&lt;/span&gt; 30 &lt;span class="nt"&gt;-an&lt;/span&gt; out_svtav1.mp4
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v av1_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p5 &lt;span class="nt"&gt;-cq&lt;/span&gt; 30 &lt;span class="nt"&gt;-an&lt;/span&gt; out_av1nvenc.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: &lt;code&gt;-preset p1&lt;/code&gt; (fastest) through &lt;code&gt;-preset p7&lt;/code&gt; (slowest/highest quality) for NVENC. &lt;code&gt;p6&lt;/code&gt;/&lt;code&gt;p7&lt;/code&gt; is where it competes on quality; &lt;code&gt;p1&lt;/code&gt;-&lt;code&gt;p3&lt;/code&gt; is where it competes on raw throughput.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If &lt;code&gt;av1_nvenc&lt;/code&gt; errors with &lt;code&gt;Cannot load nvEncodeAPI&lt;/code&gt; or "encoder not found," your GPU is pre-Ada and doesn't have the AV1 block. That's fine, drop it from the comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Time each encode
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# bench.sh - time an encode and print seconds&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift
&lt;/span&gt;&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%s&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;%.2fs&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | bc&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./bench.sh cpu      ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; slow &lt;span class="nt"&gt;-crf&lt;/span&gt; 21 &lt;span class="nt"&gt;-an&lt;/span&gt; out_cpu.mp4
./bench.sh nvenc    ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v h264_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p6 &lt;span class="nt"&gt;-tune&lt;/span&gt; hq &lt;span class="nt"&gt;-rc&lt;/span&gt; vbr &lt;span class="nt"&gt;-cq&lt;/span&gt; 23 &lt;span class="nt"&gt;-an&lt;/span&gt; out_gpu.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Realistic output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cpu     63.40s
nvenc   18.10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Faster, clearly. But don't migrate yet, we haven't checked quality or cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Measure quality with VMAF (not bitrate)
&lt;/h2&gt;

&lt;p&gt;Bitrate alone tells you nothing about whether the picture held up. Use &lt;code&gt;libvmaf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; out_gpu.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-lavfi&lt;/span&gt; &lt;span class="s2"&gt;"[0:v]setpts=PTS-STARTPTS[dist];[1:v]setpts=PTS-STARTPTS[ref];[dist][ref]libvmaf=log_fmt=json:log_path=vmaf_gpu.json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; null -

python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json;print('VMAF', json.load(open('vmaf_gpu.json'))['pooled_metrics']['vmaf']['mean'])"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it for each output. You'll typically see software at a slow preset edge out NVENC by a few VMAF points at a similar file size, and the two converge as bitrate climbs. A few points of VMAF may not matter for streaming; it absolutely matters for an archival master.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The break-even math
&lt;/h2&gt;

&lt;p&gt;Now put it together. Say:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU instance: $0.20/hr, encodes 1 stream → ~57 min/hr of this content.&lt;/li&gt;
&lt;li&gt;GPU instance: $0.55/hr, encodes ~8 of these streams in parallel → ~456 min/hr.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# breakeven.py
&lt;/span&gt;&lt;span class="n"&gt;cpu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost_per_hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minutes_per_hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;gpu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost_per_hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minutes_per_hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;456&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cpu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$/encoded-min =&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost_per_hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minutes_per_hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cpu &lt;span class="nv"&gt;$/&lt;/span&gt;encoded-min &lt;span class="o"&gt;=&lt;/span&gt; 0.00351
gpu &lt;span class="nv"&gt;$/&lt;/span&gt;encoded-min &lt;span class="o"&gt;=&lt;/span&gt; 0.00121
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GPU wins here, but only because it's saturated (8 parallel streams). Drop &lt;code&gt;minutes_per_hr&lt;/code&gt; to 57 (one stream, idle GPU) and the GPU becomes &lt;strong&gt;more&lt;/strong&gt; expensive than the CPU. That's the whole lesson: the GPU is cheaper per minute only when you actually feed it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: these instance numbers are placeholders. Plug in your cloud's real prices and your measured parallel-stream count. The break-even is yours, not mine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Saturate the GPU (or the math lies)
&lt;/h2&gt;

&lt;p&gt;The break-even above assumed 8 parallel streams. That number is not free; you have to actually run encodes in parallel to hit it, because one &lt;code&gt;ffmpeg&lt;/code&gt; process will not max out a modern encoder block on its own. Measure your real parallel ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# parallel-throughput.sh - how many concurrent NVENC encodes before it degrades&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;N&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;8&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$N&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v h264_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p5 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-rc&lt;/span&gt; vbr &lt;span class="nt"&gt;-cq&lt;/span&gt; 23 &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="s2"&gt;"out_&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;.mp4"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;wait
&lt;/span&gt;&lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"encoded &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;N&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; streams in &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | bc&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./parallel-throughput.sh 8
&lt;span class="c"&gt;# encoded 8 streams in 22.40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch &lt;code&gt;nvidia-smi&lt;/code&gt; while it runs; the &lt;code&gt;Enc&lt;/code&gt; utilization column tells you whether you are actually feeding the block or just queuing. If utilization plateaus below 100% at, say, 6 streams, then 6, not 8, is your real divisor in the cost formula. Consumer cards also cap concurrent NVENC sessions in the driver, so a datacenter card may be required to reach high parallelism at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Rate control matters more than preset
&lt;/h2&gt;

&lt;p&gt;For a transcoding pipeline you usually want consistent quality at a predictable size, which means the rate-control mode is a bigger lever than the preset:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;NVENC flag&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Constant quality&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-rc vbr -cq N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VOD, you want stable quality, variable size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capped CRF-like&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-rc vbr -cq N -maxrate M -bufsize 2M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ABR ladder rungs with a bitrate ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constant bitrate&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-rc cbr -b:v M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live, fixed-bandwidth delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ABR rung: quality target with a hard ceiling so the rung stays in its lane&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v h264_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p6 &lt;span class="nt"&gt;-tune&lt;/span&gt; hq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-rc&lt;/span&gt; vbr &lt;span class="nt"&gt;-cq&lt;/span&gt; 23 &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 4M &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 8M &lt;span class="nt"&gt;-an&lt;/span&gt; rung_1080.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: NVENC's &lt;code&gt;-cq&lt;/code&gt; is not the same scale as x264's &lt;code&gt;-crf&lt;/code&gt;. Do not copy a CRF value across; pick &lt;code&gt;-cq&lt;/code&gt; by measuring VMAF on your own footage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two more flags claw back most of the remaining quality gap with software, at almost no throughput cost: enable B-frames and a lookahead window so the encoder can plan bit allocation across upcoming frames.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-hwaccel&lt;/span&gt; cuda &lt;span class="nt"&gt;-i&lt;/span&gt; source.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v h264_nvenc &lt;span class="nt"&gt;-preset&lt;/span&gt; p6 &lt;span class="nt"&gt;-tune&lt;/span&gt; hq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-rc&lt;/span&gt; vbr &lt;span class="nt"&gt;-cq&lt;/span&gt; 23 &lt;span class="nt"&gt;-bf&lt;/span&gt; 3 &lt;span class="nt"&gt;-rc-lookahead&lt;/span&gt; 20 &lt;span class="nt"&gt;-spatial-aq&lt;/span&gt; 1 &lt;span class="nt"&gt;-an&lt;/span&gt; out_tuned.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-run your VMAF check after adding these; on most content they lift the score a point or two for free, which can be the difference that makes NVENC "good enough" for your quality bar.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to pick which
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High-volume / near-real-time / live&lt;/td&gt;
&lt;td&gt;NVENC&lt;/td&gt;
&lt;td&gt;Throughput dominates; keeps the block saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large catalog, steady upload firehose&lt;/td&gt;
&lt;td&gt;NVENC&lt;/td&gt;
&lt;td&gt;Cost-per-minute wins when utilized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archival / mastering ladders&lt;/td&gt;
&lt;td&gt;Software (x264/x265)&lt;/td&gt;
&lt;td&gt;Quality-per-bit compounds forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AV1 for storage savings&lt;/td&gt;
&lt;td&gt;SVT-AV1 (software)&lt;/td&gt;
&lt;td&gt;Currently beats av1_nvenc on efficiency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spiky, low-volume jobs&lt;/td&gt;
&lt;td&gt;Software&lt;/td&gt;
&lt;td&gt;Idle GPU is wasted money&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wire the timing + VMAF steps into CI so a preset change can't silently tank quality or throughput.&lt;/li&gt;
&lt;li&gt;Try QSV (&lt;code&gt;h264_qsv&lt;/code&gt;, &lt;code&gt;av1_qsv&lt;/code&gt; on Arc) if you're on Intel, the same harness works.&lt;/li&gt;
&lt;li&gt;Measure with footage that matches your real catalog; grain, motion, and resolution all move the numbers.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ffmpeg</category>
      <category>video</category>
      <category>tutorial</category>
      <category>devops</category>
    </item>
    <item>
      <title>5 video APIs compared on what's included before you pay extra (2026)</title>
      <dc:creator>Mason K</dc:creator>
      <pubDate>Mon, 06 Jul 2026 06:42:07 +0000</pubDate>
      <link>https://dev.to/masonwritescode/5-video-apis-compared-on-whats-included-before-you-pay-extra-2026-21c2</link>
      <guid>https://dev.to/masonwritescode/5-video-apis-compared-on-whats-included-before-you-pay-extra-2026-21c2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📦 Code: github.com/USER/video-api-bench - replace before publishing&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;The per-minute delivery rate is the easiest number to compare and the least useful. The real cost lives in encoding, analytics, and the player. This post compares Mux, Cloudflare Stream, api.video, FastPix, and AWS on what each includes by default, then gives you a tiny script to benchmark upload and time-to-ready on your own files so you stop trusting marketing pages.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have shipped video on four managed APIs across three jobs, and every single time the invoice surprised someone. Not because the delivery rate was wrong, but because encoding, analytics, and the player turned out to be separate line items on some platforms and free on others. Let's compare the parts that don't show up in the headline number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: pricing pages move. Everything here was checked in June 2026; verify the links before quoting numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Encoding: free or metered?
&lt;/h2&gt;

&lt;p&gt;This is the widest spread in the whole comparison.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;th&gt;Delivery&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Stream&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$1 / 1,000 min delivered&lt;/td&gt;
&lt;td&gt;$5 / 1,000 min stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;api.video&lt;/td&gt;
&lt;td&gt;Free (unlimited)&lt;/td&gt;
&lt;td&gt;$0.0017 / min&lt;/td&gt;
&lt;td&gt;$0.00285 / min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastPix&lt;/td&gt;
&lt;td&gt;Free on standard plan&lt;/td&gt;
&lt;td&gt;~$0.00096 / min @1080p&lt;/td&gt;
&lt;td&gt;Per-minute, tiered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mux&lt;/td&gt;
&lt;td&gt;Metered per minute&lt;/td&gt;
&lt;td&gt;Per minute&lt;/td&gt;
&lt;td&gt;Per minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS (DIY)&lt;/td&gt;
&lt;td&gt;Per minute (MediaConvert)&lt;/td&gt;
&lt;td&gt;Per GB (CloudFront)&lt;/td&gt;
&lt;td&gt;Per GB (S3)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your catalog is upload-heavy (lots of assets encoded once, watched rarely), metered encoding is not a rounding error. It can flip which platform is cheapest, even when the delivery rates look identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Analytics: included or a $499 floor?
&lt;/h2&gt;

&lt;p&gt;QoE analytics is the feature teams forget to price until playback breaks in production.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;QoE analytics&lt;/th&gt;
&lt;th&gt;Entry cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FastPix (Video Data)&lt;/td&gt;
&lt;td&gt;Session-level, 50+ signals/session&lt;/td&gt;
&lt;td&gt;Free up to 100K views/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mux (Mux Data)&lt;/td&gt;
&lt;td&gt;Mature, broad device SDKs&lt;/td&gt;
&lt;td&gt;$499/month (Media plan, 1M views, +$0.50/1K)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Stream&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Included, limited depth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;api.video&lt;/td&gt;
&lt;td&gt;Available&lt;/td&gt;
&lt;td&gt;Usage-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;Build it yourself (CloudWatch + logs)&lt;/td&gt;
&lt;td&gt;Engineering time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Honest call: Mux Data has broader data-SDK coverage (Roku, smart TVs, and more). If your playback lives on ten device types, that breadth is worth paying for. If it's web + mobile and you want diagnostics without a monthly floor, the free-up-to-100K option wins on cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The player
&lt;/h2&gt;

&lt;p&gt;Not every "video API" ships a player. AWS does not. The rest mostly do.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastPix:&lt;/strong&gt; programmable player for web, iOS, Android, included, not separately licensed. Pipes telemetry into Video Data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mux:&lt;/strong&gt; Mux Player included with streaming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare:&lt;/strong&gt; Stream Player included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS:&lt;/strong&gt; bring your own, instrument it yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing to check isn't whether a player exists. It's whether using it locks your analytics to that vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Benchmark your own files (don't trust the table)
&lt;/h2&gt;

&lt;p&gt;Speed depends on your media. Here's a minimal harness to measure upload + time-to-ready yourself instead of believing anyone's published numbers, including mine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# measure-upload.sh - time a direct upload to any signed URL&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;          &lt;span class="c"&gt;# e.g. sample.mp4&lt;/span&gt;
&lt;span class="nv"&gt;UPLOAD_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="c"&gt;# signed/direct upload URL from your provider&lt;/span&gt;

&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="nt"&gt;--upload-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UPLOAD_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: video/mp4"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null
&lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"upload_seconds=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | bc&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then poll for readiness so you get an apples-to-apples time-to-ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// poll-ready.js - node 20+, measures time from "processing" to "ready"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statusUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statusUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// normalize per provider's shape&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`time_to_ready_s=&lt;/span&gt;&lt;span class="p"&gt;${((&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;errored&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same file against two providers, throttle your network (Chrome DevTools or &lt;code&gt;tc&lt;/code&gt;) to simulate real users, and you'll get numbers that mean something for &lt;em&gt;your&lt;/em&gt; app.&lt;/p&gt;

&lt;p&gt;For reference, a public benchmark suite measured a 177.2 MB file over 4G and FastPix uploaded in 15.2s vs Mux's 47.7s, with time-to-ready 29.4s vs 53.3s. But on a smaller 64.9 MB file, Cloudinary won the whole test and FastPix placed fifth. Different file sizes, different winners. That's exactly why you benchmark your own media.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Record results in a format you can defend
&lt;/h2&gt;

&lt;p&gt;Run the harness, then drop the numbers into a table you can hand to whoever signs off. Measuring three things per provider, on the same file and the same throttled network, is enough to make a real decision:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Upload (s)&lt;/th&gt;
&lt;th&gt;Time-to-ready (s)&lt;/th&gt;
&lt;th&gt;Cold start (ms)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fill it in yourself. The point of the table is not the absolute numbers, which depend on your network and file, but that every row used identical conditions, so the comparison is honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The line item nobody benchmarks: getting out
&lt;/h2&gt;

&lt;p&gt;Lock-in is a cost too, and it never shows up in a pricing table. Before you commit, check two things. First, can you export your originals, or only the transcoded renditions? If a provider only hands back the encoded outputs, re-platforming later means re-encoding from lossy sources. Second, is there a migration path in? Some platforms ship a batch migration tool that pulls a library in from another provider; others leave you to script transfers by hand, which is a project of its own.&lt;/p&gt;

&lt;p&gt;The same coupling applies to analytics. If your playback telemetry is wired to a vendor's player SDK, switching players means losing historical QoE continuity. None of this should necessarily change your pick, but it should be a row in your spreadsheet next to price, because the cheapest platform to adopt is not always the cheapest one to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Early-stage, analytics matters, larger files:&lt;/strong&gt; FastPix first. The Startup Program ($600 in credits for teams under 4 years and under $10M raised, more for YC/VC-backed) makes the trial nearly free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already on Cloudflare:&lt;/strong&gt; Stream. Flat per-minute, free encoding, one less vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise control, you have the DevOps:&lt;/strong&gt; AWS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Just want free encoding and clean PAYG:&lt;/strong&gt; api.video.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat across all of them: these are API-first, not no-code CMSes. If your team wants drag-and-drop with zero integration work, you'll be building a front-end on top of any of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wire the benchmark harness into CI so a regression in upload time shows up before your users notice.&lt;/li&gt;
&lt;li&gt;Read the pricing pages and the docs side by side: &lt;a href="https://www.fastpix.io/pricing/video-live-stream" rel="noopener noreferrer"&gt;FastPix&lt;/a&gt;, &lt;a href="https://www.mux.com/pricing" rel="noopener noreferrer"&gt;Mux&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/stream/pricing/" rel="noopener noreferrer"&gt;Cloudflare Stream&lt;/a&gt;, &lt;a href="https://api.video/pricing/" rel="noopener noreferrer"&gt;api.video&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The same upload + poll-for-ready pattern works against every provider here, so your harness is portable if you switch.&lt;/li&gt;
&lt;li&gt;Most of these offer free credits to start (FastPix gives $25 on signup, more through its Startup Program), so you can run the benchmark on a real account before you commit a card to anyone.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>video</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
