<?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: Mathis</title>
    <description>The latest articles on DEV Community by Mathis (@mathisdev7).</description>
    <link>https://dev.clauneck.workers.dev/mathisdev7</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%2F3831431%2F8fdd32d2-d2f6-455a-9626-025b43067283.jpeg</url>
      <title>DEV Community: Mathis</title>
      <link>https://dev.clauneck.workers.dev/mathisdev7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.clauneck.workers.dev/feed/mathisdev7"/>
    <language>en</language>
    <item>
      <title>Building a GPU-accelerated screen recorder in Rust with wgpu</title>
      <dc:creator>Mathis</dc:creator>
      <pubDate>Fri, 19 Jun 2026 06:41:59 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/mathisdev7/how-i-built-a-gpu-accelerated-zoom-engine-for-screen-recording-in-rust-40jm</link>
      <guid>https://dev.clauneck.workers.dev/mathisdev7/how-i-built-a-gpu-accelerated-zoom-engine-for-screen-recording-in-rust-40jm</guid>
      <description>&lt;p&gt;I record a lot of product demos. Screen Studio on macOS made "smoothly zoom toward the cursor, then glide back" feel effortless. On Linux, where I live, nothing did it well. So I built the zoom engine myself in Rust, on top of &lt;a href="https://wgpu.rs/" rel="noopener noreferrer"&gt;wgpu&lt;/a&gt;. It now powers &lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;Screenix&lt;/a&gt;, a &lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;polished screen recorder for Linux&lt;/a&gt; (here is how it &lt;a href="https://screenix.studio/screen-studio-alternative" rel="noopener noreferrer"&gt;compares to Screen Studio&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This post is the full writeup of the real engine, not a toy example. If you have ever wanted to do real-time GPU image processing in Rust, or you care about how to keep a live preview and a final export pixel-matched, the plumbing alone should save you a weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the workflow reframe
&lt;/h2&gt;

&lt;p&gt;My first version was the obvious one: hold a hotkey during recording, the recorder zooms live, release and it glides back. I shipped it. I then threw it out.&lt;/p&gt;

&lt;p&gt;Even Screen Studio does not do that anymore. The problem is that "live" means the zoom decision is locked in at capture time. If the zoom follows the cursor a little too aggressively, or transitions out 100ms too early, your only fix is to re-record. That is miserable for a three-minute demo with one bad moment.&lt;/p&gt;

&lt;p&gt;The workflow that actually wins is &lt;strong&gt;non-destructive timeline editing&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record raw.&lt;/strong&gt; Capture the screen and a sidecar of cursor positions, clicks, and timestamps. No zoom baked in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit.&lt;/strong&gt; In the editor, drop zoom "clips" onto a timeline. Each clip says "from second 4 to second 9, zoom 2x and follow the cursor." Drag the edges, change the scale, undo, redo, none of it touches the recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview.&lt;/strong&gt; The editor renders the zoom live as you scrub, so you see exactly what you will get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export.&lt;/strong&gt; A GPU bake pass renders the final pixels from the same clip data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means the zoom engine has to live in &lt;strong&gt;three places at once&lt;/strong&gt;, and they all have to agree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a TypeScript engine that powers the live preview on a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a WGSL fragment shader that bakes the final pixels on the GPU&lt;/li&gt;
&lt;li&gt;the Rust glue that turns timeline clips into per-frame GPU parameters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting engineering is keeping those three in parity. The interesting math is that they all share one core idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea that makes everything work
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Zoom is not a rescale. It is a crop-window sample.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When you zoom in on a frame, you are not generating pixels. You are picking a smaller rectangular region of the source and drawing it to the full output. The sampler interpolates between source texels for free, so "zoom" collapses to one line of math.&lt;/p&gt;

&lt;p&gt;On the preview canvas it is literally one &lt;code&gt;drawImage&lt;/code&gt; call with a computed source rect:&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;srcW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sourceWidth&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;srcH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sourceHeight&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sx&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;centerX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sourceWidth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;srcW&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceWidth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;srcW&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;sy&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;centerY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sourceHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;srcH&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;srcH&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;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcH&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;sourceWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the GPU it is a UV remap in the fragment shader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let uv = frag_pos.xy / resolution;
let zoom = vec2&amp;lt;f32&amp;gt;(zoom_level, zoom_level_y);
let centered = uv - vec2&amp;lt;f32&amp;gt;(0.5);
let zoomed   = centered / zoom;
let final_uv = bounded_center + zoomed;
let color = textureSample(screen_texture, screen_sampler, final_uv);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same transform, two languages. Pick a window centered on &lt;code&gt;center&lt;/code&gt;, sample it into the output, let the sampler (or the canvas scaler) do the smoothing. No rescale kernel, no intermediate buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick that took the longest: bounded center
&lt;/h2&gt;

&lt;p&gt;This is the part I am most happy with, and it caused the ugliest bugs.&lt;/p&gt;

&lt;p&gt;When you zoom in and pan toward the edge, the obvious fix is to clamp the final UV to &lt;code&gt;[0, 1]&lt;/code&gt; so you never sample outside the texture. That keeps the GPU happy but creates a horrible artifact: the moment the zoom window hits the edge, the border pixels stretch across the whole side of the output. The recording looks like it is melting.&lt;/p&gt;

&lt;p&gt;The fix is subtle. &lt;strong&gt;Do not clamp the UV. Clamp the zoom center instead.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At zoom level &lt;code&gt;z&lt;/code&gt;, the visible window is &lt;code&gt;1/z&lt;/code&gt; wide. Half of that is &lt;code&gt;0.5/z&lt;/code&gt;. So the center can roam freely inside &lt;code&gt;[0.5/z, 1 - 0.5/z]&lt;/code&gt; and the window never leaves the source. If you clamp the center to that range, the final UV is mathematically guaranteed to stay in bounds, and there is nothing left to stretch.&lt;/p&gt;

&lt;p&gt;At 2x zoom the window is half the frame, so the center is locked to &lt;code&gt;[0.25, 0.75]&lt;/code&gt;. The view follows the cursor anywhere in the middle, but it physically cannot be panned far enough to expose out-of-bounds pixels.&lt;/p&gt;

&lt;p&gt;Here is the reason this section matters for the whole architecture: &lt;strong&gt;this same invariant shows up in three files, in three languages, and they must all compute it identically.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the WGSL shader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let half_window = vec2&amp;lt;f32&amp;gt;(0.5) / zoom;
let bounded_center = clamp(uniforms.zoom_center, half_window, vec2&amp;lt;f32&amp;gt;(1.0) - half_window);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the TypeScript preview engine:&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;function&lt;/span&gt; &lt;span class="nf"&gt;getFocusBoundsForScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoomScale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;margin&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;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;zoomScale&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;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&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;margin&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;In the Rust export path, where per-frame center is derived from the crop top-left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pan_x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;source_w&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pan_y&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;source_h&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One invariant, enforced everywhere. When the preview shows a centered cursor, the export shows the same centered cursor, because the boundary math is identical. If you want a single takeaway from this post, it is this: when a value needs bounds, clamp the variable that causes the problem, not the one that displays the symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline model
&lt;/h2&gt;

&lt;p&gt;A zoom effect is just a clip on a track. The data model is deliberately boring:&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;ZoomClip&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// seconds&lt;/span&gt;
  &lt;span class="nl"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// seconds&lt;/span&gt;
  &lt;span class="nl"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 1.0 to 20.0&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ZoomMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// manual | follow | deadzone | auto&lt;/span&gt;
  &lt;span class="nl"&gt;centerX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 0..1 (manual mode)&lt;/span&gt;
  &lt;span class="nl"&gt;centerY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 0..1 (manual mode)&lt;/span&gt;
  &lt;span class="nl"&gt;transitionDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// zoom-in seconds&lt;/span&gt;
  &lt;span class="nl"&gt;transitionOutDuration&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// zoom-out seconds (can differ)&lt;/span&gt;
  &lt;span class="nl"&gt;transitionInCurve&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;ZoomTransitionCurve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transitionOutCurve&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;ZoomTransitionCurve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transitionPanCurve&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;ZoomTransitionCurve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;leadTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// how far ahead a follow clip looks&lt;/span&gt;
  &lt;span class="nl"&gt;deadZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 0..1 dead-zone size&lt;/span&gt;
  &lt;span class="nl"&gt;springTension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 10..500, maps to spring constants&lt;/span&gt;
  &lt;span class="nl"&gt;cursorHidden&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&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 field is editable, every edit goes through an undo stack, the whole project serializes to a &lt;code&gt;.screenix&lt;/code&gt; file. Nothing here is GPU-specific. It is a description of intent.&lt;/p&gt;

&lt;p&gt;The four &lt;code&gt;mode&lt;/code&gt;s cover how the center is decided during the clip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual (&lt;code&gt;static_region&lt;/code&gt;)&lt;/strong&gt;: fixed &lt;code&gt;centerX/centerY&lt;/code&gt;. You parked the zoom on a UI element and it stays there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow (&lt;code&gt;follow_mouse&lt;/code&gt;)&lt;/strong&gt;: center tracks the recorded cursor, spring-smoothed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deadzone (&lt;code&gt;deadzone&lt;/code&gt;)&lt;/strong&gt;: center only moves when the cursor leaves a dead-zone in the middle of the frame. This is the "camera barely moves until you push toward the edge" feel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto (&lt;code&gt;auto_zoom&lt;/code&gt;)&lt;/strong&gt;: clips generated from click events. One button.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is worth a paragraph.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto zoom from clicks
&lt;/h3&gt;

&lt;p&gt;Most demos are "click here, click there, explain." So Auto reads the click sidecar and generates clips automatically: pad 300ms before each click and 2500ms after, merge overlapping ranges, fill tiny gaps, drop anything shorter than a second, and keep at least 4 seconds between zooms so it does not feel hyperactive. The clip center lands on the centroid of the nearby clicks.&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;return&lt;/span&gt; &lt;span class="nx"&gt;ranges&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;range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;groupClicksInRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zoomLevel&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;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="nf"&gt;clickGroupCenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="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.5&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;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;zoomLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deadzone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;centerX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;center&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="na"&gt;centerY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;center&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="cm"&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;You get a sane first cut in one click, then you nudge. This is the mode that makes the product feel like it did the boring part for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The live preview engine (TypeScript)
&lt;/h2&gt;

&lt;p&gt;The preview has to feel instant. Scrub the timeline, and the frame should immediately show the right zoom. So there is a small engine that turns a &lt;code&gt;videoTime&lt;/code&gt; plus the list of clips into a &lt;code&gt;CameraState&lt;/code&gt;:&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;CameraState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;centerX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;centerY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;strength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 0..1, how "zoomed in" we are mid-transition&lt;/span&gt;
  &lt;span class="nl"&gt;isIdentity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;A clip is not a hard on/off. It has a &lt;strong&gt;strength envelope&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;zoom-in&lt;/strong&gt;: ramps 0 to 1 over &lt;code&gt;transitionDuration&lt;/code&gt; at the start&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hold&lt;/strong&gt;: strength 1 across the clip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zoom-out&lt;/strong&gt;: ramps 1 to 0 over &lt;code&gt;transitionOutDuration&lt;/code&gt; after the clip ends
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeRegionStrength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeSec&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;timeSec&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;timeSec&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zoomOutDur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;const&lt;/span&gt; &lt;span class="nx"&gt;tRel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timeSec&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tRel&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;zoomInDur&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;applyCurve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitionInCurve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tRel&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;zoomInDur&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;timeSec&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;applyCurve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitionOutCurve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeSec&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;zoomOutDur&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 strength multiplies the scale, so the zoom eases in and out instead of snapping. And because zoom-in and zoom-out get separate durations and separate curves, you can have a soft 600ms ease in and a snappy 200ms ease out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Curves, and stealing from the best
&lt;/h3&gt;

&lt;p&gt;There are four curves: Linear, Smooth (ease-in-out cubic), Snap (ease-out cubic), and Soft. Soft is the one I care about. It is a deliberate copy of the feel of a certain macOS app, expressed as a CSS-style cubic bezier, &lt;code&gt;cubic-bezier(0.16, 1, 0.3, 1)&lt;/code&gt;:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;easeOutScreenStudio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;cubicBezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.16&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="mf"&gt;0.3&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;t&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 catch is that CSS bezier control points are defined with &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; as separate values, and you have to solve for the parameter that gives you a target &lt;code&gt;x&lt;/code&gt;. So &lt;code&gt;cubicBezier&lt;/code&gt; runs a few Newton-Raphson iterations to get close, then a bisection pass to nail it:&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;function&lt;/span&gt; &lt;span class="nf"&gt;cubicBezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp01&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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;solvedT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetX&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                 &lt;span class="c1"&gt;// Newton-Raphson&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sampleCubicBezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;solvedT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;targetX&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;d&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sampleCubicBezierDerivative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;solvedT&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;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;cx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&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;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&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="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;solvedT&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... a few bisection steps to polish ...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sampleCubicBezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;solvedT&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;Eight Newton iterations plus a bisection safety net is overkill for a 1D easing curve, but it runs once per frame and it is exact. You do not guess the feel, you reproduce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deadzone follow, and why springs matter
&lt;/h2&gt;

&lt;p&gt;Follow and Deadzone modes chase the recorded cursor. The naive version pins the center on the cursor every frame. That looks terrible: the view lurches with every micro-jitter.&lt;/p&gt;

&lt;p&gt;Deadzone fixes the lurch. The center only updates when the cursor crosses out of a central dead-zone. Between those moments, the camera holds still. When it does move, a spring carries it.&lt;/p&gt;

&lt;p&gt;The spring is where most of the "feel" lives. Cursor samples from the OS arrive at irregular intervals, sometimes with big gaps. Linear interpolation between sparse samples looks robotic. A spring feels human. And critically, the spring has to be stable for any timestep, because scrubbing the timeline can hand the engine a huge &lt;code&gt;dt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The preview uses a semi-implicit Euler integrator with a fixed sub-step, so it stays stable even when the frame interval spikes:&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;function&lt;/span&gt; &lt;span class="nf"&gt;stepSpring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dtSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&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;SUB_STEP&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="mi"&gt;240&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;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dtSec&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUB_STEP&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;force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stiffness&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;damping&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&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;A 1/240s sub-step means even a hitching 20fps preview integrates the spring 12 times per rendered frame, so the motion is smooth regardless of display rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bake path: GPU, with a soft landing
&lt;/h2&gt;

&lt;p&gt;Previewing is cheap because the source video is already decoding on the page. Export is different: you are decoding every frame, transforming it, and re-encoding. That is where the GPU earns its keep.&lt;/p&gt;

&lt;p&gt;The export pipeline is three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compute per-frame params.&lt;/strong&gt; The same clip data the preview uses gets flattened into an array of &lt;code&gt;GpuZoomParams&lt;/code&gt;, one per output frame.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;   &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GpuZoomParams&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
       &lt;span class="n"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;zoom_y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// independent Y for fill/reframe crops&lt;/span&gt;
       &lt;span class="n"&gt;center_x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;center_y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&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;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decode on the CPU, zoom on the GPU, encode on the CPU.&lt;/strong&gt; FFmpeg decodes the source to RGBA and pipes it in. The Rust side uploads each frame to a texture, runs the WGSL pass, reads it back, and writes it to the encoder's stdin.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ffmpeg decode -&amp;gt; RGBA pipe -&amp;gt; upload_screen -&amp;gt; GPU zoom pass -&amp;gt; readback -&amp;gt; ffmpeg encode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fall back to FFmpeg filters if the GPU is unusable.&lt;/strong&gt; On a machine with a broken driver or no GPU, the same per-frame params drive an FFmpeg &lt;code&gt;crop&lt;/code&gt; + &lt;code&gt;scale&lt;/code&gt; chain instead. Slower, identical output.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That fallback is not a nice-to-have on Linux, it is survival. You cannot assume Vulkan, you cannot assume a discrete GPU, you cannot assume a GPU at all. So the adapter search cascades through every backend wgpu knows about before giving up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;find_adapter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Adapter&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Backends&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="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Backends&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;VULKAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Backends&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Backends&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="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// software&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;anyhow!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No wgpu adapter found"&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;And because a broken driver can make even &lt;code&gt;new()&lt;/code&gt; panic, GPU init is wrapped in &lt;code&gt;catch_unwind&lt;/code&gt;. If it fails for any reason, the recorder drops to the FFmpeg path. The user never sees a crash, the export just finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shader, piece by piece
&lt;/h2&gt;

&lt;p&gt;The bake shader is small and worth reading because the choices are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No vertex buffer.&lt;/strong&gt; The vertex shader generates a full-screen quad from &lt;code&gt;@builtin(vertex_index)&lt;/code&gt;. Six hardcoded corners, clip space &lt;code&gt;[-1, 1]&lt;/code&gt;, nothing uploaded per frame.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -&amp;gt; @builtin(position) vec4&amp;lt;f32&amp;gt; {
    var pos = array&amp;lt;vec2&amp;lt;f32&amp;gt;, 6&amp;gt;(
        vec2&amp;lt;f32&amp;gt;(-1.0, -1.0), vec2&amp;lt;f32&amp;gt;(1.0, -1.0), vec2&amp;lt;f32&amp;gt;(-1.0, 1.0),
        vec2&amp;lt;f32&amp;gt;(-1.0,  1.0), vec2&amp;lt;f32&amp;gt;(1.0, -1.0), vec2&amp;lt;f32&amp;gt;(1.0,  1.0)
    );
    return vec4&amp;lt;f32&amp;gt;(pos[vertex_index], 0.0, 1.0);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bilinear filtering does the work.&lt;/strong&gt; The sampler is the quiet hero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sampler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="nf"&gt;.create_sampler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SamplerDescriptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;address_mode_u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;AddressMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ClampToEdge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;address_mode_v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;AddressMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ClampToEdge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mag_filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;FilterMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;min_filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;wgpu&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;FilterMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&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;Linear&lt;/code&gt; on both axes is what makes the zoom look smooth instead of blocky. The texture unit interpolates between source texels, so a 4x zoom into a small crop still looks clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BGR swap for free.&lt;/strong&gt; Some capture backends deliver frames in BGRx. On the CPU, swapping channels for a 1080p frame is a per-pixel loop. In the shader it is one branch, run in parallel across millions of pixels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let color = textureSample(screen_texture, screen_sampler, final_uv);
if (uniforms.swap_bgr &amp;gt; 0.5) {
    return vec4&amp;lt;f32&amp;gt;(color.b, color.g, color.r, color.a);
}
return color;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A runtime uniform flag decides whether to swap, so X11 (RGBx) and Wayland (BGRx) share one shader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Independent X and Y zoom.&lt;/strong&gt; The uniform carries both &lt;code&gt;zoom_level&lt;/code&gt; and &lt;code&gt;zoom_level_y&lt;/code&gt;. For cursor zoom they are equal, so the zoom is square. But turning a 16:9 recording into a 9:16 social cut needs a window whose aspect differs from the source, so the two axes zoom by different amounts. One shader, two jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The readback plumbing nobody writes about
&lt;/h2&gt;

&lt;p&gt;The shader is the fun 5%. The other 95% is getting bytes onto the GPU and back, on whatever Linux machine the user has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Triple buffering.&lt;/strong&gt; The controller keeps three source textures, three readback buffers, and three bind groups, selected by a rotating index. While the GPU renders frame N into slot 0, the CPU can map and read slot 2 from frame N minus 2. Recording never stalls waiting for a buffer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 256-byte alignment trap.&lt;/strong&gt; &lt;code&gt;copy_texture_to_buffer&lt;/code&gt; requires &lt;code&gt;bytes_per_row&lt;/code&gt; to be aligned to 256 bytes. A 1920-wide RGBA row is 7680 bytes, which happens to be aligned. A 1366-wide row is 5464 bytes, which is not. So the padded row size is computed explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bytes_per_row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;width&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;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you map that buffer back, there is padding at the end of every row. You have to un-pad it row by row into the output buffer, or the encoder gets a diagonally sheared frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.height&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;src_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bytes_per_row&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;dst_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;row_bytes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;out_rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dst_start&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;dst_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;row_bytes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nf"&gt;.copy_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;src_start&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;src_start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;row_bytes&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;Then &lt;code&gt;device.poll(WaitForSubmissionIndex(...))&lt;/code&gt; blocks until the GPU is actually done, and you unmap. Forget the poll and you get last frame's pixels. Forget the unpad and the export looks like glitch art. Forget the unmap and you leak the buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the three renderers in parity
&lt;/h2&gt;

&lt;p&gt;The hardest bug class in this project is "preview looks right, export is off by a few pixels." When you have one math idea expressed in TypeScript, WGSL, and Rust, they drift.&lt;/p&gt;

&lt;p&gt;The defenses I landed on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One invariant, copied verbatim.&lt;/strong&gt; The bounded-center margin &lt;code&gt;0.5 / zoom&lt;/code&gt; appears in all three. When I changed it once, I grepped for &lt;code&gt;0.5 /&lt;/code&gt; and &lt;code&gt;1 / (2 *&lt;/code&gt; everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the export from the same clip data.&lt;/strong&gt; The export does not re-decide where to zoom. It receives the same &lt;code&gt;ZoomClip&lt;/code&gt;s and cursor sidecar the preview uses, and deterministically replays them. There is no second source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Float arrays over integer rects.&lt;/strong&gt; When deriving the GPU center from a crop window, the export prefers continuous &lt;code&gt;pan_x&lt;/code&gt;/&lt;code&gt;pan_y&lt;/code&gt; float arrays over integer-rounded frame rects, because rounding the rect each frame introduces sub-pixel jitter that the preview never had.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest fallback.&lt;/strong&gt; The FFmpeg fallback path uses the same per-frame params, so a GPU-less machine gets the same framing, just rendered slower.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;A few things I wish I had internalized on day one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bake, do not burn.&lt;/strong&gt; Recording zoom live feels clever until a user needs to fix one transition. Non-destructive timeline effects cost more to build and pay off forever. Match the modern workflow, even if the live version is less code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for the right primitive.&lt;/strong&gt; "Zoom" felt like a scaling problem. It was a sampling problem. Reframing it as a crop-window sample made the expensive part free, in both a canvas &lt;code&gt;drawImage&lt;/code&gt; and a shader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clamp the cause, not the symptom.&lt;/strong&gt; Clamping the UV melted the edge of the frame. Clamping the center removed the whole class of artifacts. When a clamp produces artifacts, you are usually clamping the wrong variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One invariant, three languages.&lt;/strong&gt; When the same rule lives in TS, WGSL, and Rust, copy it verbatim and grep for it. Abstraction across a language boundary costs more than the duplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for the GPU to be absent.&lt;/strong&gt; Every GPU path has an FFmpeg fallback. The export works on a headless VM with a software adapter. That is not a feature, it is a survival strategy on Linux.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The boring plumbing is the project.&lt;/strong&gt; The shader is 99 lines. The controller hosting it is 800. The parity discipline around it is the real work. Budget accordingly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;This is a real product, and the zoom engine is the soul of it. If you want a &lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;polished screen recorder for Linux&lt;/a&gt; without leaving your operating system, &lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;give Screenix a try&lt;/a&gt; (or read the &lt;a href="https://screenix.studio/screen-studio-alternative" rel="noopener noreferrer"&gt;Screen Studio alternative breakdown&lt;/a&gt; if you are coming from macOS).&lt;/p&gt;

&lt;p&gt;If you want to build your own GPU pipeline in Rust, wgpu is hard to beat for headless, cross-backend work. Start with the bounded-center trick, a full-screen triangle, and a deterministic per-frame param array. The rest is readback.&lt;/p&gt;

&lt;p&gt;If you found this useful, I write about Rust, graphics, and Linux desktop internals as I ship this stuff. Questions and roasts welcome in the comments.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>gpu</category>
      <category>wgpu</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Recording screen on Linux: the state of things in 2026</title>
      <dc:creator>Mathis</dc:creator>
      <pubDate>Sun, 31 May 2026 07:13:29 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/mathisdev7/recording-screen-on-linux-the-state-of-things-in-2026-3ppa</link>
      <guid>https://dev.clauneck.workers.dev/mathisdev7/recording-screen-on-linux-the-state-of-things-in-2026-3ppa</guid>
      <description>&lt;h2&gt;
  
  
  Recording screen on Linux: the state of things in 2026
&lt;/h2&gt;

&lt;p&gt;If you have ever tried to record your screen on Linux, you already know the landscape is fragmented. Some tools only work on X11, others work on Wayland but lack basic features, and a few try to do everything and end up with a setup process that takes longer than the recording itself.&lt;br&gt;
I have been building a screen recorder for Linux for the past year, and I have tested every option I could find along the way. This is where things stand right now.&lt;/p&gt;

&lt;h3&gt;
  
  
  The built-in option: GNOME screen recorder
&lt;/h3&gt;

&lt;p&gt;If you are on Ubuntu or Fedora with GNOME, you already have a screen recorder built in. Press Ctrl+Alt+Shift+R and it starts recording, press it again to stop.&lt;br&gt;
The problem is that it is extremely limited. There are no audio controls, no webcam overlay, no zoom, and no cursor effects. The output is a WebM file that you will probably need to convert before sharing anywhere. It works for quick bug reports but it is not a tool for making content that people actually want to watch.&lt;/p&gt;

&lt;h3&gt;
  
  
  OBS Studio
&lt;/h3&gt;

&lt;p&gt;OBS is the one everyone knows. It is free, open source, and works on Linux with full support for live streaming to Twitch and YouTube, multi-source scenes, capture cards, browser overlays, and advanced audio routing. If you need any of those things, OBS is the right choice and nothing else comes close.&lt;br&gt;
But OBS was built for streaming, and when you use it for tutorial recording you end up doing a lot of setup that has nothing to do with the content you are trying to make. You configure scenes, add sources, set up audio devices, and tweak output settings before you can even hit record. If you want smooth zoom effects you need plugins or scripts, and if you want cursor emphasis you need more plugins or post-production editing.&lt;br&gt;
OBS is the best free tool for streaming and complex productions, but it is not the best tool for recording a quick tutorial on Linux.&lt;/p&gt;

&lt;h3&gt;
  
  
  SimpleScreenRecorder
&lt;/h3&gt;

&lt;p&gt;SimpleScreenRecorder does what it says on the tin. It records your screen on X11 with a simple interface and minimal resource usage, and it is reliable and lightweight for what it does.&lt;br&gt;
The catch is that it only works on X11, which means no Wayland support at all. There are also no zoom effects, no cursor overlay, and no editing capabilities. If you are on a modern Linux desktop running Wayland, which is now the default on both Ubuntu and Fedora, SimpleScreenRecorder simply will not work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kazam
&lt;/h3&gt;

&lt;p&gt;Kazam is another simple recorder built for GNOME with a clean interface and webcam overlay support that works for quick clips.&lt;br&gt;
The problem is that Kazam has not seen meaningful development in years. Wayland support is partial at best, there are no zoom effects or cursor styling, and codec options are limited. It works for basic recording but it has not kept up with what Linux creators actually need in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  VokoscreenNG
&lt;/h3&gt;

&lt;p&gt;VokoscreenNG has more features than Kazam with scheduled recordings, multiple capture modes, and cross-platform support for both Linux and Windows, all open source.&lt;br&gt;
The tradeoff is that the UI feels dated, there are no zoom or cursor effects, and the community around it is small. It is a step up from Kazam if you need scheduling but it is still a basic recorder that does not go beyond raw capture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kooha
&lt;/h3&gt;

&lt;p&gt;Kooha is worth mentioning because it is one of the few recorders that was built for Wayland from the start, using PipeWire for screen capture which is the correct modern approach on Linux.&lt;br&gt;
The tradeoff is that Kooha is minimal to a fault. There are no zoom effects, no cursor styling, and no editing tools. It records your screen and saves a file, and if you just need a clean Wayland recording with no fuss then Kooha works well. But if you need anything beyond raw capture you will need another tool to make the output look presentable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Screenix
&lt;/h3&gt;

&lt;p&gt;I built Screenix because none of these tools did what I needed. I wanted to record a tutorial on Linux and have it look polished without spending time in an editor afterward.&lt;br&gt;
Screenix records your screen with automatic smooth zooms that follow your cursor. You click somewhere and it zooms in, you move to another area and it follows, and when you stop recording the zoom effects are already baked into the video with no keyframes or plugins or post-production required.&lt;br&gt;
It also handles cursor styling with 15 themes, click emphasis, camera overlay with transitions, clip cutting, speed adjustments, blur and highlight zones, and a screenshot editor that lets you capture, beautify, and export screenshots in one click.&lt;br&gt;
It works on both X11 and Wayland through PipeWire, with support for Ubuntu 22.04+, Fedora 40+, Arch, Manjaro, CachyOS, and Linux Mint. You install it, hit record, and the output is ready to share.&lt;br&gt;
The downside is that Screenix is paid software. There is a 7-day free trial with full exports and no credit card required, but after that it requires a license. It also does not support live streaming, and it is Linux only with no Windows or macOS build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where things stand
&lt;/h3&gt;

&lt;p&gt;The Linux screen recording landscape in 2026 breaks down pretty clearly. OBS Studio is still the answer for streaming and complex productions, and nothing else comes close for that use case. SimpleScreenRecorder is lightweight and reliable if you are still on X11. Kooha and the GNOME built-in recorder work for basic Wayland capture but they are limited to raw recording with no polish. And Screenix is the only option on Linux that handles zoom, cursor effects, and editing in one workflow for people making tutorials and demos.&lt;br&gt;
The gap that still exists is between free tools that do raw capture well and paid tools that handle the post-production workflow. Most Linux creators end up recording with one tool, editing with another, and spending time on zoom effects and cursor highlighting that should not require manual keyframes in 2026.&lt;br&gt;
If you want to try Screenix, the free trial is at &lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;screenix.studio&lt;/a&gt;. No credit card required, full exports for 7 days.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I'm 19 and I Built a Screen Studio Alternative for Linux with Rust and wgpu, Here's What I Learned</title>
      <dc:creator>Mathis</dc:creator>
      <pubDate>Wed, 18 Mar 2026 12:37:16 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/mathisdev7/im-19-and-i-built-a-screen-studio-alternative-for-linux-with-rust-and-wgpu-heres-what-i-learned-log</link>
      <guid>https://dev.clauneck.workers.dev/mathisdev7/im-19-and-i-built-a-screen-studio-alternative-for-linux-with-rust-and-wgpu-heres-what-i-learned-log</guid>
      <description>&lt;h1&gt;
  
  
  I'm 19 and I Built a Screen Studio Alternative for Linux with Rust and wgpu, Here's What I Learned
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;Try Screenix here!&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I got tired of waiting for someone else to build it.&lt;/p&gt;

&lt;p&gt;If you're on macOS and record videos for your dev blog or your product, you've probably used Screen Studio. It's polished, it exports beautifully, the zoom animations feel intentional. On Linux you get OBS (which is powerful but built for streaming, not short product demos) or some GNOME recorder that exports a flat .webm. That's it.&lt;/p&gt;

&lt;p&gt;I'm Mathis. I'm 19, I do indie dev full-time, and I spent the last few months building Screenix, a screen recorder and video editor for Linux with automatic zoom, cursor tracking, and clean export. I built it in Rust with wgpu for rendering. I have 2 paying customers so far and a long list of things that were harder than I expected.&lt;/p&gt;

&lt;p&gt;This post is about the hard parts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Rust and wgpu
&lt;/h2&gt;

&lt;p&gt;Not because I wanted to suffer. Rust felt like the right call for a native Linux app where I needed direct control over rendering, memory, and system APIs. I didn't want to wrap Electron around everything or fight with GTK's rendering pipeline when I needed frame-accurate video output.&lt;/p&gt;

&lt;p&gt;wgpu gave me a portable GPU abstraction that works on Vulkan on Linux and could potentially work on other backends later. The learning curve was steep, but once the mental model clicked, the control it gave me over the rendering loop was worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hardest Part: Wayland Cursor Tracking
&lt;/h2&gt;

&lt;p&gt;This is where I spent probably the most unexpected amount of time.&lt;/p&gt;

&lt;p&gt;On X11, getting the cursor position is straightforward. On Wayland, the compositor controls everything and doesn't just let you query a global cursor position whenever you want. This is by design for security, but it makes building a screen recorder genuinely annoying.&lt;/p&gt;

&lt;p&gt;My first approach was a GNOME Shell extension. I wrote a small extension that exposed the cursor position over D-Bus, and Screenix would query it from the extension. It worked, but it meant requiring users to install and enable a separate extension before they could even start recording. Not ideal for a product that's supposed to feel polished.&lt;/p&gt;

&lt;p&gt;I eventually moved the position tracking to use the SPA (Simple Plugin API) metadata, which is part of the PipeWire ecosystem. PipeWire is the audio and video routing layer that modern Linux desktops use, and SPA metadata lets you attach arbitrary data to streams, including pointer position. This was cleaner because if you're capturing the screen through PipeWire anyway (which you are on Wayland), the cursor position can travel with the stream without needing a separate channel.&lt;/p&gt;

&lt;p&gt;But there's a catch: I still use the GNOME Shell extension for the cursor shape.&lt;/p&gt;

&lt;p&gt;The cursor shape (whether the cursor looks like an arrow, a hand, a text cursor, etc.) is separate from the position. PipeWire doesn't expose this. The Wayland protocol has a &lt;code&gt;cursor-shape&lt;/code&gt; protocol, but getting the shape of the cursor as seen by another application, not your own, requires compositor cooperation. The extension is the only reliable way I found to get this on GNOME Wayland. So there's still a dependency, just a reduced one.&lt;/p&gt;

&lt;p&gt;If you know a better way, I genuinely want to hear it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Went Wrong with the Editor Engine
&lt;/h2&gt;

&lt;p&gt;The editor is where the product actually differentiates itself from "just a recorder." You record, then you get a timeline where you can add zoom effects, adjust the cursor smoothing, and export.&lt;/p&gt;

&lt;p&gt;The zoom system was conceptually simple: track the cursor, add smooth keyframes around fast cursor movements, scale the viewport. In practice I had to think carefully about when to trigger a zoom (not every mouse movement, that would be seizure-inducing), how to smooth the easing so it doesn't feel mechanical, and how to handle recordings where the cursor barely moves versus recordings where it's flying across a 4K display.&lt;/p&gt;

&lt;p&gt;The manual zoom layer I built on top of the automatic one took longer than expected because I needed a good interaction model. You scrub the timeline, you set a zoom region, you adjust it. Getting this to feel responsive in the editor while not killing export performance required separating the preview rendering from the export pipeline.&lt;/p&gt;

&lt;p&gt;Export speed is still something I'm actively working on. The bottleneck right now is in how I'm handling frame composition before encoding. I haven't fully optimized the wgpu pipeline for batch frame output yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Underestimated
&lt;/h2&gt;

&lt;p&gt;A few things I thought would be easy that weren't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fedora support.&lt;/strong&gt; PipeWire versions and GNOME versions vary more than I expected between Ubuntu and Fedora. Things that worked on one didn't work on the other. I ended up having to test both and handle some version-specific paths explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The extension installation UX.&lt;/strong&gt; Even with the reduced dependency, asking users to install a GNOME extension is friction. A lot of people don't know how to do it, or they're on a locked-down system, or they're not on GNOME at all. I need a better fallback story for non-GNOME Wayland compositors. KDE Plasma handles things differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Being the only person working on this.&lt;/strong&gt; Not a technical problem, but it's real. Every bug I hit, I hit alone. There's no one to rubber duck with at 2am when the wgpu render pass is producing artifacts I don't understand. I got good at reading spec documents and graphics debugging tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Worked
&lt;/h2&gt;

&lt;p&gt;The rendering approach with wgpu turned out to be the right call. Once I had the pipeline working, adding effects like zoom, cursor highlight, and background rendering was additive rather than a constant fight with the existing architecture.&lt;/p&gt;

&lt;p&gt;The PipeWire integration is solid. Using the native Linux AV stack instead of trying to capture frames through X11 hacks means the capture quality is good and the approach is forward-compatible as more distros move fully to Wayland.&lt;/p&gt;

&lt;p&gt;Building in public on X helped me stay accountable. Posting daily recordings made with Screenix, of Screenix being built, was a neat loop that also forced me to actually use the product every day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where It's Going
&lt;/h2&gt;

&lt;p&gt;Current focus is export speed and picture-in-picture. After that I want to improve the cursor smoothing algorithm and add support for non-GNOME compositors.&lt;/p&gt;

&lt;p&gt;If you're building something on Linux and struggling with Wayland capture, PipeWire integration, or wgpu rendering, feel free to reach out. And if you're on Linux and need a screen recorder that actually looks good, that's what Screenix is for.&lt;/p&gt;

&lt;p&gt;Still building.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>screenstudio</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
