<?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: Sean</title>
    <description>The latest articles on DEV Community by Sean (@sean_2de44664048773bf102a).</description>
    <link>https://dev.to/sean_2de44664048773bf102a</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%2F4039336%2F811c700d-54d4-465a-ba0c-7abbea0d64e6.png</url>
      <title>DEV Community: Sean</title>
      <link>https://dev.to/sean_2de44664048773bf102a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sean_2de44664048773bf102a"/>
    <language>en</language>
    <item>
      <title>Designing an AI Photo Restoration Workflow That Feels Trustworthy</title>
      <dc:creator>Sean</dc:creator>
      <pubDate>Thu, 06 Aug 2026 07:58:55 +0000</pubDate>
      <link>https://dev.to/sean_2de44664048773bf102a/designing-an-ai-photo-restoration-workflow-that-feels-trustworthy-47ed</link>
      <guid>https://dev.to/sean_2de44664048773bf102a/designing-an-ai-photo-restoration-workflow-that-feels-trustworthy-47ed</guid>
      <description>&lt;p&gt;Old family photos are emotionally valuable, so an AI restoration tool has to do more than produce a sharp image. It has to make people feel in control of a sensitive, irreplaceable file. While building &lt;a href="https://clarivnt.com" rel="noopener noreferrer"&gt;Clarivnt&lt;/a&gt;, an online old-photo restoration tool, I found that the product experience around the model matters just as much as the model output.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make the comparison the center of the interface
&lt;/h2&gt;

&lt;p&gt;A restored photo is difficult to judge in isolation. Users need to see what changed and confirm that the people still look recognizable.&lt;/p&gt;

&lt;p&gt;Clarivnt uses a large split before-and-after view as the main visual element. The original stays visible, the restored version sits beside it, and a draggable divider makes the difference easy to inspect. This is more useful than placing two disconnected thumbnails on the page because users can compare the same facial features and damaged areas directly.&lt;/p&gt;

&lt;p&gt;The broader lesson applies to many AI products: show the input and output in one continuous context whenever users need to evaluate a transformation.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Explain the workflow before asking for a file
&lt;/h2&gt;

&lt;p&gt;The upload area states the supported formats—JPG, PNG, and WEBP—and the 10 MB limit before a file is selected. Nearby copy explains what the tool can improve: fading, scratches, blur, lost color, and soft facial details.&lt;/p&gt;

&lt;p&gt;This removes small uncertainties that often cause abandonment. A user should not have to discover requirements after an upload fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Put privacy language next to the action
&lt;/h2&gt;

&lt;p&gt;A family photograph is not a generic test image. Privacy information belongs near the upload control, not only in a policy page. Clarivnt tells users that photos are not placed in a public gallery and that the original remains untouched.&lt;/p&gt;

&lt;p&gt;That sentence is short, but it answers two important questions at the moment they arise: “Will other people see this?” and “Will I lose my original?”&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep the application layer predictable
&lt;/h2&gt;

&lt;p&gt;The web app uses Next.js 16, React 19, TypeScript, and Tailwind CSS. The goal of the stack is not novelty; it is to keep the interaction responsive and maintainable while supporting authentication, restoration credits, localized pages, and the upload/result flow.&lt;/p&gt;

&lt;p&gt;For this kind of product, the interface has several states that should remain explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no image selected&lt;/li&gt;
&lt;li&gt;upload validation&lt;/li&gt;
&lt;li&gt;restoration in progress&lt;/li&gt;
&lt;li&gt;completed result&lt;/li&gt;
&lt;li&gt;comparison and download&lt;/li&gt;
&lt;li&gt;recoverable errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treating those as product states rather than scattered loading flags makes the experience easier to reason about and test.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Give people a low-risk first result
&lt;/h2&gt;

&lt;p&gt;New accounts receive two free restoration credits. That is enough to test the workflow on a real photo without forcing a purchase decision before users understand the result quality.&lt;/p&gt;

&lt;p&gt;For AI tools, a useful free experience is often more convincing than a long feature list. The output itself becomes the product demonstration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would carry into the next AI tool
&lt;/h2&gt;

&lt;p&gt;The strongest lessons were simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make changes inspectable.&lt;/li&gt;
&lt;li&gt;State file requirements before upload.&lt;/li&gt;
&lt;li&gt;Put privacy reassurance beside sensitive actions.&lt;/li&gt;
&lt;li&gt;Model the full workflow, including failures.&lt;/li&gt;
&lt;li&gt;Let users experience a real result before asking them to pay.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can try the live workflow at &lt;a href="https://clarivnt.com" rel="noopener noreferrer"&gt;clarivnt.com&lt;/a&gt;. I would be interested to hear which part of an AI image workflow most affects your trust: the comparison UI, the privacy policy, or the consistency of the result.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>nextjs</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Built a Wobble GIF Maker with Canvas API and Next.js</title>
      <dc:creator>Sean</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:37:26 +0000</pubDate>
      <link>https://dev.to/sean_2de44664048773bf102a/how-i-built-a-wobble-gif-maker-with-canvas-api-and-nextjs-55g3</link>
      <guid>https://dev.to/sean_2de44664048773bf102a/how-i-built-a-wobble-gif-maker-with-canvas-api-and-nextjs-55g3</guid>
      <description>&lt;p&gt;A still image only needs a few well-chosen pixels to move before it starts to feel alive. That idea became &lt;a href="https://purupurumaker.me/" rel="noopener noreferrer"&gt;Purupuru Maker&lt;/a&gt;, a free browser tool for turning images into wobbly GIFs and videos.&lt;/p&gt;

&lt;p&gt;The editor lets you paint the part of an image that should move, choose one of six motion presets, tune the feel, and export the result. The important constraint was privacy: the source image should never need to leave the browser.&lt;/p&gt;

&lt;p&gt;This post walks through the actual architecture: a Canvas-based mask editor, a deformable WebGL mesh, spring motion, local GIF/video encoding, and a small SVG trick used on the landing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser-only architecture
&lt;/h2&gt;

&lt;p&gt;Purupuru Maker is built with Next.js 15 and React 19. Next.js handles routing, localized pages, metadata, and the surrounding product UI. The animation studio is a client component because it depends on browser primitives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTML Canvas for painting masks, compositing frames, and capture&lt;/li&gt;
&lt;li&gt;Pointer Events for mouse, pen, and touch input&lt;/li&gt;
&lt;li&gt;Three.js/WebGL for the deformable image mesh&lt;/li&gt;
&lt;li&gt;requestAnimationFrame for live preview&lt;/li&gt;
&lt;li&gt;MediaRecorder and canvas.captureStream() for video&lt;/li&gt;
&lt;li&gt;gifenc for GIF quantization and encoding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a user chooses a file, the editor creates a local object URL and decodes it into an HTMLImageElement. There is no upload request in this path. The image, mask, preview frames, and exported Blob all stay on the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Paint motion as an alpha mask
&lt;/h2&gt;

&lt;p&gt;The first design decision was to separate “what moves” from “how it moves.” A hidden canvas stores the painted mask. Its alpha channel is the motion weight: transparent pixels remain anchored; opaque pixels move most.&lt;/p&gt;

&lt;p&gt;The same pointer-event code works for a mouse, stylus, or finger. Painting and erasing are just two compositing modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;erase&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destination-out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;source-over&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rgba(238, 120, 104, 0.65)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;brushSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;round&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineJoin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;round&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;point&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;point&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the mask independent means a user can switch from Jelly to Sway without repainting anything. Undo/redo stores recent ImageData snapshots, while batch items can keep their own masks.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Convert the mask into mesh weights efficiently
&lt;/h2&gt;

&lt;p&gt;The image is divided into a regular triangle grid: 64×64 cells on desktop and 44×44 on mobile. Alternating each cell's diagonal avoids a visible directional bias.&lt;/p&gt;

&lt;p&gt;Reading the canvas once is important. Calling getImageData() for every vertex would create thousands of expensive readbacks, so the renderer reads the mask once and builds a summed-area table. Each vertex can then average a small neighborhood with four array lookups.&lt;/p&gt;

&lt;p&gt;After sampling, I run three smoothing passes and fade weights near the outer edge. The result is a soft deformation field instead of a hard cut between moving and fixed pixels. That soft boundary is what keeps pudding, hair, fabric, and cheeks from looking detached from the source image.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deform a textured WebGL mesh
&lt;/h2&gt;

&lt;p&gt;The source image becomes a Three.js CanvasTexture on a BufferGeometry mesh. UV coordinates stay fixed; only vertex positions change. An orthographic camera makes WebGL coordinates line up with the source image's pixel dimensions.&lt;/p&gt;

&lt;p&gt;For every preview frame, the engine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;calculates the target offset for each weighted vertex;&lt;/li&gt;
&lt;li&gt;integrates a spring-damper response toward that target;&lt;/li&gt;
&lt;li&gt;clamps the offset to prevent broken triangles;&lt;/li&gt;
&lt;li&gt;updates the dynamic position buffer;&lt;/li&gt;
&lt;li&gt;renders the mesh and composites its canvas into the visible 2D canvas.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The spring step is deliberately small and stable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stableDt&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;dt&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="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;stiffness&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;stableDt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;stableDt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;stableDt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clamping delta time matters when a background tab wakes up. Without it, one giant frame can inject enough energy to explode the mesh.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Six presets, one physics system
&lt;/h2&gt;

&lt;p&gt;The six presets are different target fields feeding the same spring integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jelly&lt;/strong&gt; combines two-axis sine waves for soft, rounded motion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sway&lt;/strong&gt; increases horizontal movement toward the lower end of a shape, which works well for hair, ribbons, and fabric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounce&lt;/strong&gt; uses a clear vertical beat and a little width change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wave&lt;/strong&gt; varies phase across the grid so motion travels through the image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pulse&lt;/strong&gt; pushes vertices away from or toward the image center.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quake&lt;/strong&gt; combines higher-frequency waves for a nervous or comic shake.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Speed controls phase progression. Amplitude determines the target size. Damping changes how closely the mesh follows the target, and gravity adds a directional bias. On supported mobile devices, DeviceMotion can add a decaying shake impulse too.&lt;/p&gt;

&lt;p&gt;This arrangement made the editor easier to extend: a new preset only needs to describe targetX and targetY. Mask sampling, spring behavior, rendering, and export do not change.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A separate SVG cut-out trick for the landing page
&lt;/h2&gt;

&lt;p&gt;The production editor uses the weighted mesh, but the landing page needed an immediate, lightweight preview before anyone opens the studio.&lt;/p&gt;

&lt;p&gt;For that hero animation, I reuse the same courier image several times inside one SVG. clipPath shapes isolate the hair, ribbon, and scarf. Each clipped image sits in its own group, and CSS applies a small transform animation to that group.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;g&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hero-motion__ribbon"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;
    &lt;span class="na"&gt;clipPath&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"url(#ribbon-clip)"&lt;/span&gt;
    &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/courier.webp"&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1024"&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1024"&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;g&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is intentionally not pretending to be the full editor. It is a cheap, crisp marketing preview that demonstrates selective motion, while the studio uses the general-purpose mask-and-mesh engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Export GIF without a server
&lt;/h2&gt;

&lt;p&gt;GIF export dynamically loads gifenc, renders 24 frames at 20 fps, and runs the physics at 60 Hz between output frames. A 0.6-second warm-up lets the spring settle before capture, so the saved loop matches the live preview instead of starting from a stiff rest pose.&lt;/p&gt;

&lt;p&gt;One surprising detail was palette stability. Generating a new palette for every frame made stationary pixels shimmer. Purupuru Maker quantizes the first frame into one 256-color palette and reuses it for the whole animation. That keeps fixed areas byte-stable and makes the wobble look much cleaner.&lt;/p&gt;

&lt;p&gt;The encoder returns bytes that become an image/gif Blob and a local download URL. No rendered frame is sent to an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Record WebM or MP4 from the same canvas
&lt;/h2&gt;

&lt;p&gt;Video export uses the browser's own media pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&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;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;videoBitsPerSecond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000_000&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 app checks MediaRecorder.isTypeSupported() before showing a format as available. WebM candidates try VP9, then VP8, then generic WebM. MP4 is enabled only when the browser exposes a compatible MP4 recorder.&lt;/p&gt;

&lt;p&gt;The recorder captures the exact canvas used for preview for about 2.2 seconds, collects chunks, creates a Blob, and stops every stream track. If MP4 is not supported, the user can still export WebM without repainting the mask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What local-first changed
&lt;/h2&gt;

&lt;p&gt;“No upload” was not only a privacy line. It simplified the core product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no object storage for private images;&lt;/li&gt;
&lt;li&gt;no render queue or job status;&lt;/li&gt;
&lt;li&gt;no account needed for the editor;&lt;/li&gt;
&lt;li&gt;no server bill for every exported frame;&lt;/li&gt;
&lt;li&gt;instant iteration after every brush stroke.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is that performance depends on the device. The editor limits the working stage to 900×680 pixels, uses a smaller mesh on mobile, caps pixel ratio at 2, and downsizes GIF output to a maximum dimension of 520 pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons from building it
&lt;/h2&gt;

&lt;p&gt;A few implementation choices had an outsized effect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate selection from motion.&lt;/strong&gt; A reusable mask makes preset exploration instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read canvas pixels once.&lt;/strong&gt; The summed-area table turned mask sampling from a bottleneck into cheap array math.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a spring after the waveform.&lt;/strong&gt; Pure sine displacement looks mechanical; spring response adds material feel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use one GIF palette.&lt;/strong&gt; Stable colors matter as much as moving vertices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat SVG and WebGL as different tools.&lt;/strong&gt; SVG cut-outs are ideal for a controlled hero demo; a mesh is better for arbitrary user images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect codecs instead of assuming them.&lt;/strong&gt; Browser-side MP4 support still varies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Purupuru Maker now includes 10 original demos, six presets, GIF/MP4/WebM export, and interfaces in English, Japanese, and Chinese.&lt;/p&gt;

&lt;p&gt;You can try it here: &lt;strong&gt;&lt;a href="https://purupurumaker.me/" rel="noopener noreferrer"&gt;purupurumaker.me&lt;/a&gt;&lt;/strong&gt;. I would love to hear which preset feels best—and what other motion style you would add.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
