<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[TheCodeFathers Journal]]></title><description><![CDATA[TheCodeFathers Journal]]></description><link>https://madalitsonyemba.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 04:43:45 GMT</lastBuildDate><atom:link href="https://madalitsonyemba.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I built a video editor that renders sharp 1080p MP4s entirely in the browser]]></title><description><![CDATA[I build websites for clients. A few weeks ago I finished one I was really proud of and wanted to show it off on TikTok. The options were not great. A raw…]]></description><link>https://madalitsonyemba.hashnode.dev/i-built-a-video-editor-that-renders-sharp-1080p-mp4s-entirely-in-the-browser</link><guid isPermaLink="true">https://madalitsonyemba.hashnode.dev/i-built-a-video-editor-that-renders-sharp-1080p-mp4s-entirely-in-the-browser</guid><category><![CDATA[webdev]]></category><category><![CDATA[marketing]]></category><category><![CDATA[Laravel]]></category><dc:creator><![CDATA[Madalitso Nyemba]]></dc:creator><pubDate>Tue, 06 Oct 2026 13:24:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac4f539f3792ca6ad5c54c9/de68b0f8-fc82-4d36-b90f-5d4141ea023e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I build websites for clients. A few weeks ago I finished one I was really proud of and wanted to show it off on TikTok.</p>
<p>The options were not great. A raw screen recording looked flat. Mockup tools were built for static images. Video editors meant an hour of keyframes for a 20 second clip.</p>
<p>So I did what developers do. I opened an empty HTML file.</p>
<h2>The first version: one file, one effect</h2>
<p>The idea was simple. Play the desktop recording inside a laptop, morph the laptop into a phone, switch to the mobile recording, and end on a call to action.</p>
<p>The first version used DOM elements and CSS-style animation. A laptop frame was just a <code>div</code> with a dark border, the screen was a <code>&lt;video&gt;</code> element, and the morph animated <code>width</code>, <code>height</code> and <code>border-radius</code> from laptop proportions to phone proportions while crossfading the two videos.</p>
<p>It looked good. Then I exported it.</p>
<h2>Problem 1: the video was blurry</h2>
<p>To get a video out of a web page, my first approach was screen capture: <code>getDisplayMedia</code>, crop to the frame with Region Capture, and record with <code>MediaRecorder</code>.</p>
<p>The result was soft and smeary. The reason is obvious in hindsight: <strong>you can only capture what is on the screen.</strong> A 1080 x 1920 vertical frame on a normal laptop screen is displayed at roughly 600 x 1080 pixels. The recording is scaled back up for phones, and everything goes soft. Tab capture also compresses on the fly.</p>
<p>No amount of tweaking bitrate fixes missing pixels.</p>
<h2>The fix: stop recording the screen</h2>
<p>Instead of recording what the browser displays, I render every frame myself onto a full-size canvas and encode it directly.</p>
<p>That meant moving the whole animation from DOM to a <code>&lt;canvas&gt;</code> of exactly 1080 x 1920, and writing it as a pure function of time:</p>
<pre><code class="language-js">function draw(t, settings) {
  drawBackground(t);
  drawHook(t, settings);
  drawDevice(t, settings);
  drawCaptions(t, settings);
  drawCallToAction(t, settings);
}
</code></pre>
<p>This one decision unlocked everything else. If <code>draw(t)</code> always produces the same frame for the same <code>t</code>, then preview and export are guaranteed to match. The preview runs it in <code>requestAnimationFrame</code>. The export runs it frame by frame, as fast or slow as the encoder needs.</p>
<h3>Easing without a library</h3>
<p>Since every value is calculated from <code>t</code>, I needed easing functions I could call directly. A small cubic-bezier solver does the job:</p>
<pre><code class="language-js">const cubicBezier = (x1, y1, x2, y2) =&gt; (t) =&gt; {
  if (t &lt;= 0) return 0;
  if (t &gt;= 1) return 1;
  let lo = 0, hi = 1, u = t;
  for (let i = 0; i &lt; 24; i++) {
    u = (lo + hi) / 2;
    const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3;
    if (x &lt; t) lo = u; else hi = u;
  }
  return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
};

const EASE = cubicBezier(0.65, 0, 0.35, 1);
const progress = (t, start, duration, ease) =&gt;
  ease(Math.min(Math.max((t - start) / duration, 0), 1));
</code></pre>
<p>Bisection instead of Newton's method is a little slower but never misbehaves, and at 24 iterations it is more precise than any screen can show.</p>
<h3>Encoding with WebCodecs</h3>
<p>Chrome and Edge ship <code>VideoEncoder</code>, which encodes H.264 in the browser, often with hardware acceleration. Each frame becomes a <code>VideoFrame</code> straight from the canvas:</p>
<pre><code class="language-js">const encoder = new VideoEncoder({
  output: (chunk, meta) =&gt; muxer.addVideoChunk(chunk, meta),
  error: (e) =&gt; console.error(e),
});

encoder.configure({
  codec: "avc1.640028", // H.264 High profile, level 4.0
  width: 1080,
  height: 1920,
  bitrate: 14_000_000,
  framerate: 30,
});

for (let i = 0; i &lt; totalFrames; i++) {
  const t = i / fps;
  await seekVideosTo(t);
  draw(t, settings);

  const frame = new VideoFrame(canvas, {
    timestamp: Math.round((i * 1e6) / fps),
    duration: Math.round(1e6 / fps),
  });
  encoder.encode(frame, { keyFrame: i % (fps * 2) === 0 });
  frame.close();

  // back-pressure: don't flood the encoder
  while (encoder.encodeQueueSize &gt; 8) {
    await new Promise((r) =&gt; setTimeout(r, 5));
  }
}

await encoder.flush();
</code></pre>
<p>A few lessons from this part:</p>
<ul>
<li><strong>Check codec support first.</strong> <code>VideoEncoder.isConfigSupported()</code> tells you whether a codec string works on the user's machine. 60 fps at 1080 x 1920 needs a higher AVC level (<code>avc1.64002A</code>) than 30 fps does.</li>
<li><strong>Always close frames.</strong> Forget <code>frame.close()</code> and memory climbs fast.</li>
<li><strong>Respect back-pressure.</strong> Without the <code>encodeQueueSize</code> check, a fast canvas can queue hundreds of frames.</li>
</ul>
<p><code>VideoEncoder</code> only gives you encoded chunks, so you still need a muxer to wrap them in an MP4 file. I used <code>mp4-muxer</code>, which is tiny and did exactly what I needed. If you are starting today, its author now maintains a successor called Mediabunny that is worth a look.</p>
<h3>Problem 2: the recordings inside the video</h3>
<p>The reel contains the user's own screen recordings. For a frame-perfect export, each recording has to show exactly the right frame at time <code>t</code>, so I seek before drawing:</p>
<pre><code class="language-js">function seek(video, time) {
  return new Promise((resolve) =&gt; {
    const target = Math.min(time % video.duration, video.duration - 0.02);
    if (Math.abs(video.currentTime - target) &lt; 0.0005) return resolve();
    video.addEventListener("seeked", resolve, { once: true });
    video.currentTime = target;
  });
}
</code></pre>
<p>Seeking every frame is slower than playing, but it is deterministic, and that matters more than speed for an export.</p>
<p>Two gotchas cost me time:</p>
<ol>
<li><strong>Cross-origin video taints the canvas.</strong> Once a tainted canvas is drawn, you cannot create a <code>VideoFrame</code> from it. All media is served from the same origin as the app.</li>
<li><strong>Seeking needs HTTP Range support.</strong> Without Range requests, the browser has to download the whole file to seek. The media route streams partial content with a proper <code>206</code> response.</li>
</ol>
<h3>Problem 3: the videos were cut off</h3>
<p>Desktop recordings are wide (mine was 1908 x 868) and phone recordings are tall. The first version used fixed device sizes, so recordings were cropped.</p>
<p>The fix: the device screen takes the aspect ratio of the recording itself, clamped to a sensible size, and the video is drawn with "contain" logic. Even the morph animates from the laptop's real proportions to the phone's real proportions.</p>
<h2>From one file to a product</h2>
<p>People who saw the reel asked how I made it, so the experiment became a product: <strong>ByteMorph</strong>.</p>
<p>The core rule I kept from the prototype is that <strong>a reel is data</strong>. Every reel is a JSON spec: a list of scenes with their text, recordings, timing, transitions and styles. The renderer just draws whatever the spec describes.</p>
<p>That one decision made almost every later feature simple:</p>
<ul>
<li><strong>Templates</strong> are saved specs.</li>
<li><strong>Dragging</strong> elements on the canvas edits positions in the spec.</li>
<li><strong>Four formats</strong> (9:16, 1:1, 4:5, 16:9) come from a layout module that computes everything relative to the canvas size.</li>
<li><strong>The upcoming AI assistant</strong> will edit the same spec, so it cannot break anything the renderer does not understand. The server validates every spec anyway.</li>
</ul>
<h3>Adding 3D</h3>
<p>The 2D canvas cannot do a convincing laptop that spins while its lid opens, so 3D scenes use Three.js. It renders into its own WebGL canvas, and each frame is copied onto the main canvas between the 2D background and the 2D overlays. The export pipeline did not change at all.</p>
<p>The same rule applies: every 3D value comes from <code>t</code>, never from a Three.js clock. Animations are stored as presets (like "lid open, 1.2 seconds, ease out") that expand into keyframes, so the preset and a user's customised version always run through the same code. And Three.js only loads when a reel actually uses 3D.</p>
<h3>Feature callouts and zoom</h3>
<p>For showing off web apps and dashboards, two features turned out to matter most:</p>
<ul>
<li><strong>Callouts:</strong> feature lists, pins and counters as a layer on any scene. Pins store their target in the recording's own coordinates, so they stay locked to the right spot even while the device moves, morphs or zooms.</li>
<li><strong>Zoom to region:</strong> the camera glides into part of the recording so a dashboard is readable on a phone. The zoomed frame is drawn from the full-resolution video, not an upscaled copy.</li>
</ul>
<h3>The rest of the stack</h3>
<ul>
<li><strong>Laravel</strong> with <strong>Inertia</strong> for the app, <strong>Filament</strong> for the admin panel and template management</li>
<li>A <strong>VitePress</strong> docs site with screenshots and clips regenerated by a Playwright script</li>
</ul>
<h2>Try it</h2>
<p>ByteMorph is live today at <strong><a href="https://bytemorph.app/?ref=devto">bytemorph.app</a></strong>. It is free to start, and every new account gets 7 days of Pro. We are also launching on Product Hunt today: <a href="https://www.producthunt.com/products/bytemorph?launch=bytemorph">here</a>.</p>
<p>I would love feedback from other developers, especially on the rendering side. If you have worked with WebCodecs, I am curious what you have run into.</p>
]]></content:encoded></item></channel></rss>