<?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: tony huang</title>
    <description>The latest articles on DEV Community by tony huang (@tonytoolbox).</description>
    <link>https://dev.to/tonytoolbox</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%2F4151487%2Fdfb79ed6-2aa5-4e19-af56-44c18f810be1.png</url>
      <title>DEV Community: tony huang</title>
      <link>https://dev.to/tonytoolbox</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tonytoolbox"/>
    <language>en</language>
    <item>
      <title>"Film look" is a contrast curve, not a noise stamp — what style presets actually change</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:40:16 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/film-look-is-a-contrast-curve-not-a-noise-stamp-what-style-presets-actually-change-en8</link>
      <guid>https://dev.to/tonytoolbox/film-look-is-a-contrast-curve-not-a-noise-stamp-what-style-presets-actually-change-en8</guid>
      <description>&lt;p&gt;"Make it look like film" sounds like a texture request. It is really four separate changes to how your pixels behave, and the difference between applying them and just stamping noise on top is the difference between a photo that reads as scanned film and a photo that reads as a filtered phone shot.&lt;/p&gt;

&lt;p&gt;Here is what actually changes, and how to tell what a tool did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four components of a film look
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Grain sits in the midtones, not everywhere.&lt;/strong&gt; Organic film grain is strongest in the middle values and nearly invisible in deep shadows and blown highlights, because it is an artifact of dye clouds, not an overlay. A noise layer applied uniformly at a fixed opacity is the single most recognizable tell of a fake: grain over jet-black areas does not happen on real film.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Highlights roll off instead of clipping.&lt;/strong&gt; Film negative handles overexposure gracefully — bright areas compress into a soft shoulder rather than snapping to white. Digital sensors clip hard. A film grade pulls that shoulder back, which is why overexposed windows in a graded photo keep some detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Halation adds a faint glow around bright sources.&lt;/strong&gt; Light passing through the emulsion reflects off the film base and back into the layers, producing a red-orange halo around highlights — most visible on small bright points (street lights, jewelry, backlit hair). It is subtle; heavy halation filters read as costume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The contrast curve is a color-negative curve, not a contrast slider.&lt;/strong&gt; Analog color negative has a long, gentle S-curve with lifted blacks and compressed saturation in the extremes. This is the biggest single contributor to "the film feel" and the one a noise stamp completely skips: you can add perfect grain to an untouched digital curve and it still looks digital.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check what a tool actually did
&lt;/h2&gt;

&lt;p&gt;Four checks, no expertise needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zoom to 100% on a dark area.&lt;/strong&gt; Grain there means a uniform noise stamp; clean shadows with grain only in midtones suggests a real grade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find a bright light source.&lt;/strong&gt; Is there a soft glow around it (halation) or a hard white blob (clipped)? Was a faint warm rim added or nothing?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare highlights before and after.&lt;/strong&gt; Did detail appear in previously blown areas (a shoulder was applied) or is it the same clipping with noise on top?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the blacks.&lt;/strong&gt; Lifted, slightly faded blacks are film; pure zeros with grain are a stamp.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a tool fails all four, it sold you a texture, not a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where style presets get more aggressive
&lt;/h2&gt;

&lt;p&gt;Grading presets (film look, Polaroid fades, cross-process) stay in tonality: your edges, faces and composition are untouched. Render presets (watercolor, pencil sketch, oil painting) are a different operation entirely — the image is re-interpreted, hatching and paper texture replace pixels, and fine details like eyelashes can be simplified. Neither is wrong, but they answer different needs: grade when you want the same photo with a different mood; render when you want a different medium. Problems start when a "sketch" tool quietly re-renders faces — that is when likeness drifts, and it is worth checking at 100% before you print.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://aiphotoeditor.best/" rel="noopener noreferrer"&gt;aiphotoeditor.best&lt;/a&gt;, where the film-grain preset is an AI re-grade of the whole image — grain, highlight roll-off, halation and the color-negative curve together, with your scene and faces kept intact — alongside watercolor, pencil sketch and six other style presets. The AI path is three free edits a day, no account; simple local filters, including a mono film look, are unlimited and never upload your photo.&lt;/p&gt;

&lt;p&gt;Honest limits: a re-grade can slightly soften faces (that is the film curve doing its job), and no preset fixes an out-of-focus source — grading changes mood, not optics.&lt;/p&gt;

</description>
      <category>design</category>
      <category>editing</category>
      <category>photography</category>
    </item>
    <item>
      <title>Animating a Photo Into a Dance Video: What Image-to-Video Models Actually Preserve</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Sun, 25 Oct 2026 15:38:24 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/animating-a-photo-into-a-dance-video-what-image-to-video-models-actually-preserve-2c34</link>
      <guid>https://dev.to/tonytoolbox/animating-a-photo-into-a-dance-video-what-image-to-video-models-actually-preserve-2c34</guid>
      <description>&lt;p&gt;Last week I shipped a small tool that turns a single photo into a short dance video — one picture, pick a style (skirt-dance twirl, hip-hop, shuffle, K-pop, disco, breakdance), get back a vertical MP4 with music. It runs on Kling O3 image-to-video through fal.ai. This post is the engineering notes: what an I2V model actually preserves from the source photo, and where the honest limits are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carries over — and what doesn't
&lt;/h2&gt;

&lt;p&gt;The model animates the person who is actually in the photo: face, hairstyle, and outfit are carried into the motion. That's the whole appeal and the whole risk. If the input is a full-body shot facing the camera, identity survives the twirl surprisingly well. If the input is a cropped torso or a three-quarter back view, the model starts inventing — and inventing is where uncanny results come from.&lt;/p&gt;

&lt;p&gt;Practical input rules we ended up documenting (all learned from failed renders):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full body visible, facing the camera. Feet included — the twirl ends where the photo ends.&lt;/li&gt;
&lt;li&gt;Even lighting on the face. Hard side light makes the model re-guess facial geometry mid-spin.&lt;/li&gt;
&lt;li&gt;One person per photo. Two people get merged or one gets dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 5s vs 10s decision is really a credit decision
&lt;/h2&gt;

&lt;p&gt;A 5-second render costs one credit; 10 seconds costs two. That sounds trivial until you watch the outputs: the model spends the first ~1.5 seconds on the wind-up, so a 5-second clip gets you one full move, while 10 seconds gets the move plus a landing. For the skirt-dance twirl specifically, the fabric flare peaks around second 3–4 — the 5s cut is fine, the 10s cut is comfortable.&lt;/p&gt;

&lt;p&gt;We price it as one-time credit packs (&lt;a href="https://dancegenerator.best/" rel="noopener noreferrer"&gt;2 videos $6.99, 6 for $19.99, 18 for $59.99&lt;/a&gt;), credits never expire, and failed renders refund automatically. No subscription, no account. The refund rule isn't generosity — with I2V, a moderate share of renders fail outright, and charging for failures is how you lose trust in one screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direction box is the underrated feature
&lt;/h2&gt;

&lt;p&gt;Besides picking a preset style, you can write free-text direction that gets blended into the choreography — "arms up on the drop" type notes. It works about as well as you'd expect from current models: it biases the motion, it doesn't guarantee it. Worth setting expectations honestly: it's a nudge, not a director's chair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native audio changes the output contract
&lt;/h2&gt;

&lt;p&gt;Kling O3 generates audio with the video, so the MP4 comes out with music already synced. That removes an entire editing step (finding a track, cutting to length, syncing), which is the part casual users never do well anyway. The tradeoff: you don't pick the track. For trend-style clips that's fine; for anything brand-driven you'd still take the output into an editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this should NOT be used
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Photos of real people without their consent. This one's a hard line, and we enforce it in &lt;a href="https://dancegenerator.best/" rel="noopener noreferrer"&gt;the terms&lt;/a&gt; rather than pretending detection will save anyone.&lt;/li&gt;
&lt;li&gt;Anything where fabric or hands matter more than the silhouette. Hands during fast motion are still the classic I2V failure case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try the pipeline without wiring up fal.ai yourself, the tool is at &lt;a href="https://dancegenerator.best/" rel="noopener noreferrer"&gt;dancegenerator.best&lt;/a&gt; — one photo in, pick a style, MP4 out. And if you're building your own: the interesting engineering isn't the API call, it's the input validation and the honest failure handling around it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>tools</category>
    </item>
    <item>
      <title>Image to SVG: the three tracing algorithms, and what a cut-ready file actually needs</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:35:52 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/image-to-svg-the-three-tracing-algorithms-and-what-a-cut-ready-file-actually-needs-4co4</link>
      <guid>https://dev.to/tonytoolbox/image-to-svg-the-three-tracing-algorithms-and-what-a-cut-ready-file-actually-needs-4co4</guid>
      <description>&lt;p&gt;Generating an SVG from a reference image is sold as one button. What you get back varies more than the marketing suggests, because "tracing an image" covers at least three different algorithms, and each produces a file that behaves differently in a cutting machine.&lt;/p&gt;

&lt;p&gt;If you cut vinyl, HTV or laser stock, the differences are not cosmetic — they decide whether the file weeds in ten minutes or becomes a fight with tweezers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three families of image-to-SVG
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Threshold tracing (black and white).&lt;/strong&gt; The software picks a luminance cut-off and turns everything above it into a filled path. This is what most free converters do, and it is the right choice for silhouettes, stencils and single-colour decals. It is also the only family that reliably produces a true single-path cut line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Multi-colour posterization.&lt;/strong&gt; The image is quantised down to N colours, then each colour region becomes its own path on its own layer. This is what you want for layered vinyl — one sheet per colour. The quality lives or dies on two numbers: how many colours the tool commits to, and how it handles the anti-aliased boundary between them. A tool that dumps 40 colours because the source photo had 40 shades has not done the work; 3-6 well-chosen layers are what a craft cutter actually wants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Centerline tracing.&lt;/strong&gt; Instead of outlining shapes, it follows the middle of each stroke — essential for line art, text and anything you want to draw or score rather than fill. Most free tools skip this entirely, which is why a hand-lettered logo converts into solid blobs instead of strokes.&lt;/p&gt;

&lt;p&gt;Check which one your tool runs before you judge the output. A bad result from family 1 is often the right algorithm with the wrong threshold, not a reason to switch tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a cut-ready file actually needs
&lt;/h2&gt;

&lt;p&gt;The SVG spec is generous; cutting software is not. A file that previews fine in a browser can still be unusable in Design Space or Silhouette Studio. The things to check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closed paths.&lt;/strong&gt; Every shape must be a closed contour. Open paths become unpredictable cuts — some software closes them for you, some cuts down the open edge and back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actual fill, not stroked outlines.&lt;/strong&gt; A path that is "a 2px black stroke with no fill" looks identical to a filled shape on screen. On a cutter, the stroke usually disappears and the machine has nothing to cut. This is the single most common failure in converted files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groups per colour layer.&lt;/strong&gt; For layered vinyl, each colour's paths should be grouped, so "send this layer to this mat" is one click rather than a manual sorting session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sane node counts.&lt;/strong&gt; A trace of a 4000-pixel photo can emit tens of thousands of nodes. Design Space slows to a crawl above a few thousand. Simplification is not cosmetic; it is the difference between a file that loads and one that freezes the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physical scale.&lt;/strong&gt; SVGs are unitless until someone assigns a size. Check the file imports at a real-world dimension (width in mm) rather than arriving at 40 kilometres wide or 4 pixels tall.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest test before you cut
&lt;/h2&gt;

&lt;p&gt;Load the file into your cutter's software and zoom to 100% at the size you will actually cut. Two failure modes show up only there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thin-feature problem.&lt;/strong&gt; Strokes finer than roughly 1.5-2 mm on 651 vinyl (and a bit more on HTV) become unusable: the weeding tool lifts the whole island, or the material tears. A good preview shows the thinnest stroke in millimetres so you can make this call &lt;em&gt;before&lt;/em&gt; cutting, not while weeding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The island problem.&lt;/strong&gt; Inside a letter "o" is an island that must be held by a bridge to the surrounding vinyl. Traces that forget bridges produce letters whose counters fall out on the transfer tape. Text-heavy designs need either manual bridges or a font-based rebuild — a trace alone will not save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI generation fits, honestly
&lt;/h2&gt;

&lt;p&gt;Text-to-SVG is a different product from image tracing. A generation model produces clean, closed, layer-grouped paths by construction — no threshold artefacts, no anti-aliasing residue — because it is drawing shapes rather than interpreting pixels. The trade is fidelity: it draws what it understands your prompt to mean, not your exact reference. For an original design, that is fine. For reproducing a specific logo, tracing is the right tool and generation is the wrong one.&lt;/p&gt;

&lt;p&gt;The combination is more useful than either alone: trace the reference for shape fidelity, then use generation to produce a cleaned variant when the trace is too noisy to weed.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://aisvgpro.com/" rel="noopener noreferrer"&gt;AISVG Pro&lt;/a&gt;, which does both under one rule: show the honest numbers before you pay. The free preview reports path count, colour layers and the thinnest stroke in millimetres, so you can compare against your material before spending a credit. The trace runs in your browser — free, no account, nothing uploaded — and a credit unlocks the full pack: layered SVG, transparent PNG, DXF for Silhouette and a real-size PDF.&lt;/p&gt;

&lt;p&gt;Honest limits: browser tracing is 2D and threshold-based; it will not reconstruct occluded parts of an image or vectorise a 3D render reliably. And generation costs real GPU time per image, which is why it is the paid half while local tracing stays free.&lt;/p&gt;

</description>
      <category>svg</category>
      <category>webdev</category>
      <category>design</category>
      <category>beginners</category>
    </item>
    <item>
      <title>JPEG, WebP, PNG: the four decisions hiding inside a format conversion</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:32:35 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/jpeg-webp-png-the-four-decisions-hiding-inside-a-format-conversion-3p6a</link>
      <guid>https://dev.to/tonytoolbox/jpeg-webp-png-the-four-decisions-hiding-inside-a-format-conversion-3p6a</guid>
      <description>&lt;p&gt;Converting an image between JPEG, WebP and PNG sounds like a format setting. It is really three different decisions, and getting them confused is why converted images sometimes come back with a grey box, a wrong size, or visibly worse quality than the file you started with.&lt;/p&gt;

&lt;p&gt;Here is what each format actually stores, and what changes when you convert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each format holds
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;JPEG&lt;/strong&gt; stores a lossy DCT-compressed image, 8 bits per channel, no alpha. Its strengths are photographic data and small files; its weaknesses are sharp edges, text, and anything that will be edited again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PNG&lt;/strong&gt; stores lossless pixels with a full alpha channel. Great for screenshots, UI, logos and anything with transparency or hard edges; large for photographs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebP&lt;/strong&gt; does both: lossy and lossless modes, plus alpha in either. It is typically 25-35% smaller than JPEG at comparable perceived quality, and it supports transparency that JPEG cannot.&lt;/p&gt;

&lt;p&gt;So the first decision is not "which is better" but "does this image have transparency or hard edges, and will it be re-edited".&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: alpha
&lt;/h2&gt;

&lt;p&gt;If the image has transparent pixels, JPEG is out — full stop. Converting RGBA to JPEG has to flatten the alpha channel onto something, and what it flattens onto depends on the encoder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some flatten onto white.&lt;/li&gt;
&lt;li&gt;Some flatten onto black (visibly catastrophic on dark logos).&lt;/li&gt;
&lt;li&gt;Some flatten onto the background colour stored in the file, which may itself be undefined.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the source of the classic "my PNG became a black box" bug. If you must have JPEG, flatten deliberately onto a colour you chose, and check the result rather than assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: repeated lossy conversion
&lt;/h2&gt;

&lt;p&gt;Every JPEG save is a new lossy generation. Decode → re-encode at quality 85 → decode again is not the same image; each pass loses a little high-frequency detail and adds a little ringing, concentrated around edges.&lt;/p&gt;

&lt;p&gt;The visible symptom after 3-4 generations is "mushy text and haloed edges", which people usually blame on the source. If a workflow requires multiple edits, keep a lossless master (PNG, or a full-quality JPEG at q95+) and export derivatives from it, rather than editing the last export.&lt;/p&gt;

&lt;p&gt;WebP has the same property in lossy mode. Being newer does not make it immune.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: the colour profile
&lt;/h2&gt;

&lt;p&gt;Here is the one that quietly ruins conversions: an image tagged with a wide-gamut profile (Display P3) converted by a tool that ignores the profile will display with shifted, oversaturated colours. A tool that honours the profile but then strips it from the output produces a file that looks right on your machine and wrong on someone else's.&lt;/p&gt;

&lt;p&gt;For web delivery the safe pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Convert to sRGB if the image is not already.&lt;/li&gt;
&lt;li&gt;Keep the sRGB profile embedded (or at minimum, do not leave an image tagged P3 but containing sRGB data).&lt;/li&gt;
&lt;li&gt;Verify by opening the output in a different tool than the one that produced it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most "the colours changed after upload" reports are this, not compression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4: resampling, which is separate from format
&lt;/h2&gt;

&lt;p&gt;Changing pixel dimensions is a different operation from changing format, and both are often presented in the same dialog.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Downscaling&lt;/strong&gt; is safe if you use a decent filter (Lanczos or a two-pass box filter). Done naively — nearest neighbour — it aliases, and thin lines become dotted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upscaling&lt;/strong&gt; cannot add information. Bicubic interpolation produces a soft, slightly blurry result; model-based upscalers produce plausible invented detail. Neither restores what was never captured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DPI is metadata, not pixels.&lt;/strong&gt; Changing 300 DPI to 72 DPI without changing pixel dimensions does not change the image at all; it changes the number a printer reads. A 3000×2000 image at 72 DPI is the same data as at 300 DPI. People routinely "resize for print" by only changing DPI and get a file no sharper than before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checklist that catches all four
&lt;/h2&gt;

&lt;p&gt;Before publishing a converted image:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the output have the alpha you expect? (Open it somewhere that shows a checkerboard.)&lt;/li&gt;
&lt;li&gt;Are the pixel dimensions what you intended — not just the DPI field?&lt;/li&gt;
&lt;li&gt;Did you edit the master, or a derivative of a derivative?&lt;/li&gt;
&lt;li&gt;Do the colours match when opened in a second application?&lt;/li&gt;
&lt;li&gt;At 100% zoom, is the text still crisp? (The most sensitive test for lossy damage.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five checks, thirty seconds, and they catch the entire class of conversion accidents.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://aiphotoeditor.best/" rel="noopener noreferrer"&gt;aiphotoeditor.best&lt;/a&gt;, where format conversion and compression run in the browser as ordinary pixel operations — JPEG, PNG and WebP in and out, with the pixel dimensions shown before you download. That layer is free and unlimited because it costs nothing to run; the AI features (enhance, restore, cutout, headshots) are a separate, explicitly counted path with three free runs a day.&lt;/p&gt;

&lt;p&gt;Honest limits: the converter does not invent detail when upscaling, and it does not run colour-profile conversion for you — if your source is Display P3, convert to sRGB in a tool that manages profiles before the file reaches the web. That is a real gap, not a hidden feature.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>design</category>
    </item>
    <item>
      <title>Why 'enhance' often makes old scans look worse (and which of the three operations you actually need)</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:32:45 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/why-enhance-often-makes-old-scans-look-worse-and-which-of-the-three-operations-you-actually-need-7li</link>
      <guid>https://dev.to/tonytoolbox/why-enhance-often-makes-old-scans-look-worse-and-which-of-the-three-operations-you-actually-need-7li</guid>
      <description>&lt;p&gt;"Enhance" is the most oversold button in photo software. On a scanned print it often makes the image measurably worse, and the reason is structural rather than a tuning problem.&lt;/p&gt;

&lt;p&gt;Here is what actually happens inside the three things people mean by enhance, and when each one helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different operations sold under one word
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Sharpening&lt;/strong&gt; — an unsharp mask. It does not add detail; it increases local contrast along existing edges. Give it a blurry edge and it produces a crisp-looking halo around nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Upscaling&lt;/strong&gt; — resampling to more pixels. Classical methods (bicubic, Lanczos) interpolate; they cannot create information. Model-based upscalers generate plausible detail, which is better looking and less truthful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Generative restoration&lt;/strong&gt; — a diffusion model that repaints regions it thinks are damaged. This is the one that can double the perceived quality, and the one that can silently replace a face.&lt;/p&gt;

&lt;p&gt;A tool that labels all three "Enhance" is telling you nothing about which is about to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sharpening makes scans worse
&lt;/h2&gt;

&lt;p&gt;A 1985 print scanned at 600 dpi has film grain, paper texture, and dust. Unsharp mask does exactly what it says: it amplifies the difference between neighbouring pixels. On a clean digital photo that means crisper edges. On a scan it means &lt;strong&gt;amplified grain and amplified dust&lt;/strong&gt;, and the eye reads that as "noisier", not "sharper".&lt;/p&gt;

&lt;p&gt;If you use radius values tuned for a 12-megapixel digital photo — typically 0.5–1.5 px — on a 3000 dpi scan you are operating at the wrong scale entirely. The same radius now sits inside the grain rather than across the edge.&lt;/p&gt;

&lt;p&gt;The practical fix is to denoise before sharpening, and to sharpen with a radius proportional to the scan resolution. There is no single slider value that works for both a phone photo and a flatbed scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why upscaling a blurry photo does not rescue it
&lt;/h2&gt;

&lt;p&gt;Upscaling is a bet about what the missing pixels probably look like. On a photo that is merely small but sharp, that bet is easy and the result is good. On a photo that is blurry — out of focus, motion blur, or heavily compressed — the missing information is not "smaller detail", it is "no detail", and the model fills the gap with plausible texture.&lt;/p&gt;

&lt;p&gt;That texture reads as detail at 100% zoom and as mush at print size, because the fabric of the invented detail does not follow the scene's own geometry. Watch for hair strands that merge into a single wavy plane, and text that becomes decorative squiggles.&lt;/p&gt;

&lt;p&gt;The test: downscale the result back to the original resolution and compare. If it looks like the original, the upscale added pixels without adding information. If it looks different in character, the model invented structure — which may be fine for a thumbnail and wrong for a document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where generative restoration genuinely wins
&lt;/h2&gt;

&lt;p&gt;On physical damage — creases, tears, water stains, dust, faded areas — a model is doing the job no classical filter can: inferring what the paper probably looked like underneath the damage. That is real value and it is why these tools exist.&lt;/p&gt;

&lt;p&gt;The cost is that the same model cannot tell "damaged area" from "unusual face". Give it a photo where the subject's eyes are partly shadowed and a face-restoration stage will happily reconstruct eyes in a shape the person never had.&lt;/p&gt;

&lt;p&gt;Two rules that keep it honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disable face restoration when identity matters.&lt;/strong&gt; Global restoration of damage, none of the face-specific stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process twice with different settings and compare.&lt;/strong&gt; If the two results differ in ways that change who the person is, the pipeline is generating rather than restoring.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A workflow that avoids the trap
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assess first.&lt;/strong&gt; Is the image small, blurry, damaged, or all three? Each needs a different operation, and doing them in the wrong order compounds errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denoise before sharpening.&lt;/strong&gt; Always on scans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upscale before generative restoration&lt;/strong&gt;, not after — a model working on more pixels has more constraints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an untouched copy.&lt;/strong&gt; The single most useful habit: the original is the only version that cannot be wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare at 100% and at the size you will actually use.&lt;/strong&gt; Most disappointments live between those two views.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;I build &lt;a href="https://aiphotoeditor.best/" rel="noopener noreferrer"&gt;aiphotoeditor.best&lt;/a&gt;. The local layer — brightness, contrast, saturation, warmth, sharpening, filters, crop, resize, format conversion — runs in the browser as plain pixel math and is unlimited and free, because none of it can invent anything. The generative stage (enhance, restore, background removal, cartoonize, headshots) is a separate, explicitly labelled operation with three free runs a day and credit packs after that.&lt;/p&gt;

&lt;p&gt;The split exists precisely because of the problem in this post: when the generative step is a separate button, you can tell which parts of the result are yours. When it is buried inside "enhance", you cannot.&lt;/p&gt;

&lt;p&gt;Honest limits: the sharpening is a standard unsharp mask, not a scene-aware algorithm; the upscaler is conservative by design and will not rescue a genuinely out-of-focus shot; and no tool can tell you which of the three operations your particular photo needed — that judgement is still yours.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>design</category>
    </item>
    <item>
      <title>Verifying a white background programmatically (and the two export mistakes it catches)</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:34:17 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/verifying-a-white-background-programmatically-and-the-two-export-mistakes-it-catches-3121</link>
      <guid>https://dev.to/tonytoolbox/verifying-a-white-background-programmatically-and-the-two-export-mistakes-it-catches-3121</guid>
      <description>&lt;p&gt;Marketplace image checks are usually automated, and "looks white" is not the same as "is 255,255,255". If you process product photos in a browser, this is a five-line check worth adding to your pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the naive check fails
&lt;/h2&gt;

&lt;p&gt;Monitors and image previews apply gamma and colour management, so two files that look identical on screen can differ by 4-6 levels per channel. A background at 250/251/249 passes your eyes and fails a strict white check.&lt;/p&gt;

&lt;p&gt;Compression makes it worse. JPEG at quality 85 puts ringing artifacts around high-contrast edges — precisely at the product boundary — so the pixels nearest the subject drift away from white even when the flat field is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check: sample the flat field, not one corner
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;whiteFieldReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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;bmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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;c&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;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&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;willReadFrequently&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;bmp&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&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;getImageData&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;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// sample a border band, skipping the product area&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;band&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;round&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.02&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;n&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;minC&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="nx"&gt;offWhite&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;alphaZero&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visit&lt;/span&gt; &lt;span class="o"&gt;=&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;y&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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&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="mi"&gt;3&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;a&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;alphaZero&lt;/span&gt;&lt;span class="o"&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="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// transparent, not white&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&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;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;data&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;data&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="mi"&gt;2&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;m&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;minC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&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;m&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;offWhite&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;n&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="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;y&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;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&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;x&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;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&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;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;visit&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;y&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;sampled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minChannel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;minC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;offWhitePct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;offWhite&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;alphaZero&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;Three numbers come out of it, and each maps to a different failure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minChannel === 255&lt;/code&gt;&lt;/strong&gt; → the field is a true white; safe for a strict check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;alphaZero &amp;gt; 0&lt;/code&gt;&lt;/strong&gt; → the file has transparency, not white. If the marketplace wants flattened white, you have the wrong export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;offWhitePct&lt;/code&gt; high but min near 255&lt;/strong&gt; → you have a few dirty pixels, usually from JPEG ringing at the product edge or a stray shadow. Re-export as PNG, or clean the matte.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two export mistakes this catches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Saving the cutout as JPEG.&lt;/strong&gt; No alpha channel survives, so transparent regions get flattened — usually to white, sometimes to black depending on the encoder. You then cannot tell a real white background from a flattened one without checking alpha, which is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Compositing onto near-white.&lt;/strong&gt; People composite onto &lt;code&gt;#fefefe&lt;/code&gt; or a "paper" swatch that came from a design system. It looks white and reads 254. If the check is a strict equality or a threshold above 254, that fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do in practice
&lt;/h2&gt;

&lt;p&gt;Export PNG with alpha for the working file, composite onto true 255 white only at the final step when the target wants a flattened file, then run the check above on the exported bytes — not on the canvas you composited on. The canvas is the thing you built; the file is the thing that gets uploaded.&lt;/p&gt;

&lt;p&gt;For batches, treat &lt;code&gt;minChannel&lt;/code&gt; as the pass/fail and &lt;code&gt;offWhitePct&lt;/code&gt; as a quality score: anything above roughly 1-2% off-white means you have edge contamination worth looking at before upload.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://aiphotoeditor.best/" rel="noopener noreferrer"&gt;aiphotoeditor.best&lt;/a&gt;, where the background removal outputs PNG with alpha preserved and the export step tells you which format you are getting before you spend a credit. Three free AI edits a day, no account; the local adjustments run in the browser and are unlimited.&lt;/p&gt;

&lt;p&gt;Honest limits: the matte is imperfect on fine hair against a similar-toned background, and this check tells you nothing about whether the marketplace accepts a soft shadow — that is a rules question that varies by category and platform.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
      <category>performance</category>
    </item>
    <item>
      <title>Local-first photo editing in the browser: what the pipeline split actually looks like</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Mon, 19 Oct 2026 19:08:19 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/local-first-photo-editing-in-the-browser-what-the-pipeline-split-actually-looks-like-kp</link>
      <guid>https://dev.to/tonytoolbox/local-first-photo-editing-in-the-browser-what-the-pipeline-split-actually-looks-like-kp</guid>
      <description>&lt;p&gt;If you build anything in the browser that touches user photos, you eventually run into the same wall: the cheap operations want to be local, and the expensive ones cannot be.&lt;/p&gt;

&lt;p&gt;Here is how I ended up splitting the pipeline, and the constraints that forced each decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost asymmetry nobody mentions in the docs
&lt;/h2&gt;

&lt;p&gt;A brightness slider is a loop over pixels. A crop is a coordinate transform. A JPEG re-encode is a codec call. None of these need a server, and if you put them behind one you have added latency, bandwidth and a privacy problem to something that costs you nothing to run locally.&lt;/p&gt;

&lt;p&gt;A diffusion step is different. It needs a GPU, it costs real money per call, and there is no version of it that runs acceptably in a browser tab today. That is not a temporary limitation of my code — it is the state of the hardware.&lt;/p&gt;

&lt;p&gt;So the pipeline naturally splits: everything deterministic runs in the client, everything generative runs on a server, and the boundary between them is the honest place to draw your pricing model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the local layer actually costs
&lt;/h2&gt;

&lt;p&gt;Canvas-based operations are fast until they are not. The three things that bit me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;getImageData&lt;/code&gt; is a synchronous trap.&lt;/strong&gt; Every pixel-level pass that reads back from the canvas stalls the main thread while the GPU drains. On a 4000×3000 photo that is a visible freeze. The fix is to do the pixel work once per adjustment change, not once per frame, and to debounce slider input — otherwise you are running the full loop on every mousemove event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Re-encoding is the real cost, not the filter.&lt;/strong&gt; Applying a filter is milliseconds; encoding the result to JPEG at quality 0.9 to show a preview is where the time goes. Previewing at display resolution and only re-encoding at full resolution on export makes the interaction feel instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Memory is worse than you think.&lt;/strong&gt; An RGBA buffer for a 12-megapixel photo is 48 MB. Keep a source copy, a working copy and an undo stack and you are at a few hundred megabytes on a phone. Mobile Safari will kill the tab without ceremony. Downscaling the working copy to the viewport size and keeping the original as a compressed blob is the only thing that survived real testing.&lt;/p&gt;

&lt;p&gt;None of that is exotic, but it means "local-first" is an architectural commitment rather than a checkbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network panel is the honest test
&lt;/h2&gt;

&lt;p&gt;The nice side effect of this split is that it is verifiable from outside. Open DevTools, switch to the Network tab, and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apply a tone adjustment. Requests appearing there means the claim is false.&lt;/li&gt;
&lt;li&gt;Use the generative feature. Requests are expected — that part really does cost money per call.&lt;/li&gt;
&lt;li&gt;Export. If the only difference between the free and paid path is this step, you have found the business gate, which is a separate thing from a technical limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A tool that passes the first check and is open about the second is telling you the truth about its own cost structure. That is a better signal than any badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy argument is a side effect
&lt;/h2&gt;

&lt;p&gt;I did not start with a privacy pitch. But once the local layer exists, the useful consequence follows: photos that never leave the device are photos people are willing to use. Scans of documents, pictures of kids, product shots under NDA — the category that gets excluded from every cloud editor by default becomes available.&lt;/p&gt;

&lt;p&gt;That is a real product difference, and it comes from the architecture, not from a policy page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the split breaks
&lt;/h2&gt;

&lt;p&gt;Two honest limits, because anyone claiming otherwise is selling something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebAssembly models are not a shortcut.&lt;/strong&gt; You can run a small model in the browser, and people do. But it is slow enough to be unusable for anything interactive on a laptop, and it drains the battery doing it. For the operations people actually want (restoration, background removal, upscaling, style generation) the server is the only option that produces good output in reasonable time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boundary moves.&lt;/strong&gt; Every year a model that needed a GPU gets smaller. The split I have today will be wrong in a couple of years, and rebuilding around a new boundary is cheaper than pretending the old one is permanent.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://aiphotoeditor.best/" rel="noopener noreferrer"&gt;aiphotoeditor.best&lt;/a&gt; along these lines: the local layer (tone, filters, crop, resize, format conversion, compression) is free and unlimited because it costs nothing to run; the AI layer is capped at three free edits a day and then one-time credit packs, because each call costs money. If you want to check the split for yourself, the network panel test above works on it too.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>privacy</category>
    </item>
    <item>
      <title>What it costs to run an AI baby-photo generator: ~4¢ an image, and an honest promise</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:00:10 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/what-it-costs-to-run-an-ai-baby-photo-generator-4c-an-image-and-an-honest-promise-54oe</link>
      <guid>https://dev.to/tonytoolbox/what-it-costs-to-run-an-ai-baby-photo-generator-4c-an-image-and-an-honest-promise-54oe</guid>
      <description>&lt;p&gt;I build small browser-first tools. One of them, &lt;a href="https://ourbaby.shop/" rel="noopener noreferrer"&gt;OurBaby&lt;/a&gt;, answers the oldest question couples ask — &lt;em&gt;what will our baby look like?&lt;/em&gt; — by blending one photo of each parent into an imagined baby portrait.&lt;/p&gt;

&lt;p&gt;Two questions come up from other developers every time I describe it, so here are both answers with real numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit economics (real numbers, October 2026)
&lt;/h2&gt;

&lt;p&gt;Generation runs on a multi-image model of the "nano-banana" class via fal.ai. The actual billed cost per image, from my fal usage export:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$0.0398 per generated image&lt;/strong&gt; (identity-blending edit, two reference photos)&lt;/li&gt;
&lt;li&gt;Same engine for every scene — studio, Christmas, beach, family-of-three&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the math is simple: a $2.99 pack of 5 photos is ~80%+ gross margin, and the free tier is a bounded acquisition cost rather than a leak. The free tier is deliberately small and capped three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;1 free preview per visitor IP per day&lt;/li&gt;
&lt;li&gt;a per-site daily spend circuit breaker (cost-estimate accumulation in KV)&lt;/li&gt;
&lt;li&gt;no API key in the deployment → the endpoint returns a waitlist 503 instead of ever going bare&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one matters. An earlier project of mine shipped an AI endpoint without a daily cap and someone found it within hours — the cap is the difference between a marketing cost and an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Photos never touch my servers as files: the browser downscales each to a 768px JPEG, sends them as data URIs, and a Cloudflare Pages Function makes a single queued call to the model with both references. The interesting part is not the plumbing, it's the prompt: the model is told to &lt;em&gt;inherit&lt;/em&gt; specific features (eye color and shape, nose, smile, hair color and texture, skin tone) from both references, rather than averaging two faces — averaging is why first-generation "baby generators" produced children that looked like nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part every "baby predictor" gets wrong
&lt;/h2&gt;

&lt;p&gt;Here's where I have to argue against my own product category. A baby's face &lt;strong&gt;cannot be predicted&lt;/strong&gt; — by my tool or any other. Appearance is polygenic: eye color alone involves several genes (the OCA2/HERC2 region does most of the work, not all of it), and each parent passes on one of two copies of every gene. The recombination is random. Even two full genomes don't compute a face.&lt;/p&gt;

&lt;p&gt;What a good generator can honestly claim is &lt;em&gt;fidelity to the parents&lt;/em&gt; — the imagined child should visibly inherit from both of you — not prediction of the child. So the copy on the site says exactly that: the result is a fictional, imagined person, and the page even explains &lt;a href="https://ourbaby.shop/what-will-my-baby-look-like/" rel="noopener noreferrer"&gt;why prediction is impossible&lt;/a&gt;. It turns out that honesty is also good SEO: it's the question people actually type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy structure, since your users' faces are your users
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Photos go encrypted to the model provider &lt;strong&gt;only&lt;/strong&gt; to generate the image&lt;/li&gt;
&lt;li&gt;No gallery, no public results wall — if you show other people's uploads, assume yours joins them&lt;/li&gt;
&lt;li&gt;The generated images live on the provider's temporary storage, not in my database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything that eats faces (headshots, restorations, family photos), I'd treat this as the baseline, not a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you before you build in this space
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Bound your free tier with per-IP &lt;strong&gt;and&lt;/strong&gt; global daily caps on day one&lt;/li&gt;
&lt;li&gt;Label outputs as fictional — it's the difference between a keepsake and a scam&lt;/li&gt;
&lt;li&gt;Put the honest limit in the title of your main content page; being straight about what AI can't do outperforms promising predictions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the result, the generator is at &lt;a href="https://ourbaby.shop/" rel="noopener noreferrer"&gt;ourbaby.shop&lt;/a&gt; — first preview free, no signup, and the &lt;a href="https://ourbaby.shop/ai-baby-generator-free/" rel="noopener noreferrer"&gt;free tier is documented in plain numbers&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aiwebdevsideprojectshowdev</category>
    </item>
    <item>
      <title>AI Photo Restoration Keeps Changing Faces — Here's the Pipeline That Stops It</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:37:54 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/ai-photo-restoration-keeps-changing-faces-heres-the-pipeline-that-stops-it-400n</link>
      <guid>https://dev.to/tonytoolbox/ai-photo-restoration-keeps-changing-faces-heres-the-pipeline-that-stops-it-400n</guid>
      <description>&lt;p&gt;If you've run old family photos through an AI restorer, you've probably hit the failure mode nobody advertises: it changes faces. Heavy restoration on a low-resolution scan doesn't recover a face — it re-draws a plausible one. You end up with a stranger wearing your grandmother's expression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why faces drift
&lt;/h2&gt;

&lt;p&gt;Face restoration models are trained to produce &lt;em&gt;plausible&lt;/em&gt; faces. With a high-signal source, "plausible" and "correct" converge. Below a certain signal level — small scan, compression, damage sitting on the features — the model stops restoring and starts re-drawing. It's a fidelity-versus-strength trade-off baked into every tool of this class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scan at 600 dpi, in color&lt;/strong&gt; — color even for black-and-white originals, because the stain and tone information helps separate damage from image. A flat daylight photo of the print beats nothing, but a real scan beats everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repair damage first, enhance faces second, upscale third&lt;/strong&gt; — separate passes, not one mega-pass. Scratches and fading act like added noise, and face restoration running on top of damage is where the drift starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bias toward fidelity, not strength&lt;/strong&gt; — the "beautiful" results that swap identity are usually high strength on low-res input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify at 100% zoom on eyes, smile, hairline&lt;/strong&gt; — if any of the three changed, reject the pass and re-run with less strength. That's the identity check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colorize last&lt;/strong&gt; — colorization is a guess about the past, not a record of it. Keep the black-and-white original as the archival master.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The hard ceiling
&lt;/h2&gt;

&lt;p&gt;Past roughly half of the facial detail missing, no model can recover identity — anything it produces is invention. That's manual-restoration territory. Below that threshold, the order of operations above is the difference between a sharper version of the same person and a stranger.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built with this
&lt;/h2&gt;

&lt;p&gt;I packaged this workflow into &lt;a href="https://aiphotoeditor.best/" rel="noopener noreferrer"&gt;AI Photo Editor&lt;/a&gt;: free local adjustments (crop, tone, contrast — nothing uploaded) plus a few free AI edits a day for enhance / restore / background removal. The local layer handles the prep steps without spending an AI credit, and the AI pass only ever sees the cleaned scan.&lt;/p&gt;

&lt;p&gt;Honest limits: it won't fix a photo beyond the reconstruction threshold above, and keeping the period look is the goal by design — if you want a "modernized" face, this is the wrong tool.&lt;/p&gt;

</description>
      <category>aiphotographywebdevtools</category>
    </item>
    <item>
      <title>Building browser-only tools that never upload your files (the local-first web stack)</title>
      <dc:creator>tony huang</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:09:06 +0000</pubDate>
      <link>https://dev.to/tonytoolbox/building-browser-only-tools-that-never-upload-your-files-the-local-first-web-stack-4hc8</link>
      <guid>https://dev.to/tonytoolbox/building-browser-only-tools-that-never-upload-your-files-the-local-first-web-stack-4hc8</guid>
      <description>&lt;p&gt;Most online tools quietly ship your files to a server: your photos, your invoices, your subtitle files, your client documents. There is a different way — build the whole tool client-side, so nothing ever leaves the device. I've been shipping a family of free tools built on this principle, and this post is the practical stack behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core pattern
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static site export.&lt;/strong&gt; The app is a Next.js static export on Cloudflare Pages — there is no backend to receive your files in the first place. Privacy is structural, not a policy promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas API&lt;/strong&gt; for anything pixel-shaped: downscaling, palette quantisation, crops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebAssembly&lt;/strong&gt; for codecs that never shipped in browsers: HEIC decoding, WebP/AVIF encoding, GIF optimisation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebCodecs + seeking&lt;/strong&gt; for video work without ffmpeg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain text processing&lt;/strong&gt; where "format" just means text with rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers&lt;/strong&gt; so the main thread keeps breathing during heavy transforms.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image format conversion&lt;/strong&gt; — wasm builds of libheif/libwebp mean you can convert HEIC or WebP entirely locally, with batch support and no file-size ceiling. Live example: &lt;a href="https://imageconverter.site/" rel="noopener noreferrer"&gt;ImageConverter&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIF compression&lt;/strong&gt; — scale, colour-reduce and drop frames in a worker. Live example: &lt;a href="https://gifcompressor.site/" rel="noopener noreferrer"&gt;GifCompressor&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video frames&lt;/strong&gt; — the browser can decode and seek video natively now: pull evenly spaced frames with timestamps, grab one exact frame, or scan a long clip for motion. No ffmpeg install, nothing uploaded. Live example: &lt;a href="https://videoframeextractor.site/" rel="noopener noreferrer"&gt;FrameExtractor&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixel art conversion&lt;/strong&gt; — canvas downscale + palette quantisation turns photos into game-ready sprites. Live example: &lt;a href="https://pixelartconverter.site/" rel="noopener noreferrer"&gt;PixelArt&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subtitle repair&lt;/strong&gt; — SRT/VTT/ASS are just text with rules, so parsing, resyncing and merging run instantly client-side. Live example: &lt;a href="https://subtoolbox.best/" rel="noopener noreferrer"&gt;SubToolbox&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculators&lt;/strong&gt; — pure JavaScript, zero I/O: aspect ratios (&lt;a href="https://aspectratio.site" rel="noopener noreferrer"&gt;https://aspectratio.site&lt;/a&gt;), marketplace seller fees (&lt;a href="https://sellerfees.org" rel="noopener noreferrer"&gt;https://sellerfees.org&lt;/a&gt;), construction materials (&lt;a href="https://buildcalcs.best" rel="noopener noreferrer"&gt;https://buildcalcs.best&lt;/a&gt;), electrical/HVAC tables (&lt;a href="https://tradecalcs.best" rel="noopener noreferrer"&gt;https://tradecalcs.best&lt;/a&gt;), even baby wake windows (&lt;a href="https://wakewindows.best" rel="noopener noreferrer"&gt;https://wakewindows.best&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest trade-offs
&lt;/h2&gt;

&lt;p&gt;Client-side is not free of constraints. Large files on low-RAM phones will hurt; you're bounded by the device instead of a server farm. Heavy AI inference is still mostly a server story (though small models in WASM are getting real). And some features — accounts, sync, sharing — reintroduce a backend by definition.&lt;/p&gt;

&lt;p&gt;But for a huge category of "give me a file, give me a different file back" problems, the browser is now enough. The user gets instant results, offline capability, and privacy that isn't just a checkbox. If you're building tools in 2026, it's worth asking first: &lt;em&gt;could this run entirely on the client?&lt;/em&gt; For us, the answer is usually yes — and it makes the product better, not just cheaper.&lt;/p&gt;




&lt;p&gt;All the tools linked above are free, require no sign-up, and run 100% in your browser. Feedback welcome in the comments.&lt;/p&gt;

</description>
      <category>productivity</category>
    </item>
  </channel>
</rss>
