<?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: MartinDelophy</title>
    <description>The latest articles on DEV Community by MartinDelophy (@martindelophy).</description>
    <link>https://dev.to/martindelophy</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%2F4029567%2Fc8d453a4-d3ea-4275-b7ce-fbece2440c21.png</url>
      <title>DEV Community: MartinDelophy</title>
      <link>https://dev.to/martindelophy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martindelophy"/>
    <language>en</language>
    <item>
      <title>I Built an AI Skill That Turns a URL or Raw Footage Into an Editable Video Project</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Wed, 05 Aug 2026 11:56:32 +0000</pubDate>
      <link>https://dev.to/martindelophy/i-built-an-ai-skill-that-turns-a-url-or-raw-footage-into-an-editable-video-project-13oa</link>
      <guid>https://dev.to/martindelophy/i-built-an-ai-skill-that-turns-a-url-or-raw-footage-into-an-editable-video-project-13oa</guid>
      <description>&lt;p&gt;AI video tools are getting better at producing output, but many of them still have the same limitation: they give you a finished MP4 and very little control over what happens next.&lt;/p&gt;

&lt;p&gt;If one subtitle is too fast, one voiceover sentence sounds wrong, or one product shot needs a longer pause, the easiest option is often to regenerate the whole video.&lt;/p&gt;

&lt;p&gt;I wanted a different workflow.&lt;/p&gt;

&lt;p&gt;I recently shipped a major upgrade to &lt;strong&gt;Timeline Studio&lt;/strong&gt;, an open-source, browser-based AI video editor. The new workflow can analyze a website, images, or raw footage, create a first cut, and export both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a finished video ready to publish&lt;/li&gt;
&lt;li&gt;an editable &lt;code&gt;.timeline&lt;/code&gt; project for future revisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea is simple: let AI handle the time-consuming first draft, while the creator keeps control of the final edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Skill can do
&lt;/h2&gt;

&lt;p&gt;After installing the Timeline Studio Skill, you can describe the result you want in natural language.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Analyze this website and create a product introduction video.
Use a professional and energetic English female voice.
Target roughly 60 seconds in 16:9.
If login is required, ask me before continuing.
Export the final video and an editable .timeline project.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Skill can then plan the content, create a narration, generate synchronized subtitles, select scenes, add background music, and apply visual emphasis.&lt;/p&gt;

&lt;p&gt;It supports several editing scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;talking-head and voice-driven videos&lt;/li&gt;
&lt;li&gt;website and SaaS walkthroughs&lt;/li&gt;
&lt;li&gt;product or brand promotion&lt;/li&gt;
&lt;li&gt;image-based videos&lt;/li&gt;
&lt;li&gt;long-form content condensation&lt;/li&gt;
&lt;li&gt;tutorials&lt;/li&gt;
&lt;li&gt;highlights and short-form clips&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The editing strategy changes with the content instead of applying the same template to every project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Website promotion is more than screen recording
&lt;/h2&gt;

&lt;p&gt;For a website introduction, recording a browser session is not enough.&lt;/p&gt;

&lt;p&gt;The system first explores the publicly accessible pages and tries to understand the product, information structure, and most important features. If the key experience requires an account, it asks the user for access instead of inventing conclusions from the landing page.&lt;/p&gt;

&lt;p&gt;The resulting video can combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stable page recordings&lt;/li&gt;
&lt;li&gt;smooth keyframed zooms&lt;/li&gt;
&lt;li&gt;lines that guide the viewer's attention&lt;/li&gt;
&lt;li&gt;a small number of accurately positioned highlights&lt;/li&gt;
&lt;li&gt;subtitles and narration aligned with the visible action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We deliberately avoid drawing boxes around everything. Too many highlights make the video feel noisy and reduce their value.&lt;/p&gt;

&lt;p&gt;Instead, the Skill chooses between three visual treatments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zoom&lt;/strong&gt; for a core feature, result, or chart&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guide line&lt;/strong&gt; for buttons, text, and reading direction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame&lt;/strong&gt; for a clearly bounded area that genuinely needs emphasis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical move is a smooth zoom in, a short hold, and a controlled zoom out. The camera should feel intentional and stable rather than shaky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voiceover becomes the timing source
&lt;/h2&gt;

&lt;p&gt;One of the biggest lessons from earlier versions was that forcing narration into an exact duration can make it sound rushed and unnatural.&lt;/p&gt;

&lt;p&gt;The upgraded workflow generates the voiceover at a natural pace first. It then uses the real speech timing to determine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;caption boundaries&lt;/li&gt;
&lt;li&gt;scene changes&lt;/li&gt;
&lt;li&gt;zoom and highlight timing&lt;/li&gt;
&lt;li&gt;music volume automation&lt;/li&gt;
&lt;li&gt;ending space&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A requested duration such as 60 seconds is treated as a target, not a reason to damage the delivery. A clear 54-second or 66-second video is often better than an unnaturally compressed 60-second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  An editable &lt;code&gt;.timeline&lt;/code&gt; v3 project
&lt;/h2&gt;

&lt;p&gt;The most important technical change is the project format.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;.timeline&lt;/code&gt; v3, voiceover is stored as separate sentence-level audio clips rather than one long, difficult-to-edit file. Each caption can reference its corresponding voice clip.&lt;/p&gt;

&lt;p&gt;A simplified representation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"caption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Career planning should not depend on headlines alone."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"audioClipId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"voice-001"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"audioClip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"voice-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means you can replace one sentence, regenerate one voice clip, or adjust one subtitle without rebuilding the entire project.&lt;/p&gt;

&lt;p&gt;The project also preserves scenes, captions, voice clips, music, overlays, effects, and animation settings. The MP4 solves today's publishing need; the project file solves tomorrow's revision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing playback loops at clip boundaries
&lt;/h2&gt;

&lt;p&gt;The upgrade also addresses an editor playback issue where a segment could repeat near a clip boundary.&lt;/p&gt;

&lt;p&gt;Individual media elements are not always reliable as the master timeline clock. Decode delays and small rounding differences can cause time to move backward or make two neighboring clips appear active at the same boundary.&lt;/p&gt;

&lt;p&gt;Timeline Studio now uses a monotonic timeline wall clock as the master and synchronizes media against it. Clip activity is evaluated using half-open intervals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[start, end)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prevents the previous clip and next clip from both being selected at the exact same timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install the Skill
&lt;/h2&gt;

&lt;p&gt;You can install the current release with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add MartinDelophy/ai-video-editor@v0.9.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Live demo:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://video-editor.ai-creator.top" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The goal is to produce a strong first cut in the time it takes to finish a coffee, without turning the result into a black box.&lt;/p&gt;

&lt;p&gt;AI performs the repetitive first-pass work. The creator still owns the timeline, the details, and the final decision.&lt;/p&gt;

&lt;p&gt;Feedback and contributions are welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Cinematic Depth in the Browser with Depth Anything V2 Small and WebGPU</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Wed, 05 Aug 2026 03:08:09 +0000</pubDate>
      <link>https://dev.to/martindelophy/building-cinematic-depth-in-the-browser-with-depth-anything-v2-small-and-webgpu-1k07</link>
      <guid>https://dev.to/martindelophy/building-cinematic-depth-in-the-browser-with-depth-anything-v2-small-and-webgpu-1k07</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;We recently added &lt;strong&gt;Cinematic Depth&lt;/strong&gt; to &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt;, an open-source browser video editor.&lt;/p&gt;

&lt;p&gt;The feature runs Depth Anything V2 Small through WebGPU, analyzes images and video frames locally, and turns the resulting depth data into an adjustable depth-of-field effect. Source media never needs to leave the user's device.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post focuses on the engineering work required to turn a depth-estimation model into a real editing capability—not just a demo that outputs a grayscale depth map.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background blur is not depth of field
&lt;/h2&gt;

&lt;p&gt;A typical background-blur feature uses person segmentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Person pixels     -&amp;gt; keep sharp
Everything else   -&amp;gt; apply one blur radius
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is useful for video calls, but it does not model the spatial structure of a scene.&lt;/p&gt;

&lt;p&gt;A frame may contain leaves close to the camera, a person in the middle, furniture behind the person, and distant buildings. A person mask only answers “person or not.” It cannot tell us how far each region is from the lens.&lt;/p&gt;

&lt;p&gt;Depth Anything V2 Small estimates continuous relative depth across the whole frame. That lets the editor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;blur foreground and background differently;&lt;/li&gt;
&lt;li&gt;keep a configurable depth range sharp;&lt;/li&gt;
&lt;li&gt;move focus through the scene;&lt;/li&gt;
&lt;li&gt;build a foundation for animated rack focus.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the Small model?
&lt;/h2&gt;

&lt;p&gt;A browser runtime has constraints that a GPU server does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;initial download size;&lt;/li&gt;
&lt;li&gt;GPU and system memory;&lt;/li&gt;
&lt;li&gt;WebGPU availability;&lt;/li&gt;
&lt;li&gt;initialization latency;&lt;/li&gt;
&lt;li&gt;the cost of processing many video frames;&lt;/li&gt;
&lt;li&gt;mobile hardware;&lt;/li&gt;
&lt;li&gt;cache and model-version management.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We chose the Q4F16 configuration of Depth Anything V2 Small as a practical balance.&lt;/p&gt;

&lt;p&gt;The project already used &lt;code&gt;@huggingface/transformers&lt;/code&gt;, so the conceptual initialization path is straightforward:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pipeline&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;@huggingface/transformers&lt;/span&gt;&lt;span class="dl"&gt;"&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;depthEstimator&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDepthEstimator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onProgress&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;depthEstimator&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;depthEstimator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;depthEstimator&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;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;depth-estimation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;device&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webgpu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;dtype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q4f16&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;progress_callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;onProgress&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;return&lt;/span&gt; &lt;span class="nx"&gt;depthEstimator&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 important production detail is reuse. We keep the initialized worker and WebGPU session alive instead of rebuilding the pipeline for every analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing an editing feature, not a model demo
&lt;/h2&gt;

&lt;p&gt;Cinematic Depth appears as the fifth card in the editor's Effects workspace.&lt;/p&gt;

&lt;p&gt;The card:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shows the untreated shot by default;&lt;/li&gt;
&lt;li&gt;switches to an unmistakable depth-of-field preview on hover or focus;&lt;/li&gt;
&lt;li&gt;auto-previews on touch devices without hover;&lt;/li&gt;
&lt;li&gt;opens a dedicated Effects inspector on desktop;&lt;/li&gt;
&lt;li&gt;opens a focused property drawer on mobile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Users can adjust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;focus distance;&lt;/li&gt;
&lt;li&gt;focus range;&lt;/li&gt;
&lt;li&gt;lens blur;&lt;/li&gt;
&lt;li&gt;bokeh highlights;&lt;/li&gt;
&lt;li&gt;Fast, Standard, or Fine analysis quality;&lt;/li&gt;
&lt;li&gt;enable, cancel, and reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Depth analysis and visual styling are separate stages. Moving the focus or blur sliders re-composites the existing depth data—it does not rerun the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning depth into blur
&lt;/h2&gt;

&lt;p&gt;For each pixel, we calculate how far its depth is from the selected focus plane:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateBlurAmount&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;focusDistance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;focusRange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;lensBlur&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;distance&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;focusDistance&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;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="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;focusRange&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lensBlur&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;Pixels inside the focus range stay sharp. Blur increases as depth moves away from that range.&lt;/p&gt;

&lt;p&gt;Canvas does not provide a single operation for assigning a different blur radius to every pixel. A practical implementation builds several blurred versions of the source and composites them with depth masks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original
├── light blur
├── medium blur
└── strong blur
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The masks also need smoothing and feathering to reduce halos around depth discontinuities.&lt;/p&gt;

&lt;p&gt;This is not a full physical lens simulation, but it produces a far more convincing spatial transition than a binary person/background mask.&lt;/p&gt;

&lt;h2&gt;
  
  
  One depth map is not enough for video
&lt;/h2&gt;

&lt;p&gt;A still image needs one inference. Video changes over time.&lt;/p&gt;

&lt;p&gt;If we reuse the first frame's depth for the entire clip, motion quickly causes the depth map and source frame to diverge. Running inference on every original frame, however, is too expensive for many browser devices.&lt;/p&gt;

&lt;p&gt;We use quality-dependent temporal sampling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Decode video
    ↓
Sample frames along the selected clip range
    ↓
Run depth estimation with WebGPU
    ↓
Store timestamped depth frames
    ↓
Reuse them during playback and export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting data conceptually looks like this:&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;depthFrames&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="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;depth0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&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="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;depth1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;depth2&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;At render time, the editor selects the depth frame matching the current clip-relative time. Interpolation between neighboring samples can make transitions smoother.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clip-scoped depth caching
&lt;/h2&gt;

&lt;p&gt;Depth inference is expensive. Re-compositing already computed depth is comparatively cheap.&lt;/p&gt;

&lt;p&gt;These changes should therefore &lt;strong&gt;not&lt;/strong&gt; invalidate analysis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;focus distance;&lt;/li&gt;
&lt;li&gt;focus range;&lt;/li&gt;
&lt;li&gt;blur strength;&lt;/li&gt;
&lt;li&gt;bokeh settings;&lt;/li&gt;
&lt;li&gt;temporarily disabling the effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We invalidate the cache only when the source, analyzed range, quality, or model revision changes.&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;function&lt;/span&gt; &lt;span class="nf"&gt;createDepthCacheKey&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;assetId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;clipStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;clipEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;modelRevision&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;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;assetId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;clipStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;clipEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;modelRevision&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;:&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;The cache is bound to the exact Visuals or Overlay clip. Every preview and export path carries an explicit clip ID so an Overlay effect cannot accidentally alter the main track.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preview and export must match
&lt;/h2&gt;

&lt;p&gt;AI editing features often look correct in the editor but change during export.&lt;/p&gt;

&lt;p&gt;To avoid that, preview and export share:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the same timestamped depth frames;&lt;/li&gt;
&lt;li&gt;the same clip identity;&lt;/li&gt;
&lt;li&gt;the same focus distance and range;&lt;/li&gt;
&lt;li&gt;the same lens blur;&lt;/li&gt;
&lt;li&gt;the same bokeh settings;&lt;/li&gt;
&lt;li&gt;the same enabled state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The feature is integrated with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the main Visuals track;&lt;/li&gt;
&lt;li&gt;Overlay / picture-in-picture clips;&lt;/li&gt;
&lt;li&gt;real-time preview;&lt;/li&gt;
&lt;li&gt;project save and restore;&lt;/li&gt;
&lt;li&gt;deterministic export;&lt;/li&gt;
&lt;li&gt;compatibility export.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We reuse the same composition logic wherever possible instead of maintaining a separate “preview approximation.”&lt;/p&gt;

&lt;h2&gt;
  
  
  ModelScope and Hugging Face mirrors
&lt;/h2&gt;

&lt;p&gt;Local inference still requires an initial model download.&lt;/p&gt;

&lt;p&gt;To support users in different network environments, the runtime can prefer ModelScope for Chinese and domestic sessions, with Hugging Face as a fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chinese / domestic session
    ↓
Try ModelScope
    ↓
Fall back to Hugging Face
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an important cache problem here: the same model has different provider URLs. If the URL becomes the cache identity, switching providers downloads identical files twice.&lt;/p&gt;

&lt;p&gt;We use a provider-independent cache identity. The files on both mirrors are checksum-verified, and production URLs are pinned to immutable revisions rather than a mutable &lt;code&gt;main&lt;/code&gt; branch.&lt;/p&gt;

&lt;p&gt;That prevents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;duplicate downloads after provider switching;&lt;/li&gt;
&lt;li&gt;sudden incompatibility after a repository update;&lt;/li&gt;
&lt;li&gt;mixing old code with new weights;&lt;/li&gt;
&lt;li&gt;deployments that cannot be reproduced.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Progress, cancellation, and useful errors
&lt;/h2&gt;

&lt;p&gt;Video analysis needs more than a spinner.&lt;/p&gt;

&lt;p&gt;The UI distinguishes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;model preparation;&lt;/li&gt;
&lt;li&gt;WebGPU session initialization;&lt;/li&gt;
&lt;li&gt;video-frame analysis;&lt;/li&gt;
&lt;li&gt;completed frame count and percentage;&lt;/li&gt;
&lt;li&gt;result finalization.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cancel is also real. It stops further decode and inference work through an AbortController and worker messages instead of merely hiding a dialog.&lt;/p&gt;

&lt;p&gt;Low-level messages such as &lt;code&gt;Failed to fetch&lt;/code&gt; are converted into actionable, localized errors for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebGPU unavailable;&lt;/li&gt;
&lt;li&gt;network failure;&lt;/li&gt;
&lt;li&gt;model download failure;&lt;/li&gt;
&lt;li&gt;user cancellation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How long does a 10-second clip take?
&lt;/h2&gt;

&lt;p&gt;There is no honest device-independent number.&lt;/p&gt;

&lt;p&gt;Processing time depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPU hardware;&lt;/li&gt;
&lt;li&gt;the browser's WebGPU implementation;&lt;/li&gt;
&lt;li&gt;video resolution;&lt;/li&gt;
&lt;li&gt;temporal sampling density;&lt;/li&gt;
&lt;li&gt;analysis quality;&lt;/li&gt;
&lt;li&gt;whether the model is cached;&lt;/li&gt;
&lt;li&gt;whether the WebGPU session is already initialized.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first run includes download, initialization, and analysis. Later runs mostly need cache access and analysis.&lt;/p&gt;

&lt;p&gt;The product optimizations that matter most are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;download the model once;&lt;/li&gt;
&lt;li&gt;reuse the initialized session;&lt;/li&gt;
&lt;li&gt;sample video according to quality;&lt;/li&gt;
&lt;li&gt;reuse depth frames across preview and export;&lt;/li&gt;
&lt;li&gt;never rerun inference for a style-only change;&lt;/li&gt;
&lt;li&gt;expose real progress and cancellation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What else can time-varying depth enable?
&lt;/h2&gt;

&lt;p&gt;Cinematic Depth is only the first use of this data.&lt;/p&gt;

&lt;p&gt;The same timestamped depth frames can support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2.5D photo animation with foreground/background parallax;&lt;/li&gt;
&lt;li&gt;text and stickers placed correctly in front of or behind subjects;&lt;/li&gt;
&lt;li&gt;near-to-far spatial transitions;&lt;/li&gt;
&lt;li&gt;depth-aware Smart Frame;&lt;/li&gt;
&lt;li&gt;keyframed rack focus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reusable temporal depth representation becomes an editing primitive rather than a one-off effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Running a model once in the browser can be a short demo. Turning it into an editing feature requires model delivery, caching, temporal mapping, cancellation, responsive UI, persistent state, and export consistency.&lt;/p&gt;

&lt;p&gt;Timeline Studio integrates Depth Anything V2 Small as something users can analyze once, adjust repeatedly, save, restore, and export.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find the project useful, a GitHub star is appreciated. Issues and implementation feedback are welcome.&lt;/p&gt;




&lt;p&gt;This feature is intended only for lawful editing of media the user is authorized to use. It must not be used for illegal, infringing, false, misleading, or identity-impersonation content, and AI-generated or edited output must not be presented as authentic footage. Users are responsible for misuse.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>webgpu</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why Browser-Based Multilingual TTS Kept Freezing at 86% — and How We Fixed It</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Tue, 04 Aug 2026 03:24:26 +0000</pubDate>
      <link>https://dev.to/martindelophy/why-browser-based-multilingual-tts-kept-freezing-at-86-and-how-we-fixed-it-4c4f</link>
      <guid>https://dev.to/martindelophy/why-browser-based-multilingual-tts-kept-freezing-at-86-and-how-we-fixed-it-4c4f</guid>
      <description>&lt;p&gt;Running text-to-speech entirely in the browser sounds simple: download an ONNX model, create an inference session, and synthesize audio without sending user text or media to a server.&lt;/p&gt;

&lt;p&gt;In production, it is much harder.&lt;/p&gt;

&lt;p&gt;While building multilingual voice generation for &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt;, we repeatedly saw the same failure: the model-loading UI stopped at &lt;strong&gt;86%&lt;/strong&gt;, the Generate button stayed busy, and switching from Chinese to English, German, Korean, Thai, or Japanese made the issue more likely.&lt;/p&gt;

&lt;p&gt;The most useful console message was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to cache file QuotaExceededError: Quota exceeded.

QuotaExceededError:
The operation failed because it would cause the application
to exceed its storage quota.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The progress bar was only the symptom. The real problem was the interaction between large model files, multiple TTS runtimes, browser storage quotas, service-worker caching, WebGPU initialization, and region-specific model mirrors.&lt;/p&gt;

&lt;p&gt;This post explains the architecture changes that made the multilingual pipeline reliable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The browser was doing more than the progress bar showed
&lt;/h2&gt;

&lt;p&gt;A browser TTS model normally goes through several stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download configuration, phonemizer, tokenizer, and vocabulary files.&lt;/li&gt;
&lt;li&gt;Download one or more ONNX model files.&lt;/li&gt;
&lt;li&gt;Store artifacts in Cache Storage.&lt;/li&gt;
&lt;li&gt;Create an ONNX Runtime session.&lt;/li&gt;
&lt;li&gt;Compile the graph for WebGPU or initialize WASM.&lt;/li&gt;
&lt;li&gt;Run a warm-up inference.&lt;/li&gt;
&lt;li&gt;Finally synthesize the requested speech.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our original progress calculation mainly represented network downloads. If the files finished downloading but cache insertion or session creation failed, the UI retained the last reported value — often 86%.&lt;/p&gt;

&lt;p&gt;So the browser was not necessarily still downloading anything. It had already moved into an unrepresented stage and thrown an exception before the state machine could reach either success or a useful error state.&lt;/p&gt;

&lt;p&gt;The fix started by treating model setup as a real multi-stage operation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checking local model cache&lt;/li&gt;
&lt;li&gt;Downloading model artifacts&lt;/li&gt;
&lt;li&gt;Initializing the local inference engine&lt;/li&gt;
&lt;li&gt;Preparing the selected voice&lt;/li&gt;
&lt;li&gt;Generating speech&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During initialization, the UI now says what is actually happening instead of pretending another file is still downloading.&lt;/p&gt;

&lt;h2&gt;
  
  
  One origin, many competing caches
&lt;/h2&gt;

&lt;p&gt;Timeline Studio supports several browser-local voice runtimes because one model family is not the best choice for every language:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language group&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Execution path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chinese&lt;/td&gt;
&lt;td&gt;Piper&lt;/td&gt;
&lt;td&gt;WebGPU first, WASM fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English&lt;/td&gt;
&lt;td&gt;Kokoro Q8&lt;/td&gt;
&lt;td&gt;WASM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selected European languages&lt;/td&gt;
&lt;td&gt;Piper&lt;/td&gt;
&lt;td&gt;WASM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Korean, Thai, Vietnamese, Russian&lt;/td&gt;
&lt;td&gt;MMS&lt;/td&gt;
&lt;td&gt;WASM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japanese&lt;/td&gt;
&lt;td&gt;Supertonic&lt;/td&gt;
&lt;td&gt;WASM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of these runtimes operate under the same browser origin. Cache Storage, IndexedDB, and service-worker caches therefore compete for the same site quota.&lt;/p&gt;

&lt;p&gt;After a user tried several voices, the origin could contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current model files&lt;/li&gt;
&lt;li&gt;Older model revisions&lt;/li&gt;
&lt;li&gt;Multiple quantization variants&lt;/li&gt;
&lt;li&gt;Duplicate files from different mirrors&lt;/li&gt;
&lt;li&gt;Service-worker response copies&lt;/li&gt;
&lt;li&gt;Runtime-specific cache entries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each individual cache looked reasonable. Together, they could exceed the browser's storage allowance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate model identity from download URL
&lt;/h2&gt;

&lt;p&gt;The most important caching change was to stop treating a model URL as the model's identity.&lt;/p&gt;

&lt;p&gt;This is fragile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;modelDownloadUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same immutable artifact can be served by ModelScope in China and Hugging Face elsewhere. If the full URL becomes the cache key, identical bytes from two providers occupy two independent cache entries.&lt;/p&gt;

&lt;p&gt;Instead, we generate a provider-independent identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheIdentity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;modelFamily&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;immutableRevision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;quantization&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;:&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;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kokoro:revision-20260804:en:female:q8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both mirror URLs resolve to that same logical entry. The provider can change without forcing a redownload or duplicating hundreds of megabytes.&lt;/p&gt;

&lt;p&gt;This also makes cache migration predictable: an artifact revision is explicit, and a new revision naturally receives a new identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use regional mirrors without fragmenting the cache
&lt;/h2&gt;

&lt;p&gt;Browser-local inference still needs a network connection the first time a voice is used.&lt;/p&gt;

&lt;p&gt;For Chinese-language and domestic sessions, Timeline Studio tries the owned ModelScope mirror first. Other sessions prefer the owned Hugging Face repository. If the preferred source fails, the loader tries the fallback source automatically.&lt;/p&gt;

&lt;p&gt;The simplified flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadVoiceArtifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceArtifact&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;cached&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;readSharedVoiceCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheIdentity&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;cached&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;cached&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;source&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;getPreferredSources&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&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;bytes&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;downloadArtifact&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;artifact&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;writeSharedVoiceCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheIdentity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;;&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;reportSourceFailure&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;error&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VoiceModelUnavailableError&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;Every production artifact is pinned to an immutable provider revision. That prevents a remote &lt;code&gt;main&lt;/code&gt; branch from silently changing model bytes or breaking the browser runtime.&lt;/p&gt;

&lt;p&gt;The product also never displays a raw &lt;code&gt;Failed to fetch&lt;/code&gt; message. Users receive a localized explanation that the model could not be downloaded and that the fallback source was attempted.&lt;/p&gt;

&lt;h2&gt;
  
  
  A smaller model can be the faster product decision
&lt;/h2&gt;

&lt;p&gt;The English pipeline originally used a roughly 325 MB FP32 Kokoro model with a WebGPU-first path.&lt;/p&gt;

&lt;p&gt;That configuration looked attractive in benchmarks, but it created several real-world problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long first download&lt;/li&gt;
&lt;li&gt;High Cache Storage pressure&lt;/li&gt;
&lt;li&gt;Unpredictable WebGPU graph compilation&lt;/li&gt;
&lt;li&gt;Driver-specific failures&lt;/li&gt;
&lt;li&gt;More competition with other language models&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We switched the English path to a roughly 92 MB Q8 model and a stable WASM execution provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&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;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferenceSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;executionProviders&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;wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;graphOptimizationLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&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;For an editor, voice generation is usually an occasional operation, not a continuously saturated inference workload. A smaller quantized model that reliably loads on more devices creates a better user experience than a theoretically faster GPU path that frequently fails before inference begins.&lt;/p&gt;

&lt;p&gt;The design goal became:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First generation succeeds&lt;/li&gt;
&lt;li&gt;Repeat generation reuses the cache&lt;/li&gt;
&lt;li&gt;Switching languages does not break existing voices&lt;/li&gt;
&lt;li&gt;Mid-range devices remain supported&lt;/li&gt;
&lt;li&gt;WebGPU has a bounded WASM fallback where appropriate&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Evict stale voice models, not the entire application cache
&lt;/h2&gt;

&lt;p&gt;Deleting every cache when storage is full is easy, but it punishes the user by removing the model they just downloaded.&lt;/p&gt;

&lt;p&gt;Instead, the cache manager protects the active voice and removes stale voice artifacts first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Determine the cache identity required by the current voice.&lt;/li&gt;
&lt;li&gt;Mark that entry as protected.&lt;/li&gt;
&lt;li&gt;Remove obsolete model revisions.&lt;/li&gt;
&lt;li&gt;Evict least-recently-used inactive voice models.&lt;/li&gt;
&lt;li&gt;Preserve application assets and the active voice.&lt;/li&gt;
&lt;li&gt;Retry the cache write.&lt;/li&gt;
&lt;li&gt;If persistent storage still fails, allow the current inference to continue from memory when possible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A simplified quota check is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureVoiceStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeIdentity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;estimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;estimate&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;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quota&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&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;usageRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quota&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;usageRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&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;evictStaleVoiceModels&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;preserve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;activeIdentity&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;least-recently-used&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turns a hard quota failure into a recoverable resource-management event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give large model files one caching owner
&lt;/h2&gt;

&lt;p&gt;Service workers are excellent for JavaScript bundles, styles, icons, and ordinary static resources. They become dangerous when they independently cache very large ONNX responses that are already managed by a model loader.&lt;/p&gt;

&lt;p&gt;Cloning and caching the same response in both layers can silently double storage use.&lt;/p&gt;

&lt;p&gt;We established a single-responsibility rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The voice model manager owns large model artifacts.&lt;/li&gt;
&lt;li&gt;The service worker does not duplicate large ONNX files.&lt;/li&gt;
&lt;li&gt;Normal application assets remain under service-worker control.&lt;/li&gt;
&lt;li&gt;Every TTS runtime uses the shared voice-artifact manifest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This made storage usage measurable and model cleanup deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progress should be byte-weighted and stage-aware
&lt;/h2&gt;

&lt;p&gt;A 5 KB configuration file and a 92 MB model should not contribute equally to progress.&lt;/p&gt;

&lt;p&gt;The downloader now reports progress using actual bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loadedBytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalBytes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;onProgress&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;progress&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the network phase ends, the UI resets into the initialization phase rather than holding at an arbitrary download percentage.&lt;/p&gt;

&lt;p&gt;Not every runtime exposes identical internal progress, so the application uses a shared high-level contract while allowing each adapter to provide the best signals it has. The UI remains honest even when graph compilation itself cannot provide byte-level progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the browser paint before heavy WASM work
&lt;/h2&gt;

&lt;p&gt;There was another small but important issue.&lt;/p&gt;

&lt;p&gt;React state updates are asynchronous. If we set the generation status and immediately begin synchronous or CPU-heavy WASM work, the main thread may not paint the new state. To the user, the page appears frozen before it ever shows a useful message.&lt;/p&gt;

&lt;p&gt;We now yield one frame before starting inference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;setGenerationState&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;generating&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;progress&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="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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;resolve&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;requestAnimationFrame&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;resolve&lt;/span&gt;&lt;span class="p"&gt;());&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;generateVoice&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not make inference faster, but it makes the product feel responsive because the browser can display the transition before entering the heavy task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hide runtime diversity behind one interface
&lt;/h2&gt;

&lt;p&gt;A multilingual product should not force the editor UI to understand every model family.&lt;/p&gt;

&lt;p&gt;Each adapter implements a common contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;VoiceRuntime&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="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AudioBuffer&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;dispose&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;The timeline, asset library, and export pipeline work with generated audio regardless of whether the source was Piper, Kokoro, MMS, or Supertonic.&lt;/p&gt;

&lt;p&gt;That separation also makes it possible to change quantization, execution providers, or mirror routing without rewriting product-level editing features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we verified
&lt;/h2&gt;

&lt;p&gt;After the changes, we tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First-time English model setup and repeated generation&lt;/li&gt;
&lt;li&gt;Repeated German generation&lt;/li&gt;
&lt;li&gt;Korean local inference&lt;/li&gt;
&lt;li&gt;Thai model download and synthesis&lt;/li&gt;
&lt;li&gt;Japanese Supertonic initialization&lt;/li&gt;
&lt;li&gt;Chinese mirror preference and provider fallback&lt;/li&gt;
&lt;li&gt;Automatic cleanup near the browser quota&lt;/li&gt;
&lt;li&gt;Cache reuse after a page refresh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repeated generation no longer presents itself as another full model download, and quota failures no longer leave the interface permanently stuck at 86%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons for browser AI applications
&lt;/h2&gt;

&lt;p&gt;The biggest lesson is that a model running once in a local prototype is not the same as a reliable browser AI feature.&lt;/p&gt;

&lt;p&gt;A production implementation should answer all of these questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are model files pinned to immutable revisions?&lt;/li&gt;
&lt;li&gt;Do regional mirrors share a cache identity?&lt;/li&gt;
&lt;li&gt;Can the service worker duplicate large artifacts?&lt;/li&gt;
&lt;li&gt;What happens when persistent storage is almost full?&lt;/li&gt;
&lt;li&gt;Does progress represent real bytes and real stages?&lt;/li&gt;
&lt;li&gt;Does the UI get a chance to paint before heavy inference?&lt;/li&gt;
&lt;li&gt;Is there a stable fallback when WebGPU is unavailable?&lt;/li&gt;
&lt;li&gt;Are unused language models eventually evicted?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The original “stuck at 86%” report looked like a progress-bar bug. In reality, it exposed an architectural problem spanning storage, networking, inference, and UI scheduling.&lt;/p&gt;

&lt;p&gt;By introducing quantized models, shared artifact identities, regional mirror fallback, quota-aware eviction, stage-aware progress, and stable WASM paths, multilingual voice generation became far more predictable across browsers and regions.&lt;/p&gt;

&lt;p&gt;If you are building local-first AI in the browser, treat model distribution and storage as first-class infrastructure. Successful inference is only the beginning; reliable recovery across devices, networks, and storage conditions is what turns it into a product.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Open-source project:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it online:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>webgpu</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building Semantic Optical Flow Tracking in the Browser with WebCodecs</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:04:28 +0000</pubDate>
      <link>https://dev.to/martindelophy/building-semantic-optical-flow-tracking-in-the-browser-with-webcodecs-2m5c</link>
      <guid>https://dev.to/martindelophy/building-semantic-optical-flow-tracking-in-the-browser-with-webcodecs-2m5c</guid>
      <description>&lt;h2&gt;
  
  
  Project links
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Timeline Studio is a local-first AI video editor that runs in the browser. I recently added an experimental &lt;strong&gt;Optical Flow Tracking&lt;/strong&gt; capability to it.&lt;/p&gt;

&lt;p&gt;The goal was not to cover the frame with impressive-looking arrows. The feature first detects a person or object, calculates optical flow only inside that semantic region, aggregates the local vectors into a motion cohort, and accumulates the cohort into a visible trajectory.&lt;/p&gt;

&lt;p&gt;When processing finishes, the browser renders a reusable WebM result and adds it to &lt;strong&gt;My assets&lt;/strong&gt;. The generated result is a real editing asset rather than a temporary analysis preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why raw optical flow was not enough
&lt;/h2&gt;

&lt;p&gt;Optical flow describes pixel displacement between adjacent frames:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v = (deltaX, deltaY)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drawing every vector gives us a familiar motion-field visualization, but those vectors do not understand the scene.&lt;/p&gt;

&lt;p&gt;They may come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;camera shake;&lt;/li&gt;
&lt;li&gt;moving background textures;&lt;/li&gt;
&lt;li&gt;subtitle changes;&lt;/li&gt;
&lt;li&gt;lighting variation;&lt;/li&gt;
&lt;li&gt;compression artifacts;&lt;/li&gt;
&lt;li&gt;hair and clothing deformation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clustering all of those vectors can easily turn background texture into a supposed “motion group.”&lt;/p&gt;

&lt;p&gt;So I changed the question from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which pixels moved?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which person or object moved, in which direction, and along what trajectory?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The processing pipeline
&lt;/h2&gt;

&lt;p&gt;The browser-local pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Selected video clip
        ↓
Detect people and objects
        ↓
Create large semantic regions
        ↓
Calculate local optical flow inside each ROI
        ↓
Aggregate vectors into motion cohorts
        ↓
Accumulate trajectories over time
        ↓
Render on clear source frames
        ↓
Encode a WebM result
        ↓
Add the result to My assets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Frame extraction, model inference, flow calculation, Canvas rendering, and video encoding all happen in the browser. The source video does not need to be uploaded to an analysis server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting the subject before calculating flow
&lt;/h2&gt;

&lt;p&gt;The first sampled frame acts as the semantic anchor. The implementation uses bounded local fallbacks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;NanoDet for lightweight person and object proposals;&lt;/li&gt;
&lt;li&gt;MediaPipe segmentation to confirm a person region;&lt;/li&gt;
&lt;li&gt;YOLOS as another detection fallback;&lt;/li&gt;
&lt;li&gt;a conservative centered semantic prior when the model runtimes fail on an otherwise valid video.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A “group” does not require multiple people. One clearly visible person or one main object is already a valid semantic motion cohort.&lt;/p&gt;

&lt;p&gt;Close-up footage also needs special handling. A person may occupy almost the complete frame, so rejecting every large detection box would incorrectly fail on normal portrait composition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Representing a semantic motion cohort
&lt;/h2&gt;

&lt;p&gt;Each detected subject becomes a large region that can be propagated through time.&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;cohort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cohort-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ymin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ymax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&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="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.525&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;dx&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="na"&gt;dy&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="na"&gt;confidence&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="na"&gt;stability&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The box is normalized to the 0–1 range. This makes it possible to calculate motion on a small analysis frame and later map the result onto a higher-resolution rendering canvas.&lt;/p&gt;

&lt;p&gt;Nearby people may be merged into a larger cohort when their regions and motion agree. A single principal object remains its own cohort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calculating flow inside the semantic ROI
&lt;/h2&gt;

&lt;p&gt;The sampled video frames are converted to grayscale. A local block-matching pass searches for the best displacement between the current frame and the next one.&lt;/p&gt;

&lt;p&gt;For a candidate displacement, the matching error can be described as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E(dx, dy) = average(abs(frameA(pixel) - frameB(pixel + displacement)))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The displacement with the lowest error becomes a candidate motion vector.&lt;/p&gt;

&lt;p&gt;Not every candidate is accepted. The implementation rejects vectors that are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;too small to represent meaningful motion;&lt;/li&gt;
&lt;li&gt;too uncertain;&lt;/li&gt;
&lt;li&gt;outside the semantic ROI;&lt;/li&gt;
&lt;li&gt;too far from the cohort’s dominant direction;&lt;/li&gt;
&lt;li&gt;produced by a poor block match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important rule is that background vectors cannot create a person or object cohort on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aggregating local vectors into one group direction
&lt;/h2&gt;

&lt;p&gt;Inside a semantic region, the accepted local vectors are combined using their confidence as a weight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cohortVector = sum(confidence[i] * vector[i]) / sum(confidence[i])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simplified implementation looks like this:&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;function&lt;/span&gt; &lt;span class="nf"&gt;aggregateVectors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;)&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;dx&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dy&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalWeight&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="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;vector&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;vectors&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;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;totalWeight&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;weight&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;totalWeight&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="na"&gt;dx&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="na"&gt;dy&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="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="na"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalWeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalWeight&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;I also compare how consistently the local vectors agree with the group direction. That becomes the cohort’s stability score.&lt;/p&gt;

&lt;p&gt;The UI exposes several useful diagnostics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;valid vector count;&lt;/li&gt;
&lt;li&gt;motion cohort count;&lt;/li&gt;
&lt;li&gt;dominant direction;&lt;/li&gt;
&lt;li&gt;trajectory stability;&lt;/li&gt;
&lt;li&gt;active detector;&lt;/li&gt;
&lt;li&gt;processed frame count;&lt;/li&gt;
&lt;li&gt;analysis and encoding progress.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These metrics make the experiment easier to understand and help identify weak tracks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning frame-by-frame motion into a trajectory
&lt;/h2&gt;

&lt;p&gt;One optical-flow step only explains the motion at that instant. To show movement from point A to point B, the cohort center is updated after each sampled frame.&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;nextPoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previousPoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cohortVector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previousPoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cohortVector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frameTime&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;points&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="nx"&gt;nextPoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visible trail is a configurable window over the accumulated points:&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;visiblePoints&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;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;trailLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This supports short motion tails as well as longer experiment-style trajectories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering the visualization
&lt;/h2&gt;

&lt;p&gt;The result Canvas contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the original video frame;&lt;/li&gt;
&lt;li&gt;a subtle analysis grid;&lt;/li&gt;
&lt;li&gt;cyan local-flow arrows;&lt;/li&gt;
&lt;li&gt;unlabeled cohort bounds;&lt;/li&gt;
&lt;li&gt;distinct accumulated trails;&lt;/li&gt;
&lt;li&gt;the main cohort direction;&lt;/li&gt;
&lt;li&gt;an experiment timecode in the upper-right corner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I intentionally removed the text label from the cohort boundary. On close-up footage, a “Person · 1” label covered the subject and made the generated video feel more like a debug screen than a finished experiment.&lt;/p&gt;

&lt;p&gt;The cohort count and detector information still appear in the inspector, where they do not obscure the image.&lt;/p&gt;

&lt;p&gt;A simplified arrow renderer uses the Canvas 2D API:&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;function&lt;/span&gt; &lt;span class="nf"&gt;drawArrow&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="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;color&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;angle&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;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startX&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;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;color&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="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;color&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;beginPath&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;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startY&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;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endY&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;stroke&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;beginPath&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;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endY&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;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;endX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;head&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;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;endY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;head&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;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&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;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;endX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;head&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;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;endY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;head&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;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&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;closePath&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;fill&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;h2&gt;
  
  
  Adding the timecode
&lt;/h2&gt;

&lt;p&gt;A clip-relative timecode is burned into the upper-right corner of each generated frame.&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;timecode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;formatTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&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="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;700 16px ui-monospace, monospace&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;padding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&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;width&lt;/span&gt; &lt;span class="o"&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;measureText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timecode&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;padding&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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;x&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;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;16&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;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&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="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rgba(3, 10, 13, 0.78)&lt;/span&gt;&lt;span class="dl"&gt;"&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;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&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="mi"&gt;34&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="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#55f3e1&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;textAlign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;textBaseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;middle&lt;/span&gt;&lt;span class="dl"&gt;"&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;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timecode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Besides improving readability, this gives the output the feel of a completed motion-analysis experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Low-resolution analysis, high-resolution rendering
&lt;/h2&gt;

&lt;p&gt;The first version stored the 192-pixel-wide analysis frames and enlarged them during result generation. The motion vectors looked correct, but the underlying video became visibly blurry.&lt;/p&gt;

&lt;p&gt;The fix was to separate the analysis and rendering paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  Analysis path
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;resize the frame to a small working resolution;&lt;/li&gt;
&lt;li&gt;convert it to grayscale;&lt;/li&gt;
&lt;li&gt;calculate local flow;&lt;/li&gt;
&lt;li&gt;update cohorts and trajectories.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rendering path
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;retain clear samples from the source video;&lt;/li&gt;
&lt;li&gt;cap the render width at 1280 pixels;&lt;/li&gt;
&lt;li&gt;map analysis coordinates to the render Canvas;&lt;/li&gt;
&lt;li&gt;redraw vectors, bounds, trails, and timecode;&lt;/li&gt;
&lt;li&gt;encode the high-resolution result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The coordinate mapping is straightforward:&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;scaleX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;renderWidth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;analysisWidth&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;scaleY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;renderHeight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;analysisHeight&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;renderX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scaleX&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;renderY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scaleY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the optical-flow calculation inexpensive without sacrificing the clarity of the generated asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encoding the result with WebCodecs
&lt;/h2&gt;

&lt;p&gt;After analysis, every sampled moment is rendered to a Canvas frame. The frames are then encoded as WebM using WebCodecs.&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;resultBlob&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;encodeFrames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;renderedFrameBlobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;renderWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;renderHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;keyframeTimes&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="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;abortController&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;onProgress&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="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;updateProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;92&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;round&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="mi"&gt;8&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;p&gt;The same abort signal is checked during frame rendering and encoding, so cancellation stops further processing and releases the encoder resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating real timeline frames
&lt;/h2&gt;

&lt;p&gt;A generated video should not become a blank rectangle when it is placed on the timeline. Compact timeline frames are therefore created from the rendered output frames.&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;trackFrames&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;createVideoTrackFramesFromBlobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;renderedFrameBlobs&lt;/span&gt;&lt;span class="p"&gt;,&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="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;renderWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;renderHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;signal&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 asset stores a matching track-frame duration:&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;asset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&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="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;video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;src&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;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resultBlob&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resultBlob&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="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;renderWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;renderHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;trackFrames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trackFrameDuration&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="na"&gt;generatedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;optical-flow-tracking&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;diagnostics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;detector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vectorCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cohorts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cohortCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dominantAngle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dominantAngle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stability&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;h2&gt;
  
  
  Adding the result to My assets
&lt;/h2&gt;

&lt;p&gt;When encoding finishes, the result is added to the media library and selected automatically.&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="nf"&gt;setUserAssets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&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;asset&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;setSelectedLibraryAssetId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;asset&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="nf"&gt;setActiveTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;media&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;setMediaTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mine&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;It is deliberately not inserted into the timeline automatically. The user can inspect the result first, then decide whether it belongs on the main visual track or as an overlay.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small browser benchmark
&lt;/h2&gt;

&lt;p&gt;I tested the pipeline with a two-second, 852×480 video at a 4 fps sampling rate.&lt;/p&gt;

&lt;p&gt;The result contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9 sampled analysis frames;&lt;/li&gt;
&lt;li&gt;268 valid motion vectors;&lt;/li&gt;
&lt;li&gt;1 semantic motion cohort;&lt;/li&gt;
&lt;li&gt;a dominant direction of roughly 235 degrees;&lt;/li&gt;
&lt;li&gt;a trajectory stability score of roughly 78%;&lt;/li&gt;
&lt;li&gt;an 852×480 WebM output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because low-resolution analysis and clear-frame rendering are separated, the result preserves the source dimensions instead of enlarging the analysis frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benefits and limitations of browser-local processing
&lt;/h2&gt;

&lt;p&gt;The complete pipeline runs locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;video frame extraction;&lt;/li&gt;
&lt;li&gt;person and object detection;&lt;/li&gt;
&lt;li&gt;person-region estimation;&lt;/li&gt;
&lt;li&gt;optical-flow calculation;&lt;/li&gt;
&lt;li&gt;cohort tracking;&lt;/li&gt;
&lt;li&gt;Canvas rendering;&lt;/li&gt;
&lt;li&gt;WebM encoding;&lt;/li&gt;
&lt;li&gt;timeline thumbnail generation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This provides several advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the source video does not need to be uploaded;&lt;/li&gt;
&lt;li&gt;there is no backend inference queue;&lt;/li&gt;
&lt;li&gt;parameter changes can be tested immediately;&lt;/li&gt;
&lt;li&gt;the result becomes an editing asset right away;&lt;/li&gt;
&lt;li&gt;server-side video storage and processing costs are reduced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are also real limitations. Long clips, high sampling rates, and 4K sources increase memory use and processing time. Heavy motion blur, long occlusion, and scene cuts can reduce tracking stability. Browser support for WebCodecs and local model runtimes also varies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The most important change was not a more complicated arrow renderer. It was giving optical flow a semantic boundary and a complete product workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Person/object detection
  +
Semantic ROI
  +
Local optical flow
  +
Cohort vector aggregation
  +
Trajectory accumulation
  +
High-resolution Canvas rendering
  +
WebCodecs encoding
  +
Reusable media asset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions made the largest difference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do not create people or object groups from background vectors.&lt;/li&gt;
&lt;li&gt;Separate low-resolution analysis from high-resolution output rendering.&lt;/li&gt;
&lt;li&gt;Treat analysis as complete only after it becomes a reusable video asset.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result feels less like a raw computer-vision demo and more like a finished browser-based motion experiment that can continue through the normal editing workflow.&lt;/p&gt;

&lt;p&gt;If you are interested in browser AI, WebCodecs, video editing, or computer vision, take a look at the repository:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>video</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building Browser-Local Video Face Swapping: Lessons from a WebGPU Inference Pipeline</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Fri, 31 Jul 2026 08:10:51 +0000</pubDate>
      <link>https://dev.to/martindelophy/building-browser-local-video-face-swapping-lessons-from-a-webgpu-inference-pipeline-5dm5</link>
      <guid>https://dev.to/martindelophy/building-browser-local-video-face-swapping-lessons-from-a-webgpu-inference-pipeline-5dm5</guid>
      <description>&lt;p&gt;Video face swapping is usually associated with cloud GPUs: upload a video, process it frame by frame on a server, and download the result.&lt;/p&gt;

&lt;p&gt;Modern browser APIs make another architecture possible. WebGPU, WebCodecs, Web Workers, and ONNX Runtime Web can move a meaningful part of the pipeline onto the user's device. Source media does not have to be uploaded to an inference server, models can be cached locally, and completed results can go straight into an editable video project.&lt;/p&gt;

&lt;p&gt;Once we built the pipeline, however, model inference turned out to be only part of the problem. Pixel conversion, cross-thread transfers, GPU initialization, target tracking, mask cleanup, memory ownership, cancellation, and reproducible benchmarking all had a direct impact on whether the feature felt usable.&lt;/p&gt;

&lt;p&gt;This article focuses on those engineering details rather than the UI.&lt;/p&gt;

&lt;p&gt;The implementation discussed here is part of the open-source &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt; project.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Responsible-use notice&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article is a technical discussion of browser image processing and machine-learning inference, not legal advice. Use only face images and videos for which you have clear authorization from the depicted people and relevant rights holders. Do not use face synthesis for illegal, infringing, deceptive, misleading, harassing, fraudulent, or identity-abuse purposes. Do not present generated output as authentic footage or a factual record. Label AI-altered content when appropriate, and comply with applicable laws, contracts, media licenses, and platform policies.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Follow one frame through the entire pipeline
&lt;/h2&gt;

&lt;p&gt;A decoded frame travels through roughly the following sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VideoFrame / Canvas
    ↓
RGBA Uint8ClampedArray
    ↓
NCHW Float32Array
    ↓
ONNX Tensor
    ↓
Generated RGB and Alpha Mask
    ↓
Canvas compositing
    ↓
WebP intermediate frame
    ↓
WebM encoding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every arrow costs time and memory.&lt;/p&gt;

&lt;p&gt;SCRFD and MobileFaceSwap expect NCHW tensors, while pixels read from a Canvas are interleaved RGBA values. Before inference, the three color channels must be separated:&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;plane&lt;/span&gt; &lt;span class="o"&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;height&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;tensor&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;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plane&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&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;plane&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="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;tensor&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;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgba&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;tensor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;plane&lt;/span&gt; &lt;span class="o"&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;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgba&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="mi"&gt;4&lt;/span&gt; &lt;span class="o"&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;tensor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;plane&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&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;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgba&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="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&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;This loop looks harmless in isolation. In a video pipeline it runs repeatedly, alongside Canvas readback, Tensor allocation, and worker communication. Together, these operations can consume substantial CPU time and memory bandwidth.&lt;/p&gt;

&lt;p&gt;That leads to an important rule: choose model input sizes based on the cost of the entire data path, not model accuracy alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Do not send a full 1080p frame through the generator
&lt;/h2&gt;

&lt;p&gt;Face detection and face generation solve different problems, so they should not use the same resolution.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Input size&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;SCRFD detection&lt;/td&gt;
&lt;td&gt;640×640&lt;/td&gt;
&lt;td&gt;Locate faces and landmarks across the complete frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity extraction&lt;/td&gt;
&lt;td&gt;112×112&lt;/td&gt;
&lt;td&gt;Extract a source identity representation that is relatively pose-independent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Face generation&lt;/td&gt;
&lt;td&gt;224×224&lt;/td&gt;
&lt;td&gt;Generate the new identity in the target pose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optical flow&lt;/td&gt;
&lt;td&gt;Long edge at most 720px&lt;/td&gt;
&lt;td&gt;Track five facial landmarks at lower cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final composition&lt;/td&gt;
&lt;td&gt;Original video resolution&lt;/td&gt;
&lt;td&gt;Preserve the background and source detail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Detection needs the complete image. A 640×640 analysis frame helps retain smaller faces. Generation only needs an aligned face crop, so it stays at 224×224.&lt;/p&gt;

&lt;p&gt;Sending a complete 1080p frame into the generator would spend most of its compute on background pixels that never change. Detecting globally, aligning the face, and generating only the region of interest is one of the main reasons this workload can run in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Download models in parallel, initialize WebGPU sessions serially
&lt;/h2&gt;

&lt;p&gt;The first run has two expensive phases: downloading the model files and creating ONNX sessions. They benefit from different scheduling strategies.&lt;/p&gt;

&lt;p&gt;Model downloads are independent, so they can run concurrently. Session creation, however, may involve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;parsing the ONNX graph;&lt;/li&gt;
&lt;li&gt;graph optimization and operator fusion;&lt;/li&gt;
&lt;li&gt;WebGPU shader generation;&lt;/li&gt;
&lt;li&gt;pipeline compilation;&lt;/li&gt;
&lt;li&gt;weight upload;&lt;/li&gt;
&lt;li&gt;GPU buffer allocation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Creating several sessions at once can produce a burst of shader compilation and GPU allocation. On resource-constrained devices, this increases startup jitter and peak memory use, and may even contribute to a lost GPU device.&lt;/p&gt;

&lt;p&gt;We use parallel download followed by serial initialization:&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="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;detectorBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;identityBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;conditionerBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;generatorBuffer&lt;/span&gt;&lt;span class="p"&gt;,&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="nb"&gt;Promise&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;modelDownloads&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;detector&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;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detectorBuffer&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;identity&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;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identityBuffer&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;conditioner&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;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conditionerBuffer&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;generator&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;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;generatorBuffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This preserves network concurrency without concentrating GPU initialization into the same moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Transfer buffers instead of cloning them
&lt;/h2&gt;

&lt;p&gt;Detection and generation should not block the UI thread, so frames and tensors move through Web Workers.&lt;/p&gt;

&lt;p&gt;If an &lt;code&gt;ArrayBuffer&lt;/code&gt; is posted without a transfer list, the browser may use structured cloning. A single 640×640 Float32 RGB tensor is already about 4.69 MB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;640 × 640 × 3 × 4 bytes ≈ 4.69 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copying that buffer at every detection anchor quickly creates memory-bandwidth and garbage-collection pressure.&lt;/p&gt;

&lt;p&gt;Transfer ownership instead:&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;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&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;detect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tensor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&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;tensor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&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;After the call, the original thread's buffer is detached and the worker owns it. Generated RGB output and alpha masks can return through the same mechanism.&lt;/p&gt;

&lt;p&gt;Transferables do not make inference itself faster. They remove avoidable copies and short-lived large objects, which matters just as much in a continuous video workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Optical flow is useful only when you can reject bad tracks
&lt;/h2&gt;

&lt;p&gt;Running full face detection on every frame is expensive. A practical pipeline detects anchor frames and propagates landmarks between them with Lucas–Kanade optical flow.&lt;/p&gt;

&lt;p&gt;Optical flow estimates local pixel movement; it does not guarantee that the match is correct. We therefore use a forward-backward consistency check.&lt;/p&gt;

&lt;p&gt;Given a point &lt;code&gt;p_t&lt;/code&gt; in the previous frame, forward tracking produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_{t+1}=F(I_t,I_{t+1},p_t)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Track that result backward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\hat{p_t}=F(I_{t+1},I_t,p_{t+1})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The forward-backward error is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;e_{fb}=\lVert \hat{p_t}-p_t \rVert_2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stable track should return close to the original point. In our case, a frame is accepted only when at least four of the five landmarks remain valid and their average error stays below a threshold.&lt;/p&gt;

&lt;p&gt;This check helps reject:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;incorrect matches during fast movement;&lt;/li&gt;
&lt;li&gt;hands or objects covering the face;&lt;/li&gt;
&lt;li&gt;motion blur;&lt;/li&gt;
&lt;li&gt;a subject leaving the frame;&lt;/li&gt;
&lt;li&gt;abrupt lighting changes;&lt;/li&gt;
&lt;li&gt;landmarks drifting onto background texture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optical flow is still limited to short propagation windows. SCRFD runs again at fixed intervals to prevent error from accumulating indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The highest-confidence face is not always the target
&lt;/h2&gt;

&lt;p&gt;Selecting the highest-confidence detection on each frame works poorly in multi-person footage. A new person may enter closer to the camera and receive a better detector score, even though they are not the editing target.&lt;/p&gt;

&lt;p&gt;Target selection becomes a temporal matching problem. Candidate scoring can consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;distance from the previous target center;&lt;/li&gt;
&lt;li&gt;change in bounding-box area;&lt;/li&gt;
&lt;li&gt;current detector confidence;&lt;/li&gt;
&lt;li&gt;distance from the frame center on the first frame.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One simplified score is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S=w_cC-w_dD-w_aA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;C&lt;/code&gt; is detector confidence, &lt;code&gt;D&lt;/code&gt; is center distance, &lt;code&gt;A&lt;/code&gt; is the area change, and the &lt;code&gt;w&lt;/code&gt; values are weights.&lt;/p&gt;

&lt;p&gt;The first frame favors a confident, reasonably large face near the center. Later frames give more weight to spatial and scale continuity.&lt;/p&gt;

&lt;p&gt;This is not full face re-identification, but it is substantially more stable than picking the highest-confidence box on every frame. When a match is not trustworthy, keeping the original frame is safer than applying the effect to the wrong person.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Traditional image processing still matters after generation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mask morphology
&lt;/h3&gt;

&lt;p&gt;The generator's mask may contain holes, spikes, or discontinuous edges. Using it directly can make the blend boundary flicker.&lt;/p&gt;

&lt;p&gt;A typical cleanup sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw mask
  ↓
Threshold
  ↓
Dilation: close local gaps
  ↓
Erosion: remove outer spikes
  ↓
Additional erosion: pull the blend region inward
  ↓
Box blur: create a smooth alpha
  ↓
Boundary safety mask: remove crop-box edges
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dilation and erosion can use a separable sliding-window implementation: filter horizontally, then vertically.&lt;/p&gt;

&lt;p&gt;A direct two-dimensional morphology operation with radius &lt;code&gt;r&lt;/code&gt; is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;O(W \times H \times r^2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separable sliding-window implementation can approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;O(W \times H)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The neural network may run on the GPU, but conventional algorithms that execute on every frame still deserve careful optimization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bounded color matching
&lt;/h3&gt;

&lt;p&gt;When the source identity and target video have different color temperatures, the generated face can look disconnected from the forehead or neck.&lt;/p&gt;

&lt;p&gt;Within the valid mask, we calculate the mean and standard deviation of the generated and target pixels, then apply a per-channel correction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'=\frac{\sigma_t}{\sigma_s}(I-\mu_s)+\mu_t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unbounded correction may amplify noise or produce extreme colors under unusual lighting. Clamp both scale and offset:&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;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetStd&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sourceStd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.22&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;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetMean&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sourceMean&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corrected result is blended with the original output rather than replacing it completely. This reduces skin-tone discontinuities without discarding texture already reconstructed by the generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. A model URL is part of its cache identity
&lt;/h2&gt;

&lt;p&gt;Loading production models from a mutable path is risky:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repository/resolve/main/model.onnx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL can remain unchanged while the remote content changes. That can lead to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;different inference results from the same frontend version;&lt;/li&gt;
&lt;li&gt;stale browser cache entries alongside new server files;&lt;/li&gt;
&lt;li&gt;regressions with no identifiable model version;&lt;/li&gt;
&lt;li&gt;temporarily inconsistent mirrors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Production URLs should point to immutable revisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repository/resolve/&amp;lt;immutable-revision&amp;gt;/model.onnx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also record the expected file size, SHA-256 hash, purpose, license, and input/output tensor definitions.&lt;/p&gt;

&lt;p&gt;After download, validate size or hash before creating a session. Otherwise, a CDN error page or partial response may be passed to the ONNX loader as if it were a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Memory ownership is harder than one inference timing
&lt;/h2&gt;

&lt;p&gt;A browser video task may hold all of these at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;decoded frames;&lt;/li&gt;
&lt;li&gt;Canvas pixels;&lt;/li&gt;
&lt;li&gt;Float32 input tensors;&lt;/li&gt;
&lt;li&gt;ONNX output tensors;&lt;/li&gt;
&lt;li&gt;grayscale optical-flow images;&lt;/li&gt;
&lt;li&gt;intermediate frame blobs;&lt;/li&gt;
&lt;li&gt;encoder buffers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Relying only on JavaScript garbage collection can produce steady memory growth after a relatively small number of frames. Several resource types need explicit cleanup:&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;tensor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&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;dispose&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;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ONNX tensors, &lt;code&gt;ImageBitmap&lt;/code&gt;, OpenCV matrices, and object URLs belong to different runtimes and have different lifetime rules.&lt;/p&gt;

&lt;p&gt;OpenCV.js deserves particular attention: &lt;code&gt;Mat&lt;/code&gt; instances allocate from a WebAssembly heap. Losing the JavaScript reference does not guarantee prompt release of the underlying memory, so &lt;code&gt;delete()&lt;/code&gt; must be called explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Cancellation must reach the deepest stage
&lt;/h2&gt;

&lt;p&gt;Closing a progress dialog is not cancellation. A real cancel signal must propagate through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model downloads;&lt;/li&gt;
&lt;li&gt;video-frame reading;&lt;/li&gt;
&lt;li&gt;face detection;&lt;/li&gt;
&lt;li&gt;optical-flow tracking;&lt;/li&gt;
&lt;li&gt;per-frame generation;&lt;/li&gt;
&lt;li&gt;intermediate compression;&lt;/li&gt;
&lt;li&gt;final video encoding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main thread can own an &lt;code&gt;AbortController&lt;/code&gt; and send a request-scoped cancel message to the worker:&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&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;cancel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;requestId&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 worker checks cancellation during download loops, before and after inference, between frames, and when switching requests. A canceled task does not continue into encoding and never adds a partial result to the asset library.&lt;/p&gt;

&lt;p&gt;Without this propagation, the UI may report that a task ended while the GPU and CPU continue working in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Report cold and warm performance separately
&lt;/h2&gt;

&lt;p&gt;"Completed in seconds" is not a reproducible benchmark.&lt;/p&gt;

&lt;p&gt;A browser AI performance report should capture at least:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Device:
GPU:
Operating system:
Browser and version:
WebGPU adapter:
Video codec:
Video resolution:
Video duration:
Output FPS:
Detection-anchor FPS:
First run:
Model initialization time:
Per-frame processing time:
Generator cumulative inference time:
Encoding time:
Peak memory:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should also separate two scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cold start
&lt;/h3&gt;

&lt;p&gt;Cold start includes model download, file validation, ONNX session creation, shader compilation, source-identity extraction, video processing, and encoding. It measures distribution and device initialization as much as inference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Warm start
&lt;/h3&gt;

&lt;p&gt;Warm start assumes that models are cached and sessions and source-identity weights can be reused. It measures video decoding, anchor detection, optical flow, generation, post-processing, and encoding.&lt;/p&gt;

&lt;p&gt;Warm-start performance is closer to the experience of editing several clips on the same page. Cold and warm numbers should not be mixed, and a report should not publish only the faster one.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Local processing does not remove consent and safety obligations
&lt;/h2&gt;

&lt;p&gt;Keeping inference in the browser can reduce the need to upload source media to a remote inference service. It does not remove the responsibilities attached to face images and synthetic output.&lt;/p&gt;

&lt;p&gt;Before releasing or operating this kind of feature, verify that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the depicted people and rights holders authorized the intended use;&lt;/li&gt;
&lt;li&gt;photos, videos, model weights, and sample code are used under valid licenses;&lt;/li&gt;
&lt;li&gt;identifiable face images and embeddings are handled lawfully and securely;&lt;/li&gt;
&lt;li&gt;generated output cannot easily be mistaken for real footage, news, or a person's actual behavior;&lt;/li&gt;
&lt;li&gt;AI alteration is disclosed in a manner appropriate to the use case;&lt;/li&gt;
&lt;li&gt;minors, public figures, and third parties are not used without authorization;&lt;/li&gt;
&lt;li&gt;the workflow is not being used for fraud, impersonation, harassment, defamation, or sexual abuse;&lt;/li&gt;
&lt;li&gt;users can cancel processing and remove generated results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The safest failure mode is to emit no effect when target identity cannot be maintained reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Browser-local video face swapping is not a matter of dropping an ONNX file into a web page. Moving from a demo to a repeatable workflow requires at least four kinds of engineering:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bound the computation:&lt;/strong&gt; run models only at the required resolution and on the required ROI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule resources deliberately:&lt;/strong&gt; combine parallel downloads, serial session initialization, and transferable buffers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure result confidence:&lt;/strong&gt; reject bad optical-flow tracks, temporal identity switches, and poor masks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manage the runtime:&lt;/strong&gt; pin model versions, release heterogeneous resources, and propagate cancellation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WebGPU provides the compute, but the surrounding system determines whether that compute becomes a stable user experience.&lt;/p&gt;

&lt;p&gt;The same lessons apply to browser-local person segmentation, super-resolution, object tracking, video restoration, and style transfer.&lt;/p&gt;

&lt;p&gt;You can explore the implementation in the &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio repository&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: AI assistance was used for translation and editorial restructuring of this article. The technical content is based on the linked project implementation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgpu</category>
      <category>javascript</category>
      <category>machinelearning</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Browser-Only Object Outlines with Detection, Prompted Segmentation, and Optical Flow</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Thu, 30 Jul 2026 08:02:23 +0000</pubDate>
      <link>https://dev.to/martindelophy/building-browser-only-object-outlines-with-detection-prompted-segmentation-and-optical-flow-2bje</link>
      <guid>https://dev.to/martindelophy/building-browser-only-object-outlines-with-detection-prompted-segmentation-and-optical-flow-2bje</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I recently added an object-outline effect to &lt;strong&gt;Timeline Studio&lt;/strong&gt;, an open-source browser-based video editor. The feature can isolate objects such as shoes, products, or toys and render a reusable outline around them—without uploading the source image or video to a server.&lt;/p&gt;

&lt;p&gt;The complete implementation is available on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post focuses on three engineering decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;separating object detection from pixel-level segmentation;&lt;/li&gt;
&lt;li&gt;keeping the same object identity across video frames;&lt;/li&gt;
&lt;li&gt;rejecting unreliable masks instead of propagating bad results.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The processing pipeline
&lt;/h2&gt;

&lt;p&gt;A conventional edge detector is not enough. It also picks up background texture, shadows, and internal details. I split the workflow into distinct stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Detect candidate objects
        ↓
Select and lock the editing target
        ↓
Create a prompt point inside the target
        ↓
Generate a pixel-level mask
        ↓
Validate mask quality
        ↓
Cache alpha and render the outline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detector answers &lt;em&gt;where the object is&lt;/em&gt;. Prompted segmentation answers &lt;em&gt;which pixels belong to it&lt;/em&gt;. Keeping these responsibilities separate makes target selection more stable in crowded scenes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run inference outside the UI thread
&lt;/h2&gt;

&lt;p&gt;Model inference on the main thread can freeze React controls and timeline interaction. Detection runs in a Web Worker, while the UI matches asynchronous responses by request ID.&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;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;requestId&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;detect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scoreThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.24&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 ONNX Runtime session prefers WebGPU and falls back to WASM when GPU initialization is unavailable or fails:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelBytes&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferenceSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;executionProviders&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;webgpu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;graphOptimizationLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WebGPU failed; falling back to WASM&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;return&lt;/span&gt; &lt;span class="nx"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferenceSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;executionProviders&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;wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;graphOptimizationLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&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;This keeps the fast path on supported devices without making WebGPU a hard requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selecting and locking the target
&lt;/h2&gt;

&lt;p&gt;Choosing the highest-confidence detection on every frame can make the effect jump to another object. I rank candidates using confidence, area, distance from the center, overlap with the previous target, and class continuity.&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;function&lt;/span&gt; &lt;span class="nf"&gt;calculateRank&lt;/span&gt;&lt;span class="p"&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;previous&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;area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getArea&lt;/span&gt;&lt;span class="p"&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;box&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;centered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCenterScore&lt;/span&gt;&lt;span class="p"&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;box&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;previousIoU&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;boxIoU&lt;/span&gt;&lt;span class="p"&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;box&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;box&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;identityBonus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&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;label&lt;/span&gt; &lt;span class="p"&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="mi"&gt;0&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="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.55&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;centered&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;previousIoU&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;identityBonus&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;For video, overlap with the previously accepted box receives a larger weight. A new object entering the frame should not silently become the editing target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompted segmentation and alpha generation
&lt;/h2&gt;

&lt;p&gt;Once the target is locked, I convert its center into a normalized prompt point and pass it to the segmentation model.&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;keypoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;point&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&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;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;point&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&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;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;probabilities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidenceMasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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="nf"&gt;getAsFloat32Array&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;alpha&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;Uint8ClampedArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probabilities&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;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;probabilities&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probabilities&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="mi"&gt;0&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;alpha&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;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;threshold&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;round&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="mi"&gt;255&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw output is never trusted immediately. I validate mask area, connected components, target-box overflow, and changes relative to the previous accepted frame. A mask that includes background or another object is rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sparse anchors plus optical flow
&lt;/h2&gt;

&lt;p&gt;Running detection and segmentation on every video frame is expensive in a browser. Full analysis runs only on sparse anchor frames; optical flow propagates the alpha between anchors.&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;needsAnchor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastAnchorIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;anchorInterval&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;trackedMaskBox&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;needsAnchor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currentAlpha&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;analyzeObjectAnchor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;lastAnchorIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&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;currentAlpha&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;propagateAlphaWithFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;previousFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;currentFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;currentAlpha&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;If the propagated area changes implausibly, tracking is discarded and detection starts again:&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;areaRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextArea&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;previousArea&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;nextBox&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;areaRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;areaRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currentAlpha&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="nx"&gt;trackedMaskBox&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failing closed matters here. Showing no effect for one uncertain frame is safer than legitimizing a wrong mask and carrying it forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate AI analysis from style rendering
&lt;/h2&gt;

&lt;p&gt;Analysis is expensive, but changing color, width, glow, or material should feel immediate. I store the analyzed alpha separately from the effect style:&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;objectOutline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;targetKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#f3efe4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;opacity&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="na"&gt;glow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;material&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paper&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;edgeDensity&lt;/span&gt;&lt;span class="p"&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="na"&gt;shadowDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.32&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;Style edits recompose the cached alpha instead of rerunning the models. Preview and export consume the same alpha and effect state, which reduces visual differences between the editor and the final video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;A browser-native object-outline tool is not a single-model feature. It is a pipeline built from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Worker-based inference;&lt;/li&gt;
&lt;li&gt;WebGPU with a WASM fallback;&lt;/li&gt;
&lt;li&gt;object detection plus prompted segmentation;&lt;/li&gt;
&lt;li&gt;temporal target locking;&lt;/li&gt;
&lt;li&gt;sparse anchors and optical flow;&lt;/li&gt;
&lt;li&gt;conservative quality gates;&lt;/li&gt;
&lt;li&gt;separate analysis and rendering state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, these pieces make image and video outlines editable while keeping the original media on the user's device.&lt;/p&gt;

&lt;p&gt;Explore the implementation and try the editor on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webgpu</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building Stable Video Portrait Outlines in the Browser with MODNet, SlimSAM, MediaPipe, and Optical Flow</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Wed, 29 Jul 2026 09:58:29 +0000</pubDate>
      <link>https://dev.to/martindelophy/building-stable-video-portrait-outlines-in-the-browser-with-modnet-slimsam-mediapipe-and-optical-bh9</link>
      <guid>https://dev.to/martindelophy/building-stable-video-portrait-outlines-in-the-browser-with-modnet-slimsam-mediapipe-and-optical-bh9</guid>
      <description>&lt;p&gt;A portrait outline effect looks simple: find a person and draw a line around them. In real video, however, the problem quickly expands into subject identity, fine matting, temporal stability, occlusion, model latency, and memory pressure.&lt;/p&gt;

&lt;p&gt;This article walks through the portrait-outline pipeline currently being developed for &lt;strong&gt;Timeline Studio&lt;/strong&gt;, an open-source, local-first video editor that runs directly in the browser. The implementation combines YOLOS tiny, MODNet, MediaPipe, SlimSAM, and OpenCV Farneback optical flow. Source footage stays on the user's device during analysis and preview.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live editor: &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why not run MODNet on every frame?
&lt;/h2&gt;

&lt;p&gt;The first prototype was straightforward: run portrait matting on every sampled frame, extract the alpha boundary, and render an outline. It worked for still frames, but an editor-quality implementation exposed four problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Full inference at 25 or 30 FPS is expensive in a browser.&lt;/li&gt;
&lt;li&gt;Independent predictions make hair and clothing edges flicker.&lt;/li&gt;
&lt;li&gt;Nearby people, sofas, and cushions can leak into the portrait mask.&lt;/li&gt;
&lt;li&gt;Style changes should not require the AI pipeline to run again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The solution was to split the job into subject locking, high-quality anchor matting, fast subject gating, guarded segmentation recovery, and temporal propagation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Video decoding
  ↓
YOLOS tiny detects and locks one person
  ↓
MODNet produces an alpha inside the person ROI
  ↓
MediaPipe constrains the subject and removes spill
  ↓
SlimSAM repairs difficult anchors only when needed
  ↓
Farneback optical flow propagates alpha between anchors
  ↓
Area, position, and motion quality checks
  ↓
SVG filters render a material-aware outline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each component has one primary responsibility:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YOLOS tiny&lt;/td&gt;
&lt;td&gt;Decide which person is the effect target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MODNet&lt;/td&gt;
&lt;td&gt;Produce a soft portrait alpha with fine edges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MediaPipe&lt;/td&gt;
&lt;td&gt;Refresh the person region quickly and suppress spill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SlimSAM&lt;/td&gt;
&lt;td&gt;Recover difficult segmentation anchors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Farneback optical flow&lt;/td&gt;
&lt;td&gt;Propagate accepted alpha masks through time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Lock the target person with YOLOS tiny
&lt;/h2&gt;

&lt;p&gt;Portrait matting answers which pixels look like a person, but it does not necessarily answer which person the user wants in a crowded scene.&lt;/p&gt;

&lt;p&gt;The pipeline starts with YOLOS tiny and ranks person detections using confidence, box area, body completeness, and the previous tracked location. Once a primary person is selected, the downstream MODNet, MediaPipe, and SlimSAM stages operate around that person's ROI.&lt;/p&gt;

&lt;p&gt;This identity-first step is important when the main person is touching a sofa, sitting beside another person, or surrounded by background people.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Use MODNet as the alpha-quality baseline
&lt;/h2&gt;

&lt;p&gt;A binary segmentation mask is usually not enough for a polished outline. Hair, clothing, and motion-blurred boundaries contain partially transparent pixels. If those values are collapsed to only zero and one, the rendered outline becomes jagged and unstable.&lt;/p&gt;

&lt;p&gt;Timeline Studio runs MODNet on an expanded person ROI rather than the full source frame. On browsers with WebGPU, the worker uses the WebGPU path; otherwise it falls back to quantized WASM.&lt;/p&gt;

&lt;p&gt;Processing an ROI lowers the amount of work while preserving more useful resolution around the target person.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Use MediaPipe as a fast soft gate
&lt;/h2&gt;

&lt;p&gt;MediaPipe person segmentation is fast and mobile-friendly, but it does not replace MODNet for every fine-edge case. Instead, the pipeline uses it as a fast subject constraint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;refresh the approximate person region between expensive anchors,&lt;/li&gt;
&lt;li&gt;remove sofa or background spill from the MODNet alpha,&lt;/li&gt;
&lt;li&gt;reduce contamination from nearby people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MediaPipe confidence mask is slightly dilated and blurred before it is applied to MODNet:&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;fusedAlpha&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;=&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modnetAlpha&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;*&lt;/span&gt; &lt;span class="nx"&gt;mediaPipeGate&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;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A soft gate is deliberate. A hard binary intersection would easily remove hair strands, fingers, and translucent clothing edges. MediaPipe also has a GPU-to-CPU fallback, while the rest of the pipeline continues if MediaPipe is unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Run SlimSAM only as a guarded fallback
&lt;/h2&gt;

&lt;p&gt;SlimSAM is not part of every frame. It is invoked only when an anchor fails quality validation—for example, when MODNet produces an incomplete person, the connected component no longer matches the detector box, or the mask includes a large spill region.&lt;/p&gt;

&lt;p&gt;The SlimSAM request includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the YOLOS person box,&lt;/li&gt;
&lt;li&gt;a positive point inside the selected person,&lt;/li&gt;
&lt;li&gt;negative points derived from other detected people,&lt;/li&gt;
&lt;li&gt;negative points derived from suspicious MODNet spill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current worker pins &lt;code&gt;Xenova/slimsam-77-uniform&lt;/code&gt; to a fixed revision. It uses FP16 on WebGPU and quantized WASM when WebGPU is not available.&lt;/p&gt;

&lt;p&gt;Depending on the frame, the SlimSAM mask can be used as the selected subject or as a soft gate over MODNet. That preserves MODNet's smooth alpha edges while benefiting from SAM's prompt-guided subject separation.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Connect frames with Farneback optical flow
&lt;/h2&gt;

&lt;p&gt;Instead of running a heavy model on every sampled frame, an OpenCV.js worker calculates Farneback dense optical flow and warps the previous alpha toward the current frame.&lt;/p&gt;

&lt;p&gt;Optical flow is limited to short temporal windows. The propagated mask is never trusted blindly. The pipeline monitors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sudden mask-area expansion or collapse,&lt;/li&gt;
&lt;li&gt;large position changes relative to tracking history,&lt;/li&gt;
&lt;li&gt;abnormal mean motion,&lt;/li&gt;
&lt;li&gt;ROI fallback to the full frame,&lt;/li&gt;
&lt;li&gt;loss of the selected person.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a check fails, the propagated alpha is rejected and a new model anchor is scheduled early. This prevents one tracking error from contaminating the rest of the clip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current analysis profile
&lt;/h2&gt;

&lt;p&gt;The editor currently calls the portrait pipeline with a profile similar to this:&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;analysis&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;analyzePersonOutlineVideo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;videoSource&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="na"&gt;flowFps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;anchorFps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxSamples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxDimension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onSample&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 means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;effect samples are generated at roughly 8 FPS,&lt;/li&gt;
&lt;li&gt;routine model anchors are approximately 3.5 seconds apart,&lt;/li&gt;
&lt;li&gt;the analysis frame's longest side is capped near 360 pixels,&lt;/li&gt;
&lt;li&gt;one run produces at most 360 samples,&lt;/li&gt;
&lt;li&gt;degraded tracking can trigger an earlier anchor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 30 FPS source does not need 30 full model executions per second for an editor preview. Preview and export resolve the cached analysis sample for the current source timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimizing more than inference
&lt;/h2&gt;

&lt;p&gt;Model inference is only one part of browser video processing. Seeking, decoding, canvas reads, worker copies, and transparent-image encoding can all become bottlenecks.&lt;/p&gt;

&lt;p&gt;The current implementation includes several engineering optimizations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prefer sequential WebCodecs decoding.&lt;/li&gt;
&lt;li&gt;Fall back to precise video seeking for unsupported codecs.&lt;/li&gt;
&lt;li&gt;Warm YOLOS/MODNet, MediaPipe, and the optical-flow worker in parallel.&lt;/li&gt;
&lt;li&gt;Transfer RGBA and alpha buffers with transferable &lt;code&gt;ArrayBuffer&lt;/code&gt; objects.&lt;/li&gt;
&lt;li&gt;Keep initialized models and workers alive for reuse.&lt;/li&gt;
&lt;li&gt;Commit each completed sample to the UI instead of waiting for the whole video.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last point matters for product experience. Users can see the current cutout, outline, timeline position, processed-frame count, and tracking state advance together. The analysis can also be canceled without leaving a hidden task running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning alpha into material-aware outlines
&lt;/h2&gt;

&lt;p&gt;The simplest outside border is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Outline = Dilate(Alpha) - Alpha
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timeline Studio builds on that ring with an SVG filter pipeline using &lt;code&gt;feMorphology&lt;/code&gt;, &lt;code&gt;feComposite&lt;/code&gt;, &lt;code&gt;feTurbulence&lt;/code&gt;, &lt;code&gt;feDisplacementMap&lt;/code&gt;, texture images, &lt;code&gt;feBlend&lt;/code&gt;, &lt;code&gt;feDropShadow&lt;/code&gt;, and &lt;code&gt;feGaussianBlur&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The current material presets are intentionally different from one another:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;crumpled paper,&lt;/li&gt;
&lt;li&gt;heavy frost,&lt;/li&gt;
&lt;li&gt;layered light halo,&lt;/li&gt;
&lt;li&gt;liquid chrome,&lt;/li&gt;
&lt;li&gt;impasto paint,&lt;/li&gt;
&lt;li&gt;ink bleed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Users can tune outline color, width, opacity, grain, texture scale, irregularity, relief, diffusion, ring count, ring spacing, and glow radius.&lt;/p&gt;

&lt;p&gt;The important architectural detail is that the person alpha is cached separately from the visual style. Changing a paper outline to chrome does not rerun YOLOS, MODNet, or optical flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivering browser AI models reliably
&lt;/h2&gt;

&lt;p&gt;The first model download is another practical challenge for local browser AI. Timeline Studio mirrors model artifacts across Hugging Face and ModelScope. Chinese and domestic sessions can prefer ModelScope, with Hugging Face as a fallback, while equivalent files share the same cache identity.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ModelScope: &lt;a href="https://www.modelscope.cn/models/martindelophy/timeline-studio-onnx-models" rel="noopener noreferrer"&gt;https://www.modelscope.cn/models/martindelophy/timeline-studio-onnx-models&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hugging Face: &lt;a href="https://huggingface.co/haixin/timeline-studio-onnx-models" rel="noopener noreferrer"&gt;https://huggingface.co/haixin/timeline-studio-onnx-models&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Models are downloaded on first use and reused from browser storage afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the project locally
&lt;/h2&gt;

&lt;p&gt;Timeline Studio is open source under the MIT License:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/MartinDelophy/ai-video-editor.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ai-video-editor
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use Node.js 20+ and a modern Chromium-based browser. A WebGPU-capable device improves the MODNet and SlimSAM paths.&lt;/p&gt;

&lt;p&gt;For a production build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
npm run preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Current limitations and next steps
&lt;/h2&gt;

&lt;p&gt;The hybrid pipeline improves speed and temporal consistency, but difficult cases remain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;people fully crossing in front of each other,&lt;/li&gt;
&lt;li&gt;a target leaving and re-entering the frame,&lt;/li&gt;
&lt;li&gt;fast shot changes,&lt;/li&gt;
&lt;li&gt;severe motion blur,&lt;/li&gt;
&lt;li&gt;low contrast between hair and background,&lt;/li&gt;
&lt;li&gt;complex overlap between a person and a held object,&lt;/li&gt;
&lt;li&gt;long clips on low-memory mobile devices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next steps include shot-boundary detection, stronger person-ID continuity, manual subject selection, a larger repeatable test set, and extending the same effect system to object outlines.&lt;/p&gt;

&lt;p&gt;The portrait-outline workspace is still under active development, so the repository README, releases, and roadmap remain the source of truth for current availability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live editor: &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ModelScope: &lt;a href="https://www.modelscope.cn/models/martindelophy/timeline-studio-onnx-models" rel="noopener noreferrer"&gt;https://www.modelscope.cn/models/martindelophy/timeline-studio-onnx-models&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are interested in browser AI, WebGPU, ONNX Runtime Web, video matting, optical flow, or local-first video editing, feedback and focused contributions are welcome.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>webgpu</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why Valid SVG Is Not Good SVG: Improving Gemini Nano for Browser-Local Vector Design</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Wed, 29 Jul 2026 02:31:46 +0000</pubDate>
      <link>https://dev.to/martindelophy/why-valid-svg-is-not-good-svg-improving-gemini-nano-for-browser-local-vector-design-2mle</link>
      <guid>https://dev.to/martindelophy/why-valid-svg-is-not-good-svg-improving-gemini-nano-for-browser-local-vector-design-2mle</guid>
      <description>&lt;p&gt;A parser can tell me whether an SVG is valid and safe. It cannot tell me whether the design is good.&lt;/p&gt;

&lt;p&gt;That distinction has become one of the most interesting problems in &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt;, the open-source, local-first video editor I am building for the browser.&lt;/p&gt;

&lt;p&gt;Timeline Studio can generate editable vector graphics with Gemini Nano through Chrome’s built-in Prompt API. The whole workflow stays in the browser: the request is detected and translated locally, the model generates SVG, and the result is sanitized before it is added to the user’s asset library.&lt;/p&gt;

&lt;p&gt;The pipeline works. But “works” is not the same as “produces a useful design.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The current browser-local pipeline
&lt;/h2&gt;

&lt;p&gt;The generation flow is intentionally small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect the actual language of the request.&lt;/li&gt;
&lt;li&gt;Translate non-English input to English in the browser.&lt;/li&gt;
&lt;li&gt;Ask Gemini Nano for one standard SVG document.&lt;/li&gt;
&lt;li&gt;Extract the first complete SVG from Markdown fences or surrounding prose.&lt;/li&gt;
&lt;li&gt;Sanitize elements, attributes, links, scripts, and external resources.&lt;/li&gt;
&lt;li&gt;Normalize the result into a 1200 × 1200 viewBox.&lt;/li&gt;
&lt;li&gt;Add the vector to My Assets without inserting it into the timeline automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shipped code is roughly this simple:&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;raw&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildVectorDesignPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;englishRequest&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;vectorXml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractVectorXml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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;vectorBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitizeGeneratedVectorXml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vectorXml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser is deliberately tolerant. Gemini Nano may return a clean SVG, wrap it in a Markdown code fence, add a sentence before it, or use an older custom wrapper. The extractor handles those cases before the sanitizer applies the real safety boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What validation catches
&lt;/h2&gt;

&lt;p&gt;The current validation layer is good at rejecting structural and security problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing or malformed SVG markup&lt;/li&gt;
&lt;li&gt;scripts and event handlers&lt;/li&gt;
&lt;li&gt;unsafe or unsupported elements&lt;/li&gt;
&lt;li&gt;external URLs and embedded remote content&lt;/li&gt;
&lt;li&gt;attributes that do not belong in an editable vector asset&lt;/li&gt;
&lt;li&gt;output that cannot be normalized into the editor’s coordinate system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is necessary. Generated markup should never be trusted just because it came from a local model.&lt;/p&gt;

&lt;p&gt;But a safe SVG can still be a bad SVG.&lt;/p&gt;

&lt;h2&gt;
  
  
  What validation misses
&lt;/h2&gt;

&lt;p&gt;Visual quality failures are much harder to express as pass or fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the artwork is clipped even though the viewBox is valid&lt;/li&gt;
&lt;li&gt;the composition technically matches the request but feels generic&lt;/li&gt;
&lt;li&gt;a supposedly transparent asset contains a full-canvas background rectangle&lt;/li&gt;
&lt;li&gt;the illustration is almost empty or uses too few meaningful shapes&lt;/li&gt;
&lt;li&gt;dozens of tiny shapes make the vector difficult to edit&lt;/li&gt;
&lt;li&gt;groups exist, but their IDs and hierarchy are not useful&lt;/li&gt;
&lt;li&gt;typography, spacing, or contrast makes the asset unusable in a video&lt;/li&gt;
&lt;li&gt;translation preserves the words but loses the design intent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these necessarily produces invalid XML. A sanitizer can accept all of them.&lt;/p&gt;

&lt;p&gt;This is the part I want to improve next.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reproducible SVG quality benchmark
&lt;/h2&gt;

&lt;p&gt;Before changing prompts or adding repair logic, I want a small benchmark that makes regressions visible.&lt;/p&gt;

&lt;p&gt;The first version should contain at least 24 prompts across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;icons and simple symbols&lt;/li&gt;
&lt;li&gt;lower thirds and title graphics&lt;/li&gt;
&lt;li&gt;charts and infographic elements&lt;/li&gt;
&lt;li&gt;callouts, labels, and badges&lt;/li&gt;
&lt;li&gt;frames and mask-like compositions&lt;/li&gt;
&lt;li&gt;abstract motion-graphics assets&lt;/li&gt;
&lt;li&gt;at least four non-English requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each result can be reviewed with a compact human rubric:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request fidelity — does the image represent what was asked for?&lt;/li&gt;
&lt;li&gt;Composition — are spacing, balance, and hierarchy usable?&lt;/li&gt;
&lt;li&gt;Polish — does it look intentional rather than merely generated?&lt;/li&gt;
&lt;li&gt;Editability — are groups and shapes structured for later editing?&lt;/li&gt;
&lt;li&gt;Safety and transparency — does it stay within the editor’s SVG rules?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal is not to invent one magical quality score. It is to create a repeatable set of prompts and examples so that a prompt change can be compared with the previous behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic checks before another model call
&lt;/h2&gt;

&lt;p&gt;Some visual failures can be approximated cheaply without running a second inference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;empty or nearly empty visible bounds&lt;/li&gt;
&lt;li&gt;shapes extending far outside the viewBox&lt;/li&gt;
&lt;li&gt;a large opaque element covering almost the entire canvas&lt;/li&gt;
&lt;li&gt;extreme coordinate values&lt;/li&gt;
&lt;li&gt;excessive shape or filter complexity&lt;/li&gt;
&lt;li&gt;duplicate, missing, or unhelpful group IDs&lt;/li&gt;
&lt;li&gt;content clustered into a tiny fraction of the canvas&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks will never replace visual review, but they can produce a machine-readable report. More importantly, they can explain a failure instead of returning a vague “generation failed” message.&lt;/p&gt;

&lt;h2&gt;
  
  
  One bounded local repair pass
&lt;/h2&gt;

&lt;p&gt;The next experiment is a single optional repair attempt. This is a proposal, not a shipped feature yet:&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;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;analyzeSvgQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vectorXml&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;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repairable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;experiments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;svgRepair&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;repaired&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;repairOnce&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;englishRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vectorXml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;report&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="nf"&gt;sanitizeGeneratedVectorXml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;extractVectorXml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repaired&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;scope&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 repair prompt would receive the original English request, the generated SVG, and a short list of deterministic warnings. It would keep the same element and safety constraints and would run at most once.&lt;/p&gt;

&lt;p&gt;Why only once?&lt;/p&gt;

&lt;p&gt;Because an unlimited generate-check-repair loop is unpredictable on a user’s laptop. Browser-local software has to respect memory, battery, model latency, cancellation, and weaker hardware. A bounded repair step is easier to understand, test, and disable.&lt;/p&gt;

&lt;p&gt;The repaired result must also pass through the exact same extractor and sanitizer. Repair is not a security exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the local-first constraint matters
&lt;/h2&gt;

&lt;p&gt;Using a hosted vision model could make evaluation and repair easier, but it would change the product boundary. Timeline Studio is exploring what a capable editor can do without uploading project media or design prompts to a backend.&lt;/p&gt;

&lt;p&gt;That creates useful engineering constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no server-side SVG cleanup service&lt;/li&gt;
&lt;li&gt;no hidden cloud fallback&lt;/li&gt;
&lt;li&gt;no unbounded retries&lt;/li&gt;
&lt;li&gt;clear errors when browser AI or local translation is unavailable&lt;/li&gt;
&lt;li&gt;generated assets remain editable and are never silently placed on the timeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The constraint is not just about privacy. It forces the quality system to be understandable and reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  I am looking for contributors
&lt;/h2&gt;

&lt;p&gt;I opened two research issues for this work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor/issues/52" rel="noopener noreferrer"&gt;Research spike: improve browser-local AI SVG generation quality&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor/issues/53" rel="noopener noreferrer"&gt;Prototype an SVG quality checker and local repair pass for Gemini Nano&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are good contributions for someone interested in browser AI, SVG internals, evaluation, prompt design, or developer tooling. You do not need to solve the entire system. A useful prompt set, a measurable static check, a failure taxonomy, or a small repair prototype would already move the project forward.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;try Timeline Studio in the browser&lt;/a&gt; or explore the &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have worked on generated SVG quality before, I would especially like to hear how you separate “valid output” from “useful design.”&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>javascript</category>
    </item>
    <item>
      <title>ブラウザだけで動画を4倍高画質化する：NanoVSR WebGPUの実装</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Tue, 28 Jul 2026 06:42:42 +0000</pubDate>
      <link>https://dev.to/martindelophy/burauzadakededong-hua-wo4bei-gao-hua-zhi-hua-surunanovsr-x-webgpunoshi-zhuang-1i64</link>
      <guid>https://dev.to/martindelophy/burauzadakededong-hua-wo4bei-gao-hua-zhi-hua-surunanovsr-x-webgpunoshi-zhuang-1i64</guid>
      <description>&lt;p&gt;動画の超解像はサーバーGPUで処理する構成が一般的ですが、動画のアップロード時間やプライバシー、GPUコストが課題になります。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt; では、NanoVSR・ONNX Runtime Web・WebGPU・FFmpeg.wasmを組み合わせ、動画の4倍高画質化をブラウザ内で完結させています。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo: &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  処理パイプライン
&lt;/h2&gt;

&lt;p&gt;全体の流れは次のとおりです。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;動画を読み込む
  ↓
12fpsでフレームを抽出
  ↓
5フレーム単位でNanoVSRへ入力
  ↓
WebGPUで4倍超解像
  ↓
PNGフレームを生成
  ↓
FFmpeg.wasmでH.264/AACのMP4に再構成
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;素材は端末内で処理され、動画フレームをサーバーへ送信しません。&lt;/p&gt;

&lt;h2&gt;
  
  
  1枚ずつではなく、5フレームをまとめて処理する
&lt;/h2&gt;

&lt;p&gt;画像用モデルは1フレームを入力しますが、動画用モデルは5フレームの時系列ウィンドウを使用します。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;th&gt;入力&lt;/th&gt;
&lt;th&gt;出力&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;画像&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1, 1, 3, 180, 320]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1, 1, 3, 720, 1280]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;動画&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1, 5, 3, 180, 320]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1, 5, 3, 720, 1280]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;単純な1フレーム超解像では、髪や細線などの生成結果がフレームごとに変わり、再生時にちらつきやすくなります。隣接する5フレームを同時に参照することで、時間方向の一貫性を改善します。&lt;/p&gt;

&lt;p&gt;実装では動画全体を一度にGPUへ渡さず、5フレームずつ処理します。これにより、GPUメモリのピークを抑え、キャンセルにも比較的早く反応できます。&lt;/p&gt;

&lt;h2&gt;
  
  
  Web Workerで推論を分離する
&lt;/h2&gt;

&lt;p&gt;ONNX推論をUIスレッドで実行すると、Reactの描画やタイムライン操作が重くなります。そのため推論処理は専用のWeb Workerへ分離しています。&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;session&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;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferenceSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;executionProviders&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;webgpu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;graphOptimizationLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&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;メインスレッドからは &lt;code&gt;ImageBitmap&lt;/code&gt; をTransferableとして送信し、余分なピクセルコピーを避けます。処理後はBitmapとONNX Tensorを明示的に解放します。&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;bitmap&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ブラウザ動画処理では、モデルだけでなくBitmap、Canvas、GPU Bufferの解放も重要です。&lt;/p&gt;

&lt;h2&gt;
  
  
  アスペクト比を維持する
&lt;/h2&gt;

&lt;p&gt;モデル入力は320×180固定ですが、実際の素材には縦動画や正方形動画もあります。&lt;/p&gt;

&lt;p&gt;Timeline Studioでは元画像を引き伸ばさず、320×180へcontain配置します。推論後の1280×720画像から有効領域だけを切り出すことで、元のアスペクト比を維持しています。&lt;/p&gt;

&lt;p&gt;また、入力素材がすでにモデルの有効出力より大きい場合は、原画像の解像度を保護し、AI結果と元画像のディテールを保守的に合成します。&lt;/p&gt;

&lt;h2&gt;
  
  
  モデルは固定Revisionでキャッシュする
&lt;/h2&gt;

&lt;p&gt;モデルはHugging FaceとModelScopeの両方から取得でき、中国語環境ではModelScopeを優先します。&lt;/p&gt;

&lt;p&gt;ただしキャッシュキーは配信元URLではなく、リポジトリ・Revision・モデルパスから生成します。ミラーが切り替わっても同じモデルを再ダウンロードしません。&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/__model-cache__/haixin/timeline-studio-onnx-models/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="nx"&gt;MODEL_REVISION&lt;/span&gt; &lt;span class="o"&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;本番環境では &lt;code&gt;main&lt;/code&gt; ではなく不変のコミットRevisionへ固定しています。ONNX SessionもWorker内で再利用するため、2回目以降の実行ではダウンロードと初期化を繰り返しません。&lt;/p&gt;

&lt;h2&gt;
  
  
  元の音声を残してMP4を生成する
&lt;/h2&gt;

&lt;p&gt;超解像モデルが返すのは画像フレームです。そこで生成したPNGをFFmpeg.wasmへ渡し、H.264動画としてエンコードします。&lt;/p&gt;

&lt;p&gt;元動画に音声があれば、クリップの開始位置と長さに合わせて音声を切り出し、AAC 192kbpsで再結合します。&lt;/p&gt;

&lt;p&gt;最終的な出力は次の構成です。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;H.264 / CRF 18&lt;/li&gt;
&lt;li&gt;AAC 192kbps&lt;/li&gt;
&lt;li&gt;yuv420p&lt;/li&gt;
&lt;li&gt;faststart MP4&lt;/li&gt;
&lt;li&gt;処理フレームレートは12fps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;これにより、Canvas上のプレビューだけでなく、音声付きのMP4として編集タイムラインへ戻せます。&lt;/p&gt;

&lt;h2&gt;
  
  
  現在の制約
&lt;/h2&gt;

&lt;p&gt;現在の実装には次の制約があります。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebGPU対応ブラウザが必要&lt;/li&gt;
&lt;li&gt;処理フレームレートは12fps&lt;/li&gt;
&lt;li&gt;モデル入力は320×180固定&lt;/li&gt;
&lt;li&gt;PNG中間フレームとFFmpeg.wasmが長尺動画でメモリを消費する&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;今後は &lt;code&gt;VideoFrame → WebGPU → WebCodecs&lt;/code&gt; のストリーミング構成に移行することで、PNG中間ファイルを減らし、より長い動画に対応できます。&lt;/p&gt;

&lt;h2&gt;
  
  
  まとめ
&lt;/h2&gt;

&lt;p&gt;ブラウザ動画超解像では、モデルを動かすだけでなく、フレーム抽出、時系列処理、リソース解放、モデルキャッシュ、音声保持、再エンコードまで一つのパイプラインとして設計する必要があります。&lt;/p&gt;

&lt;p&gt;Timeline Studioでは、この一連の処理をローカルファーストな動画編集機能として実装しています。WebGPUやブラウザAIに興味があれば、ぜひリポジトリをチェックしてください。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Removing Video Watermarks Entirely in the Browser with MI-GAN, ONNX Runtime Web, and WebGPU</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:45:31 +0000</pubDate>
      <link>https://dev.to/martindelophy/removing-video-watermarks-entirely-in-the-browser-with-mi-gan-onnx-runtime-web-and-webgpu-1jdi</link>
      <guid>https://dev.to/martindelophy/removing-video-watermarks-entirely-in-the-browser-with-mi-gan-onnx-runtime-web-and-webgpu-1jdi</guid>
      <description>&lt;p&gt;Watermark and unwanted-object removal is one of those features that looks simple in a model demo and becomes much more complicated when you put it inside a real video editor.&lt;/p&gt;

&lt;p&gt;The obvious implementation is to upload the media to a GPU server, run an inpainting model, and return the result. That works, but it introduces upload latency, infrastructure cost, and privacy concerns—especially for large or unpublished videos.&lt;/p&gt;

&lt;p&gt;For our browser video editor, we took a different approach:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Run MI-GAN locally with ONNX Runtime Web and WebGPU, so images and videos never need to leave the user's device.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;video-editor.ai-creator.top&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article covers more than model inference. It explains the product-level work required for multiple repair regions, time ranges, moving watermarks, progressive frame previews, before/after comparison, cancellation, and final video encoding.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use object-removal tools only on media you own or are authorized to edit. Removing ownership or attribution marks from third-party content may violate licenses or platform rules.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why browser-local repair?
&lt;/h2&gt;

&lt;p&gt;A server-side pipeline usually requires:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Uploading the original media.&lt;/li&gt;
&lt;li&gt;Waiting for a large video transfer.&lt;/li&gt;
&lt;li&gt;Paying for storage and GPU compute.&lt;/li&gt;
&lt;li&gt;Sending potentially sensitive footage to another system.&lt;/li&gt;
&lt;li&gt;Depending on a reliable network connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Browser-local inference moves the initial cost to downloading the model. Once the runtime and model artifacts are cached, repeated edits are much faster to start, and the original media remains local.&lt;/p&gt;

&lt;p&gt;Our implementation uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MI-GAN&lt;/strong&gt; for lightweight image inpainting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ONNX Runtime Web&lt;/strong&gt; for browser inference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGPU&lt;/strong&gt; as the preferred execution backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers&lt;/strong&gt; to keep inference off the UI thread&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas / OffscreenCanvas&lt;/strong&gt; for frame extraction, masks, and compositing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebCodecs or a compatibility encoder path&lt;/strong&gt; for the final video&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache Storage&lt;/strong&gt; to avoid downloading the model again&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The complete pipeline
&lt;/h2&gt;

&lt;p&gt;Image repair is a single inference task. Video repair has a time axis, so the pipeline is substantially different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source media
  -&amp;gt; user defines repair regions and time ranges
  -&amp;gt; resolve regions active at the current timestamp
  -&amp;gt; decode a video frame
  -&amp;gt; build a mask
  -&amp;gt; run MI-GAN inference
  -&amp;gt; composite the repaired pixels over the source frame
  -&amp;gt; commit the progressive preview
  -&amp;gt; encode the processed frame
  -&amp;gt; create a new media asset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model is only one stage. A production-quality experience also requires the canvas, playhead, processed-frame count, and progress indicator to share the same clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image repair with normalized regions
&lt;/h2&gt;

&lt;p&gt;In image mode, the user draws one or more rectangles over the canvas. We store each rectangle in normalized coordinates so the selection remains correct across different preview sizes.&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;function&lt;/span&gt; &lt;span class="nf"&gt;createMask&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;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regions&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;canvas&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;OffscreenCanvas&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;height&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;context&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="nx"&gt;context&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;context&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="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;width&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="nx"&gt;context&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;region&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;regions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;context&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;region&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&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;region&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;region&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;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;region&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;height&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="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&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;width&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each region uses values between &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;. The same rectangle can therefore be mapped back to the full-resolution source instead of the CSS-sized preview.&lt;/p&gt;

&lt;p&gt;After inference, we do not immediately commit the result. The editor displays a draggable vertical comparison line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ComparisonView&lt;/span&gt;
  &lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;repairedUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;comparePosition&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onPositionChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setComparePosition&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction matters because inpainting does not recover a hidden ground truth. It synthesizes plausible pixels from the surrounding context, so users need a clear way to inspect the result before applying it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video repair is a time-range problem
&lt;/h2&gt;

&lt;p&gt;Our first video prototype repaired only the frame currently visible in the editor. It looked correct during a single-frame test but was obviously wrong for a video.&lt;/p&gt;

&lt;p&gt;A watermark exists for a time range, not for one frame. It may also move: for example, it can appear in the bottom-left corner for the first few seconds and later switch to the top-right.&lt;/p&gt;

&lt;p&gt;We represent every repair region as an independent time-scoped object:&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;repairRegion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;region-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;startTime&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="na"&gt;endTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;keyframes&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;time&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="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.10&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 rectangle between two recorded positions is interpolated:&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;function&lt;/span&gt; &lt;span class="nf"&gt;interpolateRegion&lt;/span&gt;&lt;span class="p"&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;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ratio&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="na"&gt;x&lt;/span&gt;&lt;span class="p"&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;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&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;x&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;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&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;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&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;y&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;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&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;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&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;width&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;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&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;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&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;height&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;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&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;This supports fixed watermarks, watermarks that jump to another corner, and slowly moving overlays without creating a separate repair job for every frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiple regions for both images and videos
&lt;/h2&gt;

&lt;p&gt;Real media may contain a logo and a text overlay at the same time. Both image and video workflows therefore need multiple selections.&lt;/p&gt;

&lt;p&gt;At each video timestamp, we resolve all active regions and combine them into one mask:&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;activeRegions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;regions&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;region&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;return&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endTime&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;region&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;resolveRegionAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This data model also gives us a practical editing interface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every region has its own start and end time.&lt;/li&gt;
&lt;li&gt;A selected region can be moved and resized independently.&lt;/li&gt;
&lt;li&gt;Positions can be recorded at different timestamps.&lt;/li&gt;
&lt;li&gt;Individual regions can be deleted.&lt;/li&gt;
&lt;li&gt;Region edits can participate in undo and redo history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping MI-GAN off the main thread
&lt;/h2&gt;

&lt;p&gt;Creating the model session or running inference on the main thread can freeze the entire editor. We keep a long-lived model session inside a Web Worker and transfer frame data to it.&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;session&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;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferenceSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;executionProviders&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;webgpu&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;wasm&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;WebGPU is preferred. If it is unavailable or initialization fails, we explicitly fall back to WASM and report the actual backend in the 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="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&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;repair&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;imageBitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;regions&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;imageBitmap&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 Worker and inference session remain alive for later requests. Model and runtime assets are stored in a versioned Cache Storage entry, so a second repair should never present itself as another model download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preprocessing and compositing without degrading the full frame
&lt;/h2&gt;

&lt;p&gt;Inpainting models commonly expect a fixed input size. Resizing and regenerating the entire video frame for a small watermark can unintentionally change faces, text, and fine texture outside the selected area.&lt;/p&gt;

&lt;p&gt;Our processing rules are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Transform the frame and mask into the model's expected input.&lt;/li&gt;
&lt;li&gt;Include enough context around the masked area.&lt;/li&gt;
&lt;li&gt;Restore only the required patch at source resolution.&lt;/li&gt;
&lt;li&gt;Preserve every pixel outside the repair mask.&lt;/li&gt;
&lt;li&gt;Blend the mask boundary to avoid a visible seam.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalFrame&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;source-over&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;repairedPatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;patchX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;patchY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;patchWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;patchHeight&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping regions reasonably tight improves both performance and output stability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subtle bug: progress and preview were out of sync
&lt;/h2&gt;

&lt;p&gt;An early version used three independent notions of time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Progress&lt;/strong&gt; followed the frame-processing loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview&lt;/strong&gt; followed the frame the browser had actually decoded and rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeline&lt;/strong&gt; followed a separate playback clock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result was confusing: the progress bar might show 40% while the preview was still near 25%.&lt;/p&gt;

&lt;p&gt;The fix was to make the &lt;strong&gt;committed visible frame the single UI clock&lt;/strong&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="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;frameIndex&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;frameIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalFrames&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;frameIndex&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&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;decodeFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frameIndex&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;repairedFrame&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;repairFrameIfNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frameIndex&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;onFrameCommitted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repairedFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frameIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;reportProgress&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;completedFrames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frameIndex&lt;/span&gt; &lt;span class="o"&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;totalFrames&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;Frames outside every repair range must still call &lt;code&gt;onFrameCommitted&lt;/code&gt;. Otherwise, the preview and playhead appear frozen whenever inference is skipped.&lt;/p&gt;

&lt;p&gt;We also decode the progressive preview image and give the browser one paint before advancing the completed-frame count:&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;setPreviewFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&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;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;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;setCompletedFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frameIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During processing, the left side now displays the source frame for the current timestamp while the right side displays the repaired frame for that same timestamp. The comparison line stays interactive throughout the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate repair progress from encoding progress
&lt;/h2&gt;

&lt;p&gt;Reaching 100% repaired frames does not mean the output file is ready. We still need to initialize the encoder, encode the frames, and create the final media asset.&lt;/p&gt;

&lt;p&gt;We expose these as distinct phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Preparing the model
2. Repairing frames
3. Loading the video encoder
4. Encoding the repaired video
5. Creating the new asset
6. Complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trying to compress all of these into a single unexplained percentage makes the UI appear stuck at the end. A phase label tells the user what the browser is actually doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancellation must release real resources
&lt;/h2&gt;

&lt;p&gt;For long videos, cancellation is not optional. Changing the button text to "Cancelling..." is not enough.&lt;/p&gt;

&lt;p&gt;A real cancel path must:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stop scheduling new frame decodes.&lt;/li&gt;
&lt;li&gt;Invalidate pending Worker inference requests.&lt;/li&gt;
&lt;li&gt;Close &lt;code&gt;VideoFrame&lt;/code&gt; and &lt;code&gt;ImageBitmap&lt;/code&gt; objects.&lt;/li&gt;
&lt;li&gt;Revoke temporary Blob URLs.&lt;/li&gt;
&lt;li&gt;Stop encoders and release media resources.&lt;/li&gt;
&lt;li&gt;Avoid downloading or inserting a partial result.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;abortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Repair cancelled&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;AbortError&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;We disable duplicate cancel requests immediately, but return to an editable state only after cleanup has completed. The original media remains unchanged until the user explicitly applies the new result.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dedicated repair workspace works better than a crowded inspector
&lt;/h2&gt;

&lt;p&gt;Our first UI placed all controls in the editor's right-side property panel. That panel quickly became too small for region drawing, time ranges, keyframes, preview, comparison, and progress.&lt;/p&gt;

&lt;p&gt;The final product keeps &lt;strong&gt;AI Repair&lt;/strong&gt; as a lightweight capability entry point and opens the actual work in a larger modal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Top toolbar:&lt;/strong&gt; move, redraw, presets, undo, redo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Center:&lt;/strong&gt; source/repaired comparison canvas&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bottom:&lt;/strong&gt; playback, time scale, range handles, playhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right side:&lt;/strong&gt; repair-region list and time/position controls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Footer:&lt;/strong&gt; cancel, single-frame repair/test, process range, apply result&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mobile layout uses the same mental model in a vertical workspace. Applying closes both the repair modal and the properties drawer; cancelling closes only the modal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance lessons
&lt;/h2&gt;

&lt;p&gt;Browser AI performance varies widely with GPU, resolution, codec, and browser implementation. Rather than promising a universal frame rate, we focused on these principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download model artifacts in parallel and store them in a versioned cache.&lt;/li&gt;
&lt;li&gt;Create ONNX sessions serially and only once.&lt;/li&gt;
&lt;li&gt;Reuse the Worker and session for repeated jobs.&lt;/li&gt;
&lt;li&gt;Skip inference for frames without an active mask.&lt;/li&gt;
&lt;li&gt;Keep repair regions as small as practical.&lt;/li&gt;
&lt;li&gt;Give the UI a chance to paint between committed frames.&lt;/li&gt;
&lt;li&gt;Revoke temporary URLs and close GPU/video resources promptly.&lt;/li&gt;
&lt;li&gt;Fall back to WASM clearly when WebGPU is unavailable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first model load is still a cost, but cached repeat operations become a much better fit for an interactive editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current limitations
&lt;/h2&gt;

&lt;p&gt;MI-GAN is lightweight and fast enough to be practical in a browser, but it is not perfect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A watermark covering a face or detailed text may produce unnatural content.&lt;/li&gt;
&lt;li&gt;Very large selected areas can generate unstable structures.&lt;/li&gt;
&lt;li&gt;Fast motion may reveal temporal inconsistency between frames.&lt;/li&gt;
&lt;li&gt;Processing can be significantly slower without WebGPU.&lt;/li&gt;
&lt;li&gt;Total time includes decoding and encoding, not just model inference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frame-by-frame inpainting does not guarantee temporal consistency. Optical flow, region tracking, and temporally aware restoration models are natural next steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from building this feature was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Successful model inference is only the beginning. The real product is the editing flow that lets users inspect, trust, cancel, and apply the result.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Repairing one image in a demo and shipping multi-region, time-aware, progressively previewed video repair are completely different engineering problems.&lt;/p&gt;

&lt;p&gt;This implementation runs locally in the browser and inserts the result as a new media asset, leaving the original recoverable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;https://github.com/MartinDelophy/ai-video-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;https://video-editor.ai-creator.top/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are experimenting with WebGPU, ONNX Runtime Web, browser-side media processing, or video-editor UX, I hope these implementation details save you a few iterations.&lt;/p&gt;

</description>
      <category>webgpu</category>
      <category>javascript</category>
      <category>machinelearning</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Running AI Music Generation Entirely in the Browser: ONNX, WebGPU, Samplers, and Long Audio Stitching</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Sun, 26 Jul 2026 03:03:41 +0000</pubDate>
      <link>https://dev.to/martindelophy/running-ai-music-generation-entirely-in-the-browser-onnx-webgpu-samplers-and-long-audio-5b07</link>
      <guid>https://dev.to/martindelophy/running-ai-music-generation-entirely-in-the-browser-onnx-webgpu-samplers-and-long-audio-5b07</guid>
      <description>&lt;p&gt;Many developers think running AI models in the browser is hard because of inference performance.&lt;/p&gt;

&lt;p&gt;After shipping &lt;strong&gt;local browser AI music generation&lt;/strong&gt; in our open-source video editor &lt;a href="https://github.com/martindelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt;, I can confirm:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model inference is the easy part.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The real difficulty lies in solving browser-specific constraints: model size, WebGPU memory limits, cache quotas, sampler correctness, long audio generation, and stable editor integration.&lt;/p&gt;

&lt;p&gt;This article breaks down our complete production-grade browser AI music pipeline, including all pitfalls and fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why We Chose Stable Audio 3 Small (Q4 ONNX)
&lt;/h2&gt;

&lt;p&gt;We use the &lt;strong&gt;Stable Audio 3 Small Music Q4 ONNX&lt;/strong&gt; model.&lt;/p&gt;

&lt;p&gt;Total size: &lt;strong&gt;683MB&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It consists of four core components:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text Encoder&lt;/td&gt;
&lt;td&gt;Encode prompt into conditioning vectors&lt;/td&gt;
&lt;td&gt;213MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number Conditioner&lt;/td&gt;
&lt;td&gt;Encode target audio duration&lt;/td&gt;
&lt;td&gt;&amp;lt;1MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DiT Diffusion&lt;/td&gt;
&lt;td&gt;Main denoising computation&lt;/td&gt;
&lt;td&gt;380MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio Decoder&lt;/td&gt;
&lt;td&gt;Decode latents to 44.1kHz stereo audio&lt;/td&gt;
&lt;td&gt;45MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Browser-specific optimizations
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Model sharding&lt;/strong&gt;&lt;br&gt;
We split model weights into chunks under 100MB to avoid browser network timeout and parsing failures.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;4-bit quantization&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;MatMul / Linear&lt;/code&gt; → &lt;code&gt;MatMulNBits&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Embedding&lt;/code&gt; → &lt;code&gt;GatherBlockQuantized&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Other parameters remain FP32&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Q4 drastically reduces download size and runtime memory usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; minor grainy artifacts in high-frequency and reverb tails — acceptable for fully local browser generation and runnable on 16GB M1 devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Parallel Download + Serial WebGPU Initialization
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Parallel network fetch
&lt;/h3&gt;

&lt;p&gt;Model files are downloaded in parallel to reduce first-time startup latency:&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;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&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;paths&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;path&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchModelFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;h3&gt;
  
  
  Serial GPU initialization (critical fix)
&lt;/h3&gt;

&lt;p&gt;While network requests are parallelizable, &lt;strong&gt;WebGPU session creation cannot&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Parallel initialization of multiple large ONNX sessions causes instant unified memory spikes, especially on Apple Silicon, often leading to WebGPU allocation failure or tab crash.&lt;/p&gt;

&lt;p&gt;We enforce a strict sequential pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Text Encoder → Number Conditioner → DiT → Audio Decoder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Core engineering rule
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Network I/O can be parallelized, but WebGPU resource initialization must be serialized.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once initialized in a Web Worker, all GPU sessions are reused for subsequent generations, avoiding reloading overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Multilingual Prompt Pipeline (Auto Translate + Structured Prompts)
&lt;/h2&gt;

&lt;p&gt;Stable Audio models natively understand only English.&lt;/p&gt;

&lt;p&gt;To support global users, we built a fully automatic multilingual prompt pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User native prompt
→ Language detection
→ Chrome built-in translation
→ Structured English music prompt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also auto-append standardized metadata: style, mood, instrument, BPM, and no-vocal constraints.&lt;/p&gt;

&lt;p&gt;Example transformation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raw Chinese input&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;melancholic piano in a rainy café&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final structured prompt&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;melancholic jazz piano in a rainy café,
cinematic soundtrack,
dreamy,
piano,
90 BPM,
instrumental music,
clean production,
no vocals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For browsers without translation support, we gracefully fall back and ask users for English input, avoiding silent generation failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Most Audio Quality Issues Are Not From Quantization
&lt;/h2&gt;

&lt;p&gt;Initially, our generated audio suffered from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grainy high-frequency noise&lt;/li&gt;
&lt;li&gt;Blurry instrument texture&lt;/li&gt;
&lt;li&gt;Unstable reverb tails&lt;/li&gt;
&lt;li&gt;Loose rhythm structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We initially suspected Q4 quantization quality loss.&lt;/p&gt;

&lt;p&gt;After deep investigation, the real root cause was &lt;strong&gt;incorrect sampler schedule implementation&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correct Rectified Flow sampling logic
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;denoised&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;tCurrent&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;xNext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;tNext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;denoised&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tNext&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;randomNoise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The time parameter &lt;code&gt;t&lt;/code&gt; &lt;strong&gt;must decay strictly from 1 to 0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Our original schedule only decayed to ~0.27, leaving &lt;strong&gt;27% residual noise&lt;/strong&gt; in the final latent before decoding — this destroyed audio quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixed LogSNR schedule
&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;const&lt;/span&gt; &lt;span class="nx"&gt;logSnr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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="mf"&gt;8.2&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;sigma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;logSnr&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Hard enforce clean endpoints&lt;/span&gt;
&lt;span class="nx"&gt;schedule&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="o"&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;schedule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;]&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="c1"&gt;// Zero-noise final step&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;tNext&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;denoised&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;This single fix improved audio quality far more than increasing sampling steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaway
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Poor local model generation quality is usually caused by preprocessing/scheduler bugs, not model quantization.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. From Latent Output to Final WAV
&lt;/h2&gt;

&lt;p&gt;Latent length is dynamically calculated based on target audio duration:&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;latentLength&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;seconds&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;44100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DiT outputs latent tensors, and the decoder produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shape: &lt;code&gt;[1, 2, audioFrames]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Format: Float32&lt;/li&gt;
&lt;li&gt;Range: -1 ~ 1&lt;/li&gt;
&lt;li&gt;Sample rate: 44100Hz&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We convert float samples to standard 16-bit PCM WAV:&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;sample&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;sample&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;32768&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;32767&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, the audio asset is automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Converted to WAV blob&lt;/li&gt;
&lt;li&gt;Decoded for accurate duration&lt;/li&gt;
&lt;li&gt;Waveform-analyzed&lt;/li&gt;
&lt;li&gt;Saved with prompt/model metadata&lt;/li&gt;
&lt;li&gt;Imported into the editor asset library&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Long Audio Generation: Why We Avoid Direct 120s Inference
&lt;/h2&gt;

&lt;p&gt;Direct long-form audio inference (90s/120s) in browsers causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebGPU buffer allocation failures&lt;/li&gt;
&lt;li&gt;Excessive memory usage&lt;/li&gt;
&lt;li&gt;Tab freezing or system process killing&lt;/li&gt;
&lt;li&gt;Peak decoder memory pressure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To solve this, we use a &lt;strong&gt;segmented generation + intelligent looping strategy&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;90s target → generate 45s segment&lt;/li&gt;
&lt;li&gt;120s target → generate 60s segment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final long audio is stitched from high-quality short segments, cutting VRAM and inference time nearly in half.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Seamless Audio Stitching (No Clicks, No Jumps)
&lt;/h2&gt;

&lt;p&gt;Naive loop splicing creates obvious artifacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Volume jumps&lt;/li&gt;
&lt;li&gt;Misaligned drum hits&lt;/li&gt;
&lt;li&gt;Phase discontinuity&lt;/li&gt;
&lt;li&gt;Audible clicks/pauses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We analyze the last 5 seconds of the source segment and score candidate cut points using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RMS energy&lt;/li&gt;
&lt;li&gt;Amplitude jump difference&lt;/li&gt;
&lt;li&gt;Waveform slope jump&lt;/li&gt;
&lt;li&gt;Shorten ratio penalty&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unified scoring formula:&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;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;rms&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;amplitudeJump&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;slopeJump&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;shortenedRatio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lower score = better loop boundary.&lt;/p&gt;

&lt;p&gt;We also use &lt;strong&gt;dynamic fade duration&lt;/strong&gt; (0.25s ~ 1.5s) based on local audio energy:&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;fadeSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;localRms&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mf"&gt;1.5&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;High-energy regions get longer fades; quiet regions use short transitions for natural looping.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Model Caching: Harder Than Inference
&lt;/h2&gt;

&lt;p&gt;We use &lt;strong&gt;Service Worker + Cache Storage&lt;/strong&gt; to cache the 683MB model persistently.&lt;/p&gt;

&lt;p&gt;We fixed two production-critical browser cache issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue 1: Cache hit misjudgment
&lt;/h3&gt;

&lt;p&gt;Custom HTTP headers cannot be reliably exposed cross-origin after Service Worker proxying.&lt;br&gt;
This caused UI to show “downloading” even when models were loaded from local cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Directly query Cache Storage from the AI worker instead of relying on response headers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue 2: QuotaExceededError
&lt;/h3&gt;

&lt;p&gt;Dual-write from both AI Worker and Service Worker caused temporary duplicate model occupancy, exceeding browser storage quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final cache architecture:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only Service Worker writes cache&lt;/li&gt;
&lt;li&gt;AI Worker only reads/validates cache&lt;/li&gt;
&lt;li&gt;Cache failure does not block generation&lt;/li&gt;
&lt;li&gt;Auto clean old model cache versions&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Large model caching requires a single source of truth for writes. Cache logic must never break core inference.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  9. Model Mirroring &amp;amp; Immutable Version Locking
&lt;/h2&gt;

&lt;p&gt;Public Hugging Face repos are unstable for production use — authors may delete, overwrite, or update weights.&lt;/p&gt;

&lt;p&gt;We fully mirrored and frozen the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mirror repo: &lt;code&gt;haixin/stable-audio-3-small-music-onnx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Fixed immutable commit hash: &lt;code&gt;0b8a05e0bc3511e674b4cb3413d3ef6c48880cdb&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We verified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;26 model files&lt;/li&gt;
&lt;li&gt;Full 683MB byte integrity&lt;/li&gt;
&lt;li&gt;SHA256 hashes&lt;/li&gt;
&lt;li&gt;ONNX graph &amp;amp; weight shards&lt;/li&gt;
&lt;li&gt;License &amp;amp; notice files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Old cache entries are compatible, so existing users do not need to re-download.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Real Boundaries &amp;amp; Value of Browser-Native AI Music
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Q4 quantization cannot match FP16/FP32 server-side quality&lt;/li&gt;
&lt;li&gt;Long audio relies on intelligent looping instead of full inference&lt;/li&gt;
&lt;li&gt;WebGPU support depends on device/browser implementation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Unique strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Zero server-side inference cost&lt;/li&gt;
&lt;li&gt;100% local processing (prompt + audio never uploaded)&lt;/li&gt;
&lt;li&gt;One-time model download + persistent cache&lt;/li&gt;
&lt;li&gt;Hot GPU session reuse for fast subsequent generation&lt;/li&gt;
&lt;li&gt;Native integration into video editor workflow&lt;/li&gt;
&lt;li&gt;Deployable via static hosting&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Running production-ready AI music in the browser is not just about getting ONNX inference running.&lt;/p&gt;

&lt;p&gt;It requires careful system-level engineering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Correct sampler scheduling&lt;/li&gt;
&lt;li&gt;WebGPU memory &amp;amp; concurrency control&lt;/li&gt;
&lt;li&gt;Intelligent long audio stitching&lt;/li&gt;
&lt;li&gt;Robust caching strategy&lt;/li&gt;
&lt;li&gt;Immutable model supply chain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these fixes, a 683MB quantized music model can become a &lt;strong&gt;usable, stable, production-grade browser AI feature&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open Source Project
&lt;/h2&gt;

&lt;p&gt;If you are interested in browser-side AI, WebGPU inference, or local media generation:&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/martindelophy/ai-video-editor" rel="noopener noreferrer"&gt;martindelophy/ai-video-editor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I Built a Local-First AI Video Editor with WebCodecs, WebGPU, and WASM</title>
      <dc:creator>MartinDelophy</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:35:21 +0000</pubDate>
      <link>https://dev.to/martindelophy/how-i-built-a-local-first-ai-video-editor-with-webcodecs-webgpu-and-wasm-4lkn</link>
      <guid>https://dev.to/martindelophy/how-i-built-a-local-first-ai-video-editor-with-webcodecs-webgpu-and-wasm-4lkn</guid>
      <description>&lt;p&gt;Modern video editors are powerful, but AI-assisted workflows often begin with the same trade-off: upload your media to someone else's server, wait for processing, and accept that the editable state lives somewhere else.&lt;/p&gt;

&lt;p&gt;I wanted to explore a different constraint:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How much of a serious AI video editing workflow can run entirely inside the browser?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Timeline Studio&lt;/a&gt;, an MIT-licensed video editor built with React, browser media APIs, ONNX Runtime Web, WebGPU, WASM, and WebCodecs.&lt;/p&gt;

&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%2F8p38s301ox53ltsozhkb.webp" 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%2F8p38s301ox53ltsozhkb.webp" alt="Timeline Studio local-first browser architecture" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting part was not adding another timeline UI. It was getting media playback, AI inference, editable timeline state, audio mixing, and deterministic export to agree on the same definition of time — without relying on an editing backend.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;Source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;Live editor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor/releases/tag/v0.7.0" rel="noopener noreferrer"&gt;Latest release&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture in one view
&lt;/h2&gt;

&lt;p&gt;Timeline Studio separates interactive editing from expensive media and AI work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React UI and timeline state
          |
          +-- Native media playback for responsive preview
          |
          +-- Web Workers
          |      +-- ONNX Runtime Web
          |      +-- WebGPU inference
          |      +-- WASM fallback
          |
          +-- OfflineAudioContext for audio mixing
          |
          +-- Deterministic composition renderer
                 +-- exact timeline timestamps
                 +-- Canvas composition
                 +-- WebCodecs encoding
                 +-- MP4 / WebM muxing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Large models are loaded only when a feature needs them. Model artifacts are revision-pinned and cached locally, while inference runs in long-lived workers so repeated operations do not rebuild the entire runtime.&lt;/p&gt;

&lt;p&gt;The editor remains responsive because React owns the editing interface and declarative project state, not every decoded video frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  One timeline, two playback paths
&lt;/h2&gt;

&lt;p&gt;A real-time preview and a high-quality export have different requirements.&lt;/p&gt;

&lt;p&gt;During editing, the active video element follows the browser's native playback path. Trying to continuously seek media elements from delayed React state produced visible stuttering, especially with multiple layers.&lt;/p&gt;

&lt;p&gt;Export takes the opposite approach. It resolves every frame from an exact output timestamp. For each timestamp, the renderer determines which clips are active and composes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;main visual media;&lt;/li&gt;
&lt;li&gt;picture-in-picture overlays;&lt;/li&gt;
&lt;li&gt;captions and stickers;&lt;/li&gt;
&lt;li&gt;masks, filters, and effects;&lt;/li&gt;
&lt;li&gt;keyframed transforms;&lt;/li&gt;
&lt;li&gt;animations and transitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This split gives the editor smooth playback without making the exported file depend on real-time UI performance.&lt;/p&gt;

&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%2Fg8yvsav37hv955vfd8dj.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%2Fg8yvsav37hv955vfd8dj.png" alt="Timeline Studio multi-track editor" width="800" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic export with WebCodecs
&lt;/h2&gt;

&lt;p&gt;MediaRecorder is useful when compatibility matters, but it records a real-time stream. If the tab stalls or the device cannot keep up, the output can inherit timing problems from the recording session.&lt;/p&gt;

&lt;p&gt;Timeline Studio therefore treats WebCodecs as the primary export path.&lt;/p&gt;

&lt;p&gt;For each output frame, the renderer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;converts the frame index into an exact timeline timestamp;&lt;/li&gt;
&lt;li&gt;resolves every active visual and its source timestamp;&lt;/li&gt;
&lt;li&gt;draws the shared composition geometry to the export canvas;&lt;/li&gt;
&lt;li&gt;passes the frame to the selected video encoder;&lt;/li&gt;
&lt;li&gt;mixes audio separately and muxes it into the final container.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same geometry rules are shared by preview and export. A caption, mask, overlay, or transform should not move merely because the output resolution changes.&lt;/p&gt;

&lt;p&gt;MediaRecorder remains a compatibility fallback, but it is not allowed to silently change the requested format. Active exports also expose a real cancel path that stops further work, releases media resources, and never downloads a partial file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running ONNX models with WebGPU and WASM
&lt;/h2&gt;

&lt;p&gt;The editor currently uses browser-side models for several workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Piper/VITS and Kokoro for multilingual voice generation;&lt;/li&gt;
&lt;li&gt;Whisper small q8 for automatic captions;&lt;/li&gt;
&lt;li&gt;YOLOS and MODNet for subject detection and portrait matting;&lt;/li&gt;
&lt;li&gt;vocal separation and AI music generation;&lt;/li&gt;
&lt;li&gt;JoyVASA and LivePortrait for talking-avatar generation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WebGPU is preferred when a model path has been verified with it. WASM is an explicit fallback rather than a hidden change of engine.&lt;/p&gt;

&lt;p&gt;A lesson from this work was that model availability is not the same as workflow reliability. A voice is not added to the catalog just because an ONNX file exists; it needs to pass generation, timeline insertion, and downloaded-WAV validation through the editor itself.&lt;/p&gt;

&lt;p&gt;Heavy inference runs outside the UI thread. For model bundles containing several artifacts, downloads happen in parallel, while GPU sessions are created serially to avoid unreliable initialization and memory pressure. Once initialized, a worker stays alive for repeated generations on the same page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching large model artifacts
&lt;/h2&gt;

&lt;p&gt;Downloading a model again on every visit would make a browser AI editor impractical.&lt;/p&gt;

&lt;p&gt;Timeline Studio uses a service worker and versioned browser caches for its application shell and large runtime assets. Model URLs are revision-pinned so a cached artifact has a stable identity. When hosting locations change, legacy cache keys can still be recognized to avoid forcing users to download identical weights again.&lt;/p&gt;

&lt;p&gt;This changes the perceived workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the first run performs model setup;&lt;/li&gt;
&lt;li&gt;later runs reuse the initialized or cached assets;&lt;/li&gt;
&lt;li&gt;repeated generation is presented as generation, not another fake "model download."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application is also installable as a PWA, so the editor shell can start independently of a full network round trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio is its own timeline problem
&lt;/h2&gt;

&lt;p&gt;Video export is only half of the job. Voiceovers, music, separated source audio, and enabled embedded video audio all need to agree with clip ranges and source offsets.&lt;/p&gt;

&lt;p&gt;Timeline Studio prepares audio independently with &lt;code&gt;OfflineAudioContext&lt;/code&gt;. Each source is cropped and scheduled against the export range, then mixed before muxing.&lt;/p&gt;

&lt;p&gt;This becomes particularly important for custom in/out exports. Cropping only the visuals is not enough: captions, overlays, voice, music, embedded audio, and SRT timing all have to be rebased to zero consistently.&lt;/p&gt;

&lt;p&gt;Waveforms are derived from decoded peaks when available. While decoding or restoration is still in progress, clips render a stable low-emphasis placeholder instead of becoming blank and visually ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI output should become editable state
&lt;/h2&gt;

&lt;p&gt;The most useful architectural decision was to avoid treating AI output as a final opaque artifact.&lt;/p&gt;

&lt;p&gt;Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;speech recognition becomes timed caption clips;&lt;/li&gt;
&lt;li&gt;generated speech becomes editable audio linked to a caption;&lt;/li&gt;
&lt;li&gt;subject detection becomes a timestamped analysis track;&lt;/li&gt;
&lt;li&gt;vocal separation becomes independent vocal and instrumental clips;&lt;/li&gt;
&lt;li&gt;avatar generation becomes replaceable visual media;&lt;/li&gt;
&lt;li&gt;AI music is added to the asset library instead of being inserted automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps AI operations reversible. Users can move, trim, replace, unlink, or regenerate individual results without repeating the entire workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the timeline agent-editable
&lt;/h2&gt;

&lt;p&gt;The repository also includes an &lt;code&gt;edit-timeline-studio&lt;/code&gt; skill and a versioned command runner for tools such as Codex, Claude Code, Copilot, and Gemini CLI.&lt;/p&gt;

&lt;p&gt;The command layer can inspect portable &lt;code&gt;.timeline&lt;/code&gt; projects, validate revisioned edit plans, show field-level diffs, apply supported edits transactionally, and render a documented portable subset to H.264/AAC MP4.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run agent &lt;span class="nt"&gt;--&lt;/span&gt; project.inspect /absolute/path/project.timeline
npm run agent &lt;span class="nt"&gt;--&lt;/span&gt; project.diff /absolute/path/edit-plan.json
npm run agent &lt;span class="nt"&gt;--&lt;/span&gt; project.run /absolute/path/edit-plan.json
npm run agent &lt;span class="nt"&gt;--&lt;/span&gt; project.render /absolute/path/render-request.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dry runs, stable clip IDs, explicit timestamps, revision checks, and idempotent operation IDs matter here. An agent should not turn a user's editable project into an irreversible render or apply the same mutation twice after a retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Building a browser video editor is less about drawing frames than maintaining one consistent interpretation of time.&lt;/p&gt;

&lt;p&gt;The timeline, media elements, waveform data, captions, keyframes, source offsets, transitions, audio mixer, and export renderer must all answer the same questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which clips are active now?&lt;/li&gt;
&lt;li&gt;Which source frame or audio sample belongs here?&lt;/li&gt;
&lt;li&gt;Which values are interpolated at this timestamp?&lt;/li&gt;
&lt;li&gt;What should happen before the first keyframe and after the last one?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI adds another layer, but the same principle applies: generated results become much more useful when they enter a deterministic, editable timeline model.&lt;/p&gt;

&lt;p&gt;The browser platform is now capable of handling far more of this stack than I expected. WebCodecs, WebGPU, WASM, Workers, OfflineAudioContext, Cache Storage, and installable PWAs can support a substantial local-first media workflow — as long as their responsibilities remain clearly separated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Timeline Studio is open source and actively developed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://video-editor.ai-creator.top/" rel="noopener noreferrer"&gt;Live editor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor/tree/main/skills/edit-timeline-studio" rel="noopener noreferrer"&gt;Agent skill&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/MartinDelophy/ai-video-editor/releases/tag/v0.7.0" rel="noopener noreferrer"&gt;v0.7.0 release&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run it locally with Node.js 20 or newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/MartinDelophy/ai-video-editor.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ai-video-editor
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you work with browser media, WebCodecs, WebGPU, ONNX, timeline UX, or deterministic rendering, feedback and focused contributions are welcome.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
