<?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: iLoveVideoEditor</title>
    <description>The latest articles on DEV Community by iLoveVideoEditor (@ilovevideoeditor).</description>
    <link>https://dev.to/ilovevideoeditor</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%2F4029376%2F55ec6471-bcb1-4ed9-9337-aeda6f0aba1c.png</url>
      <title>DEV Community: iLoveVideoEditor</title>
      <link>https://dev.to/ilovevideoeditor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ilovevideoeditor"/>
    <language>en</language>
    <item>
      <title>Build a Reviewable AI Video Agent with MCP</title>
      <dc:creator>iLoveVideoEditor</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:41:22 +0000</pubDate>
      <link>https://dev.to/ilovevideoeditor/build-a-reviewable-ai-video-agent-with-mcp-2dda</link>
      <guid>https://dev.to/ilovevideoeditor/build-a-reviewable-ai-video-agent-with-mcp-2dda</guid>
      <description>&lt;p&gt;An AI video agent should not begin with a render call.&lt;/p&gt;

&lt;p&gt;It should first prove that it understands the engine it is about to use, the&lt;br&gt;
composition it selected, and the constraints that determine whether the output&lt;br&gt;
will be readable and safe to publish.&lt;/p&gt;

&lt;p&gt;The minimum dependable loop is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brief
-&amp;gt; discover
-&amp;gt; inspect
-&amp;gt; validate
-&amp;gt; preview
-&amp;gt; approve
-&amp;gt; render
-&amp;gt; verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sequence is less dramatic than a one-prompt demo. It is also much closer&lt;br&gt;
to a system a real team can operate.&lt;/p&gt;
&lt;h2&gt;
  
  
  The complete tool sequence
&lt;/h2&gt;

&lt;p&gt;iLoveVideoEditor exposes 14 typed tools through its MCP server. A reviewable&lt;br&gt;
run uses a subset in an explicit order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ilovevideoeditor_list_templates
-&amp;gt; ilovevideoeditor_get_template
-&amp;gt; ilovevideoeditor_get_layer_capabilities
-&amp;gt; ilovevideoeditor_measure_text
-&amp;gt; ilovevideoeditor_local_preview
-&amp;gt; ilovevideoeditor_local_capture
-&amp;gt; ilovevideoeditor_local_analyze
-&amp;gt; human approval
-&amp;gt; ilovevideoeditor_render_template or ilovevideoeditor_render_json
-&amp;gt; ilovevideoeditor_get_render_status
-&amp;gt; ilovevideoeditor_get_download_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model still reasons. It simply reasons against a real contract instead of&lt;br&gt;
inventing template variables, effects, transitions, or render behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Install the server
&lt;/h2&gt;

&lt;p&gt;Configure the published package in an MCP-compatible client:&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;"mcpServers"&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;"ilovevideoeditor"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@ilovevideoeditor/mcp-server"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&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;"VF_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vf_live_..."&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;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;Keep the real key in the client's secret environment. Never paste it into a&lt;br&gt;
prompt, article, screenshot, evaluation fixture, or committed configuration.&lt;/p&gt;

&lt;p&gt;The sequence in this article was checked against&lt;br&gt;
&lt;code&gt;@ilovevideoeditor/mcp-server&lt;/code&gt; v1.0.2.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: start read-only
&lt;/h2&gt;

&lt;p&gt;The first call should not spend credits or create a job.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;ilovevideoeditor_list_templates&lt;/code&gt; to discover candidates, then call&lt;br&gt;
&lt;code&gt;ilovevideoeditor_get_template&lt;/code&gt; to inspect the required text, image, color, and&lt;br&gt;
file variables.&lt;/p&gt;

&lt;p&gt;This catches a common planning error: an agent creates a detailed storyboard&lt;br&gt;
that cannot be mapped to the selected template.&lt;/p&gt;

&lt;p&gt;A compatible template must support more than the general visual style. It must&lt;br&gt;
also support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the target aspect ratio;&lt;/li&gt;
&lt;li&gt;the required media types;&lt;/li&gt;
&lt;li&gt;the planned copy density;&lt;/li&gt;
&lt;li&gt;the number and purpose of scenes;&lt;/li&gt;
&lt;li&gt;the brand constraints;&lt;/li&gt;
&lt;li&gt;the intended call to action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If tool discovery or schema inspection is unstable, adding a paid render call&lt;br&gt;
only makes the failure harder to diagnose.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: validate capabilities instead of guessing
&lt;/h2&gt;

&lt;p&gt;When a brief asks for a motion effect or transition, call&lt;br&gt;
&lt;code&gt;ilovevideoeditor_get_layer_capabilities&lt;/code&gt; before adding it to the plan.&lt;/p&gt;

&lt;p&gt;Do the same for typography. A sentence can be grammatically correct and still&lt;br&gt;
be unusable in a 9:16 composition. &lt;code&gt;ilovevideoeditor_measure_text&lt;/code&gt; lets the&lt;br&gt;
agent shorten, split, or restructure copy while the decision is still cheap.&lt;/p&gt;

&lt;p&gt;The output of this stage should be a small, reviewable artifact:&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;"scene"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cta"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"purpose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Close with one action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"copy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Start your first reviewable render"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"templateVariable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ctaText"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durationSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assumptions"&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;"reviewRequired"&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="s2"&gt;"Confirm destination URL"&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 is a better approval boundary than a raw prompt and a cheaper approval&lt;br&gt;
boundary than a finished render.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: give the agent eyes
&lt;/h2&gt;

&lt;p&gt;A structurally valid payload can still produce a weak video.&lt;/p&gt;

&lt;p&gt;Start a local preview, capture representative frames, and analyze them before&lt;br&gt;
submitting a cloud render. The visual pass should look for concrete failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clipped or overflowing text;&lt;/li&gt;
&lt;li&gt;unsafe margins;&lt;/li&gt;
&lt;li&gt;weak contrast;&lt;/li&gt;
&lt;li&gt;missing media;&lt;/li&gt;
&lt;li&gt;unintended crops;&lt;/li&gt;
&lt;li&gt;an empty or visually weak scene;&lt;/li&gt;
&lt;li&gt;a call to action that appears too briefly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The report should be specific enough to drive a correction:&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;"scene"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cta"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;11.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checks"&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;"textOverflow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safeMargins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contrast"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missingMedia"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;"reviewRequired"&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="s2"&gt;"Confirm destination URL"&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;Visual inspection does not eliminate human review. It gives the reviewer a&lt;br&gt;
smaller and better-defined set of decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: keep success states separate
&lt;/h2&gt;

&lt;p&gt;One green status is not enough for branded video.&lt;/p&gt;

&lt;p&gt;Treat these as separate states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schema valid&lt;/strong&gt; — the request matches the tool and composition contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render completed&lt;/strong&gt; — the engine produced the expected file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual checks passed&lt;/strong&gt; — the composition meets the defined quality rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human approved&lt;/strong&gt; — claims, media, timing, and CTA were reviewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distribution authorized&lt;/strong&gt; — the output may be sent to its destination.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A completed render does not prove that a price is current, a product claim is&lt;br&gt;
supported, a face or voice is licensed, captions are accessible, or the&lt;br&gt;
destination URL is correct.&lt;/p&gt;

&lt;p&gt;Those decisions remain explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: render only after approval
&lt;/h2&gt;

&lt;p&gt;After the storyboard and preview are approved, use&lt;br&gt;
&lt;code&gt;ilovevideoeditor_render_template&lt;/code&gt; or &lt;code&gt;ilovevideoeditor_render_json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then poll the job with &lt;code&gt;ilovevideoeditor_get_render_status&lt;/code&gt; and retrieve the&lt;br&gt;
completed output with &lt;code&gt;ilovevideoeditor_get_download_url&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The run record should contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the normalized brief;&lt;/li&gt;
&lt;li&gt;selected template and version;&lt;/li&gt;
&lt;li&gt;variable map;&lt;/li&gt;
&lt;li&gt;storyboard;&lt;/li&gt;
&lt;li&gt;captured review frames;&lt;/li&gt;
&lt;li&gt;reviewer decision;&lt;/li&gt;
&lt;li&gt;render job ID and status history;&lt;/li&gt;
&lt;li&gt;final destination.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the decisions, not the secrets. Redact API keys, private source URLs,&lt;br&gt;
customer identifiers, and sensitive transcript text from prompts, screenshots,&lt;br&gt;
logs, and fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safe first experiment
&lt;/h2&gt;

&lt;p&gt;The first proof can stop before rendering:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;install the MCP server;&lt;/li&gt;
&lt;li&gt;list the available tools;&lt;/li&gt;
&lt;li&gt;discover templates;&lt;/li&gt;
&lt;li&gt;inspect one template;&lt;/li&gt;
&lt;li&gt;measure one short line of text;&lt;/li&gt;
&lt;li&gt;stop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once that path is stable, add local preview, a captured frame, and one approved&lt;br&gt;
render.&lt;/p&gt;

&lt;p&gt;Explore the current tool surface and installation paths:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ilovevideoagents.com/tools?utm_source=devto&amp;amp;utm_medium=organic_content&amp;amp;utm_campaign=reviewable-video-agents&amp;amp;utm_content=ext-a02-tutorial" rel="noopener noreferrer"&gt;https://ilovevideoagents.com/tools?utm_source=devto&amp;amp;utm_medium=organic_content&amp;amp;utm_campaign=reviewable-video-agents&amp;amp;utm_content=ext-a02-tutorial&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; iLoveVideoEditor builds the MCP server and rendering system&lt;br&gt;
described in this article. Product behavior and counts were checked against the&lt;br&gt;
repository fact registry and MCP server v1.0.2 on 2026-08-11.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>webdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>How We Cut Our Browser Video Renderer's Frame Time by 80%</title>
      <dc:creator>iLoveVideoEditor</dc:creator>
      <pubDate>Wed, 15 Jul 2026 01:22:58 +0000</pubDate>
      <link>https://dev.to/ilovevideoeditor/how-we-cut-our-browser-video-renderers-frame-time-by-80-3db1</link>
      <guid>https://dev.to/ilovevideoeditor/how-we-cut-our-browser-video-renderers-frame-time-by-80-3db1</guid>
      <description>&lt;h1&gt;
  
  
  How We Cut Our Browser Video Renderer's Frame Time by 80%
&lt;/h1&gt;

&lt;p&gt;A 30-second product promo — 1,050 frames at 1920×1080 — was rendering at roughly one frame per second. In the browser, on a machine that plays modern games without blinking. The same job on our headless render server took 10 minutes.&lt;/p&gt;

&lt;p&gt;This is the writeup of how we found four distinct problems, fixed them in a single commit, and got scene medians down from ~0.5–1.0s to ~90–165ms per frame — with pixel-level proof that the output didn't change. Well, almost: the verification pass also caught a real bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The renderer in 60 seconds
&lt;/h2&gt;

&lt;p&gt;Our engine renders a video by drawing every frame onto a canvas, then feeding the bitmaps to a WebCodecs &lt;code&gt;VideoEncoder&lt;/code&gt; (H.264 in MP4). Layers are DOM elements — text, images, shapes with real CSS — so rasterizing a layer means calling &lt;code&gt;domToCanvas&lt;/code&gt; from &lt;a href="https://www.npmjs.com/package/modern-screenshot" rel="noopener noreferrer"&gt;modern-screenshot&lt;/a&gt;, which serializes the element's subtree into an SVG &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; and rasterizes it. Effect layers additionally go through a WebGL compositor for blur, distortion, and CSS-filter passes. The exact same bundle runs in two places: your browser tab for preview and local export, and headless Chromium on our Railway workers for API renders.&lt;/p&gt;

&lt;p&gt;That architecture is why "one frame per second" was so embarrassing: every layer already renders as a flat bitmap. The work per frame should be a handful of canvas draws and one encode call.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we measured
&lt;/h2&gt;

&lt;p&gt;We didn't profile the whole render — that number is a sum, and sums hide everything. We put timers around each stage of the per-frame pipeline (DOM capture, effect compositing, overlay processing, encode) and took the &lt;strong&gt;median per scene&lt;/strong&gt;, because our four scenes use very different layers. The overall median was ~0.9 seconds of serial work per frame. The breakdown pointed at four separate problems, in four separate subsystems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: preview overlays billed to export
&lt;/h2&gt;

&lt;p&gt;The preview UI shows post-effect overlay canvases so you can see what an effect does to a layer in isolation. That work is owned by an &lt;code&gt;EffectOverlayManager&lt;/code&gt; — and its &lt;code&gt;process()&lt;/code&gt; ran &lt;strong&gt;on every exported frame too&lt;/strong&gt;, even though export has no UI at all. Cost: 60–260ms per frame of pure waste, depending on the scene.&lt;/p&gt;

&lt;p&gt;The fix was a flag: &lt;code&gt;captureFrame&lt;/code&gt; (the export path) now gates overlay processing off. Free win, zero risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: WebGL context churn
&lt;/h2&gt;

&lt;p&gt;Effect layers composite at their padded bounding-box size. The old code did this whenever the size changed:&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="nx"&gt;effectCompositor&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;effectCompositor&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;WebGLEffectCompositor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;padded&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;padded&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Destroying a compositor means losing the GL context (&lt;code&gt;WEBGL_lose_context&lt;/code&gt;) and recompiling every shader on the next frame. Animated layers change their bbox constantly, so we were paying a full context teardown mid-render, over and over. The fix is a tiny LRU pool keyed by exact render size, capped at four instances — enough to cover the few sizes a scene alternates between, small enough that an animated layer can't grow it unbounded:&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;acquire&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;current&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;current&lt;/span&gt;&lt;span class="p"&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;width&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&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;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;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// hot path: keep what you have&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;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store&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="c1"&gt;// check old one back in&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&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="s2"&gt;x&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="s2"&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;new&lt;/span&gt; &lt;span class="nc"&gt;WebGLEffectCompositor&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;h2&gt;
  
  
  Finding 3: the DOM capture that ran 1,050 times
&lt;/h2&gt;

&lt;p&gt;This was the big one. &lt;code&gt;domToCanvas&lt;/code&gt; costs 16–300ms per call — it's a full DOM serialization and rasterization. We already cached layer surfaces, but the cache key included &lt;strong&gt;every&lt;/strong&gt; prop. So a simple opacity fade — the most common animation in any template — invalidated the capture on every single frame, and we re-rasterized identical content 1,050 times per render.&lt;/p&gt;

&lt;p&gt;The insight that fixes it: &lt;strong&gt;content and transform are different things.&lt;/strong&gt; A fade or a slide changes where and how transparently a layer is drawn; it doesn't change what the layer looks like. And canvas already knows how to translate, scale, rotate, and set alpha — that's what &lt;code&gt;drawImage&lt;/code&gt; with an affine transform and &lt;code&gt;globalAlpha&lt;/code&gt; do, in microseconds.&lt;/p&gt;

&lt;p&gt;So the cache is now two-level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Level A (full-props key)&lt;/strong&gt; — every prop participates. A hit returns the finished surface untouched. Same as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level B (content key)&lt;/strong&gt; — excludes the props that are applied only at draw time: transform, opacity, CSS filters (consumed downstream by the WebGL pass), and audio volume (transitions like &lt;code&gt;fade&lt;/code&gt; animate it every frame alongside opacity). A hit skips &lt;code&gt;domToCanvas&lt;/code&gt; entirely and redraws the cached content bitmap with the current transform: ~1–3ms instead of 16–300ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Level B is only correct because of how the capture is built: the clone is rasterized with &lt;code&gt;transform: none&lt;/code&gt; and &lt;code&gt;opacity: 1&lt;/code&gt; forced, so the bitmap is transform-free and opacity-free by construction. Whatever the animation does is re-applied at draw time, and the result is pixel-identical to re-capturing.&lt;/p&gt;

&lt;p&gt;One more detail in the same vein: empty placeholder layers (zero-size) used to go through the &lt;code&gt;foreignObject&lt;/code&gt; round-trip every frame too. Caching the zero-size result costs one map entry and skips that entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the opacity² bug
&lt;/h2&gt;

&lt;p&gt;We verify performance changes with pixel diffs — render the same frames before and after, compare bitmaps. The diffs came back identical &lt;strong&gt;except&lt;/strong&gt; for fades, and the new output was &lt;em&gt;less&lt;/em&gt; transparent than the old. That wasn't a regression: it exposed an existing bug. Export had been baking opacity into the captured bitmap &lt;strong&gt;and&lt;/strong&gt; applying it again via &lt;code&gt;globalAlpha&lt;/code&gt; — effectively &lt;code&gt;opacity²&lt;/code&gt;. Preview applied it once. Export and preview had disagreed about every fade in every video, and nobody had noticed because a slightly-more-opaque fade still looks like a fade.&lt;/p&gt;

&lt;p&gt;The fix: strip opacity from the capture clone (already done for Level B), apply it exactly once at draw time. Export now matches preview — which is the actual specification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Scene medians per frame on the product-launch template that started all this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scene 1 (hero product + effects)&lt;/td&gt;
&lt;td&gt;1024ms&lt;/td&gt;
&lt;td&gt;165ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scene 2 (feature callouts)&lt;/td&gt;
&lt;td&gt;716ms&lt;/td&gt;
&lt;td&gt;141ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scene 3 (image gallery)&lt;/td&gt;
&lt;td&gt;518ms&lt;/td&gt;
&lt;td&gt;98ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scene 4 (CTA)&lt;/td&gt;
&lt;td&gt;543ms&lt;/td&gt;
&lt;td&gt;87ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;End to end, that's roughly an &lt;strong&gt;80% cut&lt;/strong&gt;. The same 30-second video that took ~10 minutes on the render server now finishes in about 3. Local export in a real browser went from "one frame per second" to comfortably faster than realtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still slow
&lt;/h2&gt;

&lt;p&gt;Honesty section, because the server number (3 minutes for 30 seconds of video) is still far from the browser number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SwiftShader.&lt;/strong&gt; Headless Chromium on a server has no GPU, so every WebGL pass runs on software rasterization. That's the dominant remaining cost on API renders, and no amount of caching changes it — the fix list there is different (smaller effect surfaces, GPU instances, or skipping GL for passes the canvas API can do).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level-B trade-offs.&lt;/strong&gt; Redrawing a cached bitmap is pixel-identical for translate/fade, but a layer scaled up 3× is still drawn from a 1× capture — canvas upscaling is good, not magic. For extreme scale animations we'd want to re-capture at the target density; we don't do that yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode is now the floor.&lt;/strong&gt; With rendering this cheap, the WebCodecs encoder is a visible share of per-frame time. That's a good problem to have.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure per stage, per frame, per scene.&lt;/strong&gt; "Render is slow" was unactionable; four medians were four fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate content from transform.&lt;/strong&gt; Cache the expensive thing (a rasterized bitmap), re-apply the cheap thing (affine + alpha). This pattern shows up everywhere — game engines, browsers, and your React app all do some version of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview and export are different workloads.&lt;/strong&gt; Don't bill preview-only work to the export path; it compounds per frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGL context creation is a budget item.&lt;/strong&gt; Pool contexts the way you pool connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixel-diff your "no visual change" claims.&lt;/strong&gt; It's cheap, and it caught a correctness bug we didn't know we had.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building something similar — a canvas renderer, a DOM-to-image pipeline, a headless export service — the &lt;a href="https://dev.to/docs/api"&gt;API docs&lt;/a&gt; show the render endpoint this engine sits behind, and the &lt;a href="https://dev.to/install"&gt;CLI&lt;/a&gt; (&lt;code&gt;npm i -g ilovevideoeditor&lt;/code&gt;) can render a template locally so you can watch the frame counter move. It moves a lot faster now.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>webcodecs</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
