<?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.to/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.to/mathisdev7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mathisdev7"/>
    <language>en</language>
    <item>
      <title>OBS Alternatives for Linux</title>
      <dc:creator>Mathis</dc:creator>
      <pubDate>Tue, 28 Jul 2026 07:17:37 +0000</pubDate>
      <link>https://dev.to/mathisdev7/obs-alternatives-for-linux-561a</link>
      <guid>https://dev.to/mathisdev7/obs-alternatives-for-linux-561a</guid>
      <description>&lt;p&gt;The best OBS alternatives for Linux are Screenix for polished tutorials, Kooha for simple Wayland recording, GPU Screen Recorder for gameplay, SimpleScreenRecorder for X11, and wf-recorder for the terminal&lt;/p&gt;

&lt;p&gt;OBS Studio is excellent software, I have used it, benchmarked it, and learned from it&lt;/p&gt;

&lt;p&gt;It is also the wrong tool for a surprising number of Linux screen recordings&lt;/p&gt;

&lt;p&gt;If you stream, manage several cameras, build scenes, route audio through filters, or use capture cards, OBS is probably still the best choice&lt;/p&gt;

&lt;p&gt;If you want to record a clean product demo, tutorial, bug report, or short walkthrough, its flexibility can become setup work, you configure sources, arrange the canvas, check the audio mixer, choose an encoder, record, then open another editor to add zooms, cursor emphasis, cuts, and captions&lt;/p&gt;

&lt;p&gt;I wanted a more direct workflow on Linux, so I built &lt;a href="https://screenix.studio/obs-alternative" rel="noopener noreferrer"&gt;an OBS alternative for Linux&lt;/a&gt; called Screenix, I am obviously biased, but this is not going to be a list where my app mysteriously wins every category&lt;/p&gt;

&lt;p&gt;There are good OBS alternatives for very different jobs, here is the short answer&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Best option&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polished tutorials and product demos&lt;/td&gt;
&lt;td&gt;Screenix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimal Wayland recording&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/SeaDve/Kooha" rel="noopener noreferrer"&gt;Kooha&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gameplay and replay recording&lt;/td&gt;
&lt;td&gt;&lt;a href="https://git.dec05eba.com/gpu-screen-recorder/about/" rel="noopener noreferrer"&gt;GPU Screen Recorder&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lightweight X11 capture&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.maartenbaert.be/simplescreenrecorder/" rel="noopener noreferrer"&gt;SimpleScreenRecorder&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal-first Wayland capture&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ammen99/wf-recorder" rel="noopener noreferrer"&gt;wf-recorder&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming and complex scenes&lt;/td&gt;
&lt;td&gt;&lt;a href="https://obsproject.com/" rel="noopener noreferrer"&gt;OBS Studio&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The best OBS alternatives for Linux in 2026
&lt;/h2&gt;

&lt;p&gt;The right Linux OBS alternative depends on what you are recording, if you need a finished tutorial, choose an editor-first tool, if you need raw Wayland capture, choose a portal-based recorder, if you need gameplay or instant replay, choose a GPU-first recorder&lt;/p&gt;

&lt;p&gt;The six options below are not interchangeable, each one has a clear reason to use it&lt;/p&gt;

&lt;h2&gt;
  
  
  Why look for an OBS alternative on Linux
&lt;/h2&gt;

&lt;p&gt;OBS is built around scenes and sources, that model is powerful because a livestream is a live production&lt;/p&gt;

&lt;p&gt;A tutorial is different, the important decisions often happen after capture&lt;/p&gt;

&lt;p&gt;You may want to change where a zoom begins, hide the cursor while typing, remove a pause, resize the camera, turn a horizontal recording into a vertical clip, or add captions, baking those choices into a live OBS scene makes them harder to change later&lt;/p&gt;

&lt;p&gt;That gives us a better way to compare Linux screen recorders&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How quickly can you start a basic recording&lt;/li&gt;
&lt;li&gt;Does it support X11, Wayland, or both&lt;/li&gt;
&lt;li&gt;Can it capture microphone and desktop audio&lt;/li&gt;
&lt;li&gt;Is it designed for streaming, gameplay, raw capture, or edited tutorials&lt;/li&gt;
&lt;li&gt;What can you change after recording&lt;/li&gt;
&lt;li&gt;Does it require a cloud account&lt;/li&gt;
&lt;li&gt;How much control does it expose over codecs and performance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no universal winner because these priorities conflict, a minimal recorder should not grow an OBS-sized settings panel, and a professional streaming tool should not remove the controls that professionals need&lt;/p&gt;

&lt;h2&gt;
  
  
  1, Screenix, best for polished Linux tutorials
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://screenix.studio/obs-alternative" rel="noopener noreferrer"&gt;Screenix&lt;/a&gt; is the option I built because I wanted the Screen Studio style workflow on Linux, record first, make visual decisions afterward, then export&lt;/p&gt;

&lt;p&gt;It captures on X11 and Wayland, with PipeWire and the desktop portal used for Wayland sessions, it records the screen, microphone, desktop audio, camera, cursor positions, cursor types, and clicks as separate information&lt;/p&gt;

&lt;p&gt;That separation matters, the recording is not just a flat video with every effect burned into it&lt;/p&gt;

&lt;p&gt;After capture, the editor can use the cursor timeline to generate automatic zooms, or you can add manual, follow, and deadzone zoom regions yourself, the zoom timing and framing stay editable, so one awkward transition does not force a new recording&lt;/p&gt;

&lt;p&gt;The editor also includes&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cuts, trims, speed changes, and transitions&lt;/li&gt;
&lt;li&gt;cursor themes, cursor sizing, click effects, and idle hiding&lt;/li&gt;
&lt;li&gt;movable and resizable camera layouts, including background blur or removal&lt;/li&gt;
&lt;li&gt;crop and aspect ratio tools for horizontal, square, and vertical output&lt;/li&gt;
&lt;li&gt;backgrounds, padding, rounded corners, and shadows&lt;/li&gt;
&lt;li&gt;local transcription with Whisper&lt;/li&gt;
&lt;li&gt;MP4, WebM, and GIF export, up to 4K and high frame rates where the source and system support them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoffs are real, Screenix is not a livestreaming app, it has no OBS-sized plugin ecosystem, and it is paid software, currently $39 per year or $59 for a lifetime license, with a 7-day trial and no credit card&lt;/p&gt;

&lt;p&gt;Choose it when the finished recording matters more than live scene control, especially for software demos, coding tutorials, product updates, onboarding videos, and bug reports&lt;/p&gt;

&lt;h2&gt;
  
  
  2, Kooha, best minimal recorder for Wayland
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/SeaDve/Kooha" rel="noopener noreferrer"&gt;Kooha&lt;/a&gt; is what I recommend when someone says, “I just need a record button”&lt;/p&gt;

&lt;p&gt;Its interface stays deliberately small, choose what to capture, choose whether to include the pointer and audio, set a delay if needed, then record&lt;/p&gt;

&lt;p&gt;Kooha uses PipeWire and the XDG desktop portal, which makes it a natural fit for modern GNOME and Wayland desktops, it can record microphone audio, desktop audio, or both, and supports WebM, MP4, GIF, and Matroska output&lt;/p&gt;

&lt;p&gt;This is its biggest strength and its main limitation, Kooha gives you a clean capture, not a full editing workflow&lt;/p&gt;

&lt;p&gt;There are experimental options for hardware encoding, multiple video sources, and individual window recording, but the project labels those experimental for stability or performance reasons, that honesty is useful when deciding whether it fits a production workflow&lt;/p&gt;

&lt;p&gt;Choose Kooha for quick clips, issue reports, and simple recordings where you plan to keep the result as captured&lt;/p&gt;

&lt;h2&gt;
  
  
  3, GPU Screen Recorder, best for gameplay and replay
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://git.dec05eba.com/gpu-screen-recorder/about/" rel="noopener noreferrer"&gt;GPU Screen Recorder&lt;/a&gt; is built around a different priority, recording the screen with very low overhead by keeping capture and encoding on the GPU&lt;/p&gt;

&lt;p&gt;That makes it especially interesting for games, high frame rate desktop capture, streaming, and replay buffers, its graphical frontends can provide a ShadowPlay-style workflow, including a hotkey-driven overlay and background replay recording&lt;/p&gt;

&lt;p&gt;This is not the first tool I would give someone making a narrated product tutorial, it is performance-oriented, hardware support matters, and its interface and setup are closer to gaming capture than timeline editing&lt;/p&gt;

&lt;p&gt;Choose it when every frame of game performance matters, or when you want an always-running replay buffer that can save the last moments after something happens&lt;/p&gt;

&lt;h2&gt;
  
  
  4, SimpleScreenRecorder, best for lightweight X11 recording
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.maartenbaert.be/simplescreenrecorder/" rel="noopener noreferrer"&gt;SimpleScreenRecorder&lt;/a&gt; has been one of the dependable Linux answers for years&lt;/p&gt;

&lt;p&gt;Despite the name, it exposes useful control over codecs, containers, frame rates, audio, and capture areas, it can record the entire screen, part of the screen, or an OpenGL application directly, and its multithreaded pipeline is designed to avoid blocking the application being recorded&lt;/p&gt;

&lt;p&gt;The important caveat in 2026 is display server support, SimpleScreenRecorder is an X11 and OpenGL recorder, it is not the best choice for a native Wayland session&lt;/p&gt;

&lt;p&gt;That does not make it obsolete, many Linux users still run X11, and for them it remains a capable, free, open source recorder without the scene model of OBS&lt;/p&gt;

&lt;p&gt;One download warning, the maintainer says &lt;code&gt;simplescreenrecorder.com&lt;/code&gt; is unaffiliated and may contain malware, use the &lt;a href="https://www.maartenbaert.be/simplescreenrecorder/" rel="noopener noreferrer"&gt;official project page&lt;/a&gt; or your distribution repository&lt;/p&gt;

&lt;p&gt;Choose it when you use X11, want detailed encoding controls, and only need a reliable raw recording&lt;/p&gt;

&lt;h2&gt;
  
  
  5, wf-recorder, best command-line option for wlroots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ammen99/wf-recorder" rel="noopener noreferrer"&gt;wf-recorder&lt;/a&gt; is the small, composable option in this list&lt;/p&gt;

&lt;p&gt;It records Wayland output through the &lt;code&gt;wlr-screencopy&lt;/code&gt; protocol and works particularly well with wlroots-based compositors such as Sway and Hyprland, it fits naturally into shell scripts, keybindings, and custom desktop workflows&lt;/p&gt;

&lt;p&gt;A typical command can be as small as&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wf-recorder &lt;span class="nt"&gt;-f&lt;/span&gt; recording.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can select a region with a companion tool such as &lt;code&gt;slurp&lt;/code&gt;, choose audio devices, and pass codec options without opening a graphical interface&lt;/p&gt;

&lt;p&gt;The limitation is compatibility, this is not a universal Wayland recorder because not every compositor implements the wlroots capture protocol, GNOME and KDE users are generally better served by a portal-based application&lt;/p&gt;

&lt;p&gt;Choose it when you use a compatible compositor and prefer a Unix-style tool that can be scripted&lt;/p&gt;

&lt;h2&gt;
  
  
  6, OBS Studio, still best for streaming
&lt;/h2&gt;

&lt;p&gt;An article about OBS alternatives should also say when you should stop looking for one&lt;/p&gt;

&lt;p&gt;OBS remains the strongest choice here for&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;live streaming to Twitch, YouTube, or an RTMP endpoint&lt;/li&gt;
&lt;li&gt;complex scenes with several video, browser, image, and capture sources&lt;/li&gt;
&lt;li&gt;capture cards and multi-camera productions&lt;/li&gt;
&lt;li&gt;advanced audio routing, monitoring, filters, and separate tracks&lt;/li&gt;
&lt;li&gt;plugins, scripts, hotkeys, and deeply customized workflows&lt;/li&gt;
&lt;li&gt;a free, open source tool available across Linux, Windows, and macOS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OBS supports both X11 and Wayland on Linux, including PipeWire capture on Wayland, its setup wizard also gives new users a reasonable starting point&lt;/p&gt;

&lt;p&gt;The problem is not that OBS cannot record a tutorial, it clearly can, the question is how much work remains between pressing stop and having the video you wanted&lt;/p&gt;

&lt;p&gt;If you already have a scene collection that produces exactly the right result, keep it, switching tools for the sake of switching tools is wasted time&lt;/p&gt;

&lt;h2&gt;
  
  
  The Linux detail that comparison lists usually miss
&lt;/h2&gt;

&lt;p&gt;On X11, a recorder can inspect windows, capture pixels, and read global cursor coordinates with relatively direct APIs&lt;/p&gt;

&lt;p&gt;Wayland intentionally prevents arbitrary applications from reading the whole desktop, capture normally goes through PipeWire and an XDG desktop portal, the portal asks you which monitor or window to share, then gives the recorder access to that stream&lt;/p&gt;

&lt;p&gt;This is better for security, but it means “supports Linux” is not specific enough&lt;/p&gt;

&lt;p&gt;A recorder may work on X11 but not Wayland, it may work on GNOME but depend on different portal behavior under KDE, or it may use a compositor-specific protocol that works on Sway and Hyprland but not elsewhere&lt;/p&gt;

&lt;p&gt;Before choosing any recorder, check your session with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$XDG_SESSION_TYPE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the result is &lt;code&gt;wayland&lt;/code&gt;, prefer a recorder that explicitly supports PipeWire and the desktop portal, unless you know your compositor provides the protocol required by a tool such as wf-recorder&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from testing OBS against Screenix
&lt;/h2&gt;

&lt;p&gt;I ran a separate 1080p60 NVENC comparison on Ubuntu Wayland with an RTX 2060, both applications completed the recording without dropped frames&lt;/p&gt;

&lt;p&gt;OBS used dramatically less CPU in that benchmark, 11.2 percent on average compared with 120.8 percent for Screenix, Screenix includes its Tauri GUI process in that measurement, but the gap is still large and worth stating plainly&lt;/p&gt;

&lt;p&gt;Screenix used less VRAM in the same run, about 467 MB compared with about 858 MB, and less NVENC utilization, the output files were 14.5 MB for Screenix and 12.9 MB for OBS&lt;/p&gt;

&lt;p&gt;Those numbers did not reveal a universal winner, they revealed different engineering priorities&lt;/p&gt;

&lt;p&gt;OBS is highly optimized for continuous live composition and encoding, Screenix spends more resources on a recording and editing workflow that preserves cursor data, click events, camera information, and non-destructive effects&lt;/p&gt;

&lt;p&gt;You can see the recording samples, method, and full table in my &lt;a href="https://screenix.studio/obs-alternative" rel="noopener noreferrer"&gt;OBS vs Screenix Linux comparison&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My practical recommendation
&lt;/h2&gt;

&lt;p&gt;Use OBS when you are producing something live&lt;/p&gt;

&lt;p&gt;Use GPU Screen Recorder when you are capturing gameplay or need replay&lt;/p&gt;

&lt;p&gt;Use Kooha when you want the smallest useful graphical Wayland recorder&lt;/p&gt;

&lt;p&gt;Use SimpleScreenRecorder when you are on X11 and want detailed capture controls&lt;/p&gt;

&lt;p&gt;Use wf-recorder when your Wayland compositor supports it and you prefer the terminal&lt;/p&gt;

&lt;p&gt;Use &lt;a href="https://screenix.studio" rel="noopener noreferrer"&gt;Screenix&lt;/a&gt; when you are making a tutorial or product demo and want zooms, cursor work, camera layout, editing, transcription, and export in one Linux app&lt;/p&gt;

&lt;p&gt;The best OBS alternative is not the app with the longest feature list, it is the one built around what happens after you press record&lt;/p&gt;

&lt;p&gt;What are you using on Linux, and which desktop or compositor are you using it on, I am especially interested in the setups that work reliably on Wayland&lt;/p&gt;

</description>
      <category>linux</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <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.to/mathisdev7/how-i-built-a-gpu-accelerated-zoom-engine-for-screen-recording-in-rust-40jm</link>
      <guid>https://dev.to/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.to/mathisdev7/recording-screen-on-linux-the-state-of-things-in-2026-3ppa</link>
      <guid>https://dev.to/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.to/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.to/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>
