<?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[Ashish Sikka]]></title><description><![CDATA[Ashish Sikka]]></description><link>https://ashishsikka.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 13 Nov 2026 21:50:30 GMT</lastBuildDate><atom:link href="https://ashishsikka.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your AI agent can't submit to a directory it knows nothing about]]></title><description><![CDATA[Five things to write down before an AI agent can submit your product to launch directories, with real numbers from 338 sites.]]></description><link>https://ashishsikka.hashnode.dev/your-ai-agent-can-t-submit-to-a-directory-it-knows-nothing-about</link><guid isPermaLink="true">https://ashishsikka.hashnode.dev/your-ai-agent-can-t-submit-to-a-directory-it-knows-nothing-about</guid><category><![CDATA[automation]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Startups]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[Ashish sikka]]></dc:creator><pubDate>Wed, 11 Nov 2026 12:41:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeff8153a42503095e3acac/918f8bfd-d068-4985-a758-33edb78df9b5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>"Submit my product to launch directories" sounds like a perfect job for a coding agent. It's repetitive, it's forms, and nobody wants to do it.</p>
<p>I tried it for my own products in October. The first attempts were bad. The later ones submitted 285 of 338 directories for one product and 690 across five campaigns. The model was the same both times. The difference was what I had written down for it.</p>
<p>Here are the five things.</p>
<h2>1. What the directory is, before the page loads</h2>
<p>An agent that has to discover each site from scratch wastes most of its run. So every directory gets a record. A trimmed one:</p>
<pre><code class="language-json">{
  "domain": "getmagictools.com",
  "submit_url": "https://getmagictools.com/submit",
  "login": "none for the free listing",
  "badge_required": false,
  "dofollow": "yes",
  "time_to_live": "reviewed within 30 days",
  "paid_tiers": "Pro, $9.90 once (skip)",
  "steps": ["Search the site for the product first...", "Fill in Tool Name and Tool URL", "Click Submit Tool"],
  "gotchas": ["No description field: the site writes the listing from your homepage."]
}
</code></pre>
<p>The fields that earn their place:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Why the agent needs it</th>
</tr>
</thead>
<tbody><tr>
<td><code>submit_url</code></td>
<td>Finding the submit page is the slowest step on most sites.</td>
</tr>
<tr>
<td><code>login</code></td>
<td>Google, email code, magic link or none. It decides the whole flow.</td>
</tr>
<tr>
<td><code>badge_required</code></td>
<td>160 of the 318 free directories want a badge on your site.</td>
</tr>
<tr>
<td><code>dofollow</code></td>
<td>188 of the 318 give a followed link, 106 don't, 24 I couldn't confirm.</td>
</tr>
<tr>
<td><code>time_to_live</code></td>
<td>From 24 hours to two months. Otherwise "pending" looks like failure.</td>
</tr>
<tr>
<td><code>steps</code>, <code>gotchas</code></td>
<td>What tripped the last agent that tried.</td>
</tr>
</tbody></table>
<p>I collected 471 directories and checked each one by hand. 318 have a free listing. 36 are paid only, 16 were dead, 83 are flagged to skip.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/m304vmailm3o10dzu1at.png" alt="471 directories checked, 318 with a free listing open" /></p>
<h2>2. Who it is when a site asks</h2>
<p>Sign-up is the real obstacle, not the form. Across 1,304 recorded results, 848 went through Sign in with Google. Most of the rest wanted an email code.</p>
<p>So the agent works in a Chrome profile that's signed in to one Google account used only for launches. I sign in myself. From there it handles sign-ups, logins and one-time codes alone, and it never sees or types a password. It reads only the mail from the site it's working on.</p>
<h2>3. One source of truth for the listing</h2>
<p>Forms disagree about everything. One wants a one-line tagline, the next a full description. One wants two categories and five tags. One wants a 16:9 screenshot, another a 1400x950 preview.</p>
<p>The agent gets a single listing file with every length of every text, and a folder of images rendered up front in each size. It trims; it doesn't write new claims per site.</p>
<h2>4. Permission to stop</h2>
<p>This was the biggest fix. Without it, an agent loops on a form it can't finish or, worse, invents an answer.</p>
<p>"Needs a human" is a first-class result with a reason. In one 31-minute session on November 7, the agent worked 23 directories: 14 submitted, 1 already listed, 8 handed back.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/tran47os85px6d9eretq.png" alt="14 submitted, 8 handed back, 1 already listed in 31 minutes" /></p>
<p>The eight: two bot checks, two sign-ins where the open Gmail tab was the wrong account (my mistake), one listing that had become paid, one cookie banner it wouldn't accept on my behalf, and two forms asking for facts it didn't have, like the product's tech stack.</p>
<p>It also has hard limits. It never pays. It never posts votes, comments or reviews. If a site asks it to stop, it stops.</p>
<h2>5. Memory outside the chat</h2>
<p>Chat history is the wrong place to keep the state of 300 submissions. Each site gets a step log and a result file on disk. When an agent crashes or hits a quota, the next one reads the log and carries on, so nothing is submitted twice. At the end there's one report: every listing, its URL, its status, its follow-up date and any badge code.</p>
<p>You'll want that file. Directories write to you weeks later.</p>
<h2>What it added up to</h2>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/i8hvpjmko42c1vsu8zuy.png" alt="822 directories attempted, 690 submitted across five campaigns" /></p>
<p>Three of the products were on new domains. Their Ahrefs Domain Rating went from 3 to 48, 2 to 36 and 0 to 33 between mid October and November 7. That's three products of my own, so it's an example, not a promise. I measured DR and nothing else.</p>
<h2>The packaged version</h2>
<p>I turned those five things into a product, the Launch Agent Kit: the database, the per-site steps, the listing template, the image pipeline and the tracker, as a folder your agent reads. It's mine, and it runs with Claude Code, Codex, Cursor, Antigravity, OpenCode, Copilot or Devin.</p>
<p>$29 once until December 1, then $99, with a DR 30 money-back guarantee for sites starting at DR 0 to 10.</p>
<p><a href="https://swipetolaunch.com/kit?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=kit-articles">swipetolaunch.com/kit</a></p>
<p>Or build your own from the list above. The order I'd do it in is 1, 4, 2, 5, 3.</p>
]]></content:encoded></item><item><title><![CDATA[Watermarking 200 photos in the browser, with nothing uploaded]]></title><description><![CDATA[A free batch watermark tool that runs entirely in the browser: a shared SVG renderer, embedded fonts, canvas export and a tiny zip writer.]]></description><link>https://ashishsikka.hashnode.dev/watermarking-200-photos-in-the-browser-with-nothing-uploaded</link><guid isPermaLink="true">https://ashishsikka.hashnode.dev/watermarking-200-photos-in-the-browser-with-nothing-uploaded</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[canvas]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Ashish sikka]]></dc:creator><pubDate>Mon, 09 Nov 2026 04:24:34 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeff8153a42503095e3acac/32ff3999-7089-4ff7-83c4-c6583fd5bdbc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>I work on <a href="https://sellphotos.online/blog/browser-batch-watermark?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=browser-batch-watermark">sellphotos.online</a>, where this was originally published. Written with AI assistance.</em></p>
<p>A watermark tool that uploads your photos to a server asks photographers to hand over the very thing they're trying to protect. So we built <a href="https://sellphotos.online/tools/photo-watermark?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=browser-batch-watermark">ours</a> to run entirely in the tab. You drop in up to 200 photos, set a mark, and get back one file or a zip. Nothing is sent anywhere.</p>
<p>The interesting part is that it isn't a separate project. The tool draws its watermarks with the same code our server uses for photos that are actually for sale.</p>
<h2>One renderer, two rasterisers</h2>
<p>sellphotos.online is a store for event photographers. When a photographer uploads a shoot, a processing container (Node 22 with sharp and libvips) draws a watermark over each preview that buyers browse. Those designs live in a folder of plain TypeScript, with no enums and imports written with <code>.ts</code> extensions. Node 22 strips the types at load time in the container, and Vite bundles the same files into the web app.</p>
<p>The core function returns an SVG string sized to one photo's frame:</p>
<pre><code class="language-ts">renderWatermark(frame, designId, settings, logo) // → { svg, usedLogo }
</code></pre>
<p>On the server, librsvg rasterises that SVG and sharp composites it. In the browser, the SVG is loaded into an <code>&lt;img&gt;</code> and drawn onto a canvas over the photo. Same SVG, two rasterisers, so the SVG may only use features both draw the same way. We learned the rules by measuring:</p>
<ul>
<li><strong>No <code>textPath</code>, <code>textLength</code> or <code>text-transform</code>.</strong> Letter-spaced text is anchored at its measured start, because browsers count trailing letter-spacing when centring and librsvg doesn't.</li>
<li><strong>Repeated text is <code>&lt;use&gt;</code> copies inside a rotated group,</strong> not a rotated <code>&lt;pattern&gt;</code>. At 2048 px the pattern took 1.8 s in the container and the group 0.3 s.</li>
<li><strong>No blur, shadow or morphology filters.</strong> They cost 6–10 s per photo on the server.</li>
<li><strong>Marks without a plate get a thin contrasting outline,</strong> so a white mark stays visible on a white sky.</li>
</ul>
<h2>Layout from the short edge</h2>
<p>A batch mixes portraits, landscapes and squares, so a mark sized in pixels would look tiny on one photo and huge on the next. Every design sizes from the frame's short edge instead:</p>
<pre><code class="language-ts">// workers/processor/container/wm/render.ts (simplified)
const base = Math.min(w, h);
export const margin = (c: Ctx, frac = 0.035) =&gt; Math.max(8, Math.round(c.base * frac));
export const sz = (c: Ctx, frac: number, min = 8) =&gt; Math.max(min, c.base * frac * c.k); // k = the user's size setting
</code></pre>
<p>Each photo in the batch gets its own SVG, rendered for its own width and height. A name in the corner sits the same distance in from the edge on a phone portrait as on a 3:2 landscape.</p>
<h2>Branding marks, kept away from the store</h2>
<p>The free tool has something the paid product deliberately doesn't: corner and edge marks. You pick a spot on a 3×3 grid (or every corner), a style (plain, badge, strip or a script signature), and whether a logo sits beside or above your text.</p>
<p>Those marks never reach a seller's previews. A corner mark is cropped off in seconds, so every design a seller can choose either crosses the middle of the photo or tiles the whole frame. The branding marks live in their own module, and the processor never sees them.</p>
<p>To keep the tool's preview and export code identical to the seller designs, a branding mark still travels as one number:</p>
<pre><code class="language-ts">// workers/processor/container/wm/branding.ts (simplified)
// id = 1000 + style × 100 + layout × 10 + position
export function renderMark(frame, designId, settings, logo, opts = {}) {
  return isBrandingId(designId)
    ? renderBranding(frame, designId, settings, logo, opts)
    : renderWatermark(frame, designId, settings, logo, opts);
}
</code></pre>
<h2>The font problem</h2>
<p>An SVG loaded as an image is sandboxed. It can't fetch anything, including web fonts, so text silently falls back to a system font. The fix is to read which faces the SVG actually uses and inline those font files as <code>data:</code> URIs:</p>
<pre><code class="language-ts">// apps/web/app/lib/tools/watermark-export.ts (simplified)
export function fontFacesIn(svg: string): FontFace[] {
  const seen = new Map&lt;string, FontFace&gt;();
  for (const m of svg.matchAll(/font-family="([^",]+)[^"]*" font-weight="(\d+)"( font-style="italic")?/g)) {
    const key = `${m[1]}/${m[2]}${m[3] ? "/italic" : ""}`;
    const file = FONT_FILES[key];
    if (file &amp;&amp; !seen.has(key)) seen.set(key, { family: m[1], weight: Number(m[2]), style: m[3] ? "italic" : "normal", file });
  }
  return [...seen.values()];
}
</code></pre>
<p>Each woff2 is fetched once per page, base64-encoded, and cached in a <code>Map</code> of promises. A failed fetch is evicted so the next export retries it. The <code>@font-face</code> rules go into a <code>&lt;style&gt;</code> block inside the SVG, right after the opening tag. They're Latin subsets of the same font files the container has installed, so a mark looks the same in the tool as on a store's previews.</p>
<h2>Export: one canvas at a time</h2>
<pre><code class="language-ts">// simplified
const source = await loadImage(photoObjectUrl);
const frame = outputFrame(source.naturalWidth, source.naturalHeight); // long edge ≤ 4,096 px
const { svg } = renderMark(frame, designId, settings, logo);
const overlay = await loadImage(URL.createObjectURL(new Blob([withStyle(svg, await embeddedFontCss(svg))], { type: "image/svg+xml" })));

const canvas = document.createElement("canvas");
canvas.width = frame.w;
canvas.height = frame.h;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(source, 0, 0, frame.w, frame.h);
ctx.drawImage(overlay, 0, 0, frame.w, frame.h);
const blob = await toBlob(canvas, type, type === "image/jpeg" ? 0.92 : undefined);
</code></pre>
<p>The tool accepts JPEG, PNG and WebP up to 25 MB each. PNG stays PNG, because it may carry transparency, and everything else comes back as JPEG at quality 0.92. The long edge is capped at 4,096 px, plenty for the web and social media, and the original file on disk is never touched.</p>
<p>Photos are processed one after another, each on its own full-size canvas. So the 200-photo limit is about patience and zip size rather than memory. A cancel flag is checked between photos, and a cancelled batch saves nothing. When a photo is added, one object URL is created and reused by both the preview and the export, and a 320 px JPEG thumbnail is made for the list.</p>
<h2>A zip writer in a few dozen lines</h2>
<p>Browsers have no built-in zip writer, and the server's streaming one relies on <code>node:zlib</code>. JPEG and PNG files are already compressed, though, so a zip that only <em>stores</em> files loses almost nothing. That format is simple enough to write by hand: a CRC-32 per file, a local header before each file's bytes, a central directory and an end record.</p>
<pre><code class="language-ts">// apps/web/app/lib/tools/client-zip.ts
export function crc32(data: Uint8Array, crc = 0): number {
  const t = (table ??= (() =&gt; {
    const t = new Uint32Array(256);
    for (let i = 0; i &lt; 256; i++) {
      let c = i;
      for (let k = 0; k &lt; 8; k++) c = c &amp; 1 ? 0xedb88320 ^ (c &gt;&gt;&gt; 1) : c &gt;&gt;&gt; 1;
      t[i] = c &gt;&gt;&gt; 0;
    }
    return t;
  })());
  let c = ~crc;
  for (let i = 0; i &lt; data.length; i++) c = t[(c ^ data[i]!) &amp; 0xff]! ^ (c &gt;&gt;&gt; 8);
  return ~c &gt;&gt;&gt; 0;
}
</code></pre>
<p>Names are written as UTF-8 (general-purpose flag <code>0x0800</code>), and times as MS-DOS date/time. Files with the same name become <code>a.jpg</code> and <code>a (2).jpg</code>, compared case-insensitively. The detail that matters most for big batches is in <code>zipBlobs</code>. It reads each <code>Blob</code> once to compute its CRC, then puts the Blob itself into the output instead of a copy of its bytes. The whole batch stays in the browser's blob store, not in the page's JavaScript heap.</p>
<h2>What "nothing uploads" buys you</h2>
<ul>
<li><strong>Trust you can check.</strong> Open the network tab while you export: there are no requests carrying your photos.</li>
<li><strong>No server cost per photo,</strong> so the tool can be free with no limits or sign-up.</li>
<li><strong>The trade-offs are honest ones.</strong> Output is capped at 4,096 px, and a big batch on an old phone takes a while. For photos you're about to post or send to a client, that's usually fine.</li>
</ul>
<p>If you watermark photos you mean to sell, a corner mark won't stop anyone. That's why the paid side of sellphotos.online uses full-frame designs and only releases the clean original after the buyer has paid you. The <a href="https://sellphotos.online/tools/photo-watermark?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=browser-batch-watermark">watermark tool</a> is free either way, with no account needed.</p>
]]></content:encoded></item><item><title><![CDATA[Notes on generating QR codes for print: error correction, logo holes and quiet zones]]></title><description><![CDATA[Notes on QR codes that get printed: error correction per medium, logo hole sizing, quiet zones, and one path string for SVG, PNG and canvas.]]></description><link>https://ashishsikka.hashnode.dev/notes-on-generating-qr-codes-for-print-error-correction-logo-holes-and-quiet-zones</link><guid isPermaLink="true">https://ashishsikka.hashnode.dev/notes-on-generating-qr-codes-for-print-error-correction-logo-holes-and-quiet-zones</guid><category><![CDATA[qr code]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[frontend]]></category><dc:creator><![CDATA[Ashish sikka]]></dc:creator><pubDate>Mon, 09 Nov 2026 04:23:40 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeff8153a42503095e3acac/182a067d-5cfc-47c5-8fcd-ff0c85eed5ec.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>I work on <a href="https://sellphotos.online/blog/print-ready-qr-codes-logo?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=print-ready-qr-codes-logo">sellphotos.online</a>, where this was originally published. Written with AI assistance.</em></p>
<p>Every event on sellphotos.online gets a QR code. Photographers print it on posters, notice boards and table cards, or put it on the last slide at a prize-giving. Guests scan it in poor light, at an angle, through glare on a laminated sheet. We generate the codes ourselves with the small <code>uqr</code> encoder, then draw them with our own code. These are the decisions that came out of that.</p>
<h2>Pick the error correction level per medium</h2>
<p>QR codes carry Reed-Solomon error correction at four levels. Roughly, these are the share of codewords that can be recovered:</p>
<table>
<thead>
<tr>
<th>level</th>
<th>recoverable</th>
</tr>
</thead>
<tbody><tr>
<td>L</td>
<td>~7%</td>
</tr>
<tr>
<td>M</td>
<td>~15%</td>
</tr>
<tr>
<td>Q</td>
<td>~25%</td>
</tr>
<tr>
<td>H</td>
<td>~30%</td>
</tr>
</tbody></table>
<p>Higher levels need more modules for the same data, so at a fixed print size each module gets smaller. We use three levels, one for each place a code ends up:</p>
<ul>
<li><strong>M</strong> for codes read off a screen. These are the UPI and Pix payment codes on a buyer's order page. A screen is flat, lit and clean.</li>
<li><strong>Q</strong> for printed event codes. Paper gets scuffed, laminate catches glare, and cheap printers blur edges.</li>
<li><strong>H</strong> when a logo sits in the middle, because the logo hides modules on purpose.</li>
</ul>
<p>For scale: a 35-character event link such as <code>https://sellphotos.online/e/AbC1234</code> comes out as version 3 (29×29 modules) at M, version 4 (33×33) at Q and version 5 (37×37) at H.</p>
<h2>The logo hole</h2>
<p>Plenty of generators paste a logo over the finished code and count on error correction to cope. We clear a square of modules first and draw the logo inside it, with a little padding, so no dark modules peek out around a transparent logo:</p>
<pre><code class="language-ts">// apps/web/app/lib/qr.ts
export const LOGO_RATIO = 0.24;

export function logoHole(matrix: QrMatrix, ratio = LOGO_RATIO): QrHole {
  let size = Math.max(3, Math.round(matrix.size * ratio));
  if (size % 2 !== matrix.size % 2) size += 1; // same parity: exactly centred
  const x = (matrix.size - size) / 2;
  return { x, y: x, size };
}
</code></pre>
<p>The parity line is easy to miss. A QR code's side is always odd (21 + 4 × (version − 1)), so an even-sized hole can't sit exactly in the middle. <code>(size − hole) / 2</code> would land on a half module. Bumping the hole to the same parity keeps it centred on whole modules.</p>
<p>The hole is 24% of the side. At version 5 with H, that's a 9×9 hole in a 37×37 code: 81 of 1,369 modules, about 6%. H can recover about 30%, so most of that margin is left for scuffs, glare and print blur. That's an approximation, since modules map onto codewords unevenly and codewords are interleaved across blocks. Even so, a hole of a few percent behind a ~30% budget leaves real slack.</p>
<p>Also check where the function patterns are. At version 5, the only alignment pattern sits near the bottom-right corner, well clear of a hole spanning modules 14 to 22. From version 7 up, alignment patterns appear in the centre as well, so a long URL with a logo would put one under the logo. Short links matter here.</p>
<h2>Quiet zone, and a caption that stays out of it</h2>
<p>The standard (ISO/IEC 18004) asks for four light modules on every side, and we keep all four. Photographers can add the event title as a caption. It goes in its own band below the quiet zone, so the zone stays clear. Long titles are cut at 40 characters, and the font size comes from a rough width estimate (a bold sans is about 0.58 em per glyph), so we never measure text.</p>
<h2>One path string, three outputs</h2>
<p>The Share dialog shows a live preview and offers an SVG download, PNGs and a printable table card. They all come from one function that turns the matrix into SVG path data, in module units:</p>
<pre><code class="language-ts">// square style (simplified): each row's dark runs become one rectangle
for (let y = 0; y &lt; size; y++) {
  let x = 0;
  while (x &lt; size) {
    if (!data[y][x]) { x++; continue; }
    let end = x;
    while (end &lt; size &amp;&amp; data[y][end]) end++;
    parts.push(`M${x + margin} ${y + margin}h${end - x}v1h-${end - x}z`);
    x = end;
  }
}
</code></pre>
<p>Merging runs keeps the path short. The rounded style draws data modules as dots of radius 0.48, slightly under half a module so they stay distinct. It draws the finder patterns as nested rounded squares and fills them with <code>evenodd</code>, which punches out their holes.</p>
<p>That one string feeds three renderers:</p>
<ul>
<li>the React preview, as <code>&lt;path d={…}&gt;</code></li>
<li>the SVG download, where the path goes into a standalone document on a white background</li>
<li>the PNG, drawn on a canvas with <code>ctx.fill(new Path2D(d), "evenodd")</code></li>
</ul>
<p>We never draw an SVG onto the canvas through an <code>&lt;img&gt;</code>. That keeps us clear of canvas tainting and of <code>img-src</code> rules for <code>data:</code> and <code>blob:</code> URLs, and because every output comes from the same geometry, they always match.</p>
<p>A few details on the PNG side:</p>
<ul>
<li><strong>Whole pixels per module.</strong> <code>modulePx = Math.floor(width / side)</code>, and the code is centred in what's left over. A fractional module size blurs the edges between modules.</li>
<li><strong>A size cap.</strong> The canvas stops at 4096 × 4096 pixels. The printable table card is A6 portrait at about 300 dpi (1240 × 1748 px).</li>
<li><strong>Fonts first.</strong> A canvas doesn't wait for web fonts, so we <code>await document.fonts.load(...)</code> before drawing the caption. Otherwise the first download comes out in a fallback font.</li>
<li><strong>The logo inside the SVG</strong> is embedded as a PNG <code>data:</code> URL, so the file stands alone when it goes to a print shop. It's re-encoded to PNG because design and print tools read PNG inside SVG, but not always WebP.</li>
</ul>
<h2>Colour</h2>
<p>Photographers can pick a colour for the modules. We compute the WCAG contrast ratio against white and warn below 4.5. Our own note in the code puts the point where codes may stop scanning at about 3, so the warning fires with a margin to spare.</p>
<h2>Keeping the encoder out of the client bundle</h2>
<p>The encoder is small, but most visitors never need it, so we keep it out of the main bundle in two ways.</p>
<ol>
<li><strong>Lazy loading for sellers.</strong> The Share dialog loads its QR tab on demand, so <code>uqr</code> ships only in that chunk.</li>
<li><strong>Server-only for buyers.</strong> The UPI payment code on a buyer's order page is built in the route's loader. React Router's client build drops loaders, and the QR module and <code>uqr</code> go with it. The browser receives a side length and a path string, and fills a <code>Path2D</code>:</li>
</ol>
<pre><code class="language-ts">// apps/web/app/routes/buyer/order.tsx (simplified)
// Loader only: used in a component, this would ship the encoder to every buyer.
function upiHandoff(p: UpiPayment) {
  const uri = upiUri(p);
  const matrix = encodeQr(uri, "M"); // read off a screen
  return { uri, qr: { side: matrix.size + QUIET_ZONE * 2, path: qrModulePath(matrix) } };
}
</code></pre>
<p>The comment is there for the next person to touch that file. One import from a component would quietly put the encoder back into every buyer's download.</p>
<h2>What we'd check before trusting a design</h2>
<p>None of this replaces printing the thing. Before calling a style or logo size done, we'd print it at the size people will actually use, scan it from across a room with an older phone, and try it under a ceiling light reflecting off laminate. The numbers above say how much damage a code should survive. Testing on paper shows whether it does.</p>
<p>The code above lives in a photo-selling app. Each event's QR code opens a gallery where guests find their photos with a selfie. If you're curious what's at the other end of the scan, there's a <a href="https://sellphotos.online/demo?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=print-ready-qr-codes-logo">sample gallery</a>.</p>
]]></content:encoded></item><item><title><![CDATA[How we built selfie search for event photos on Cloudflare (and kept the originals out of it)]]></title><description><![CDATA[How we built selfie search for event photos: direct-to-R2 uploads, Queues, a sandboxed sharp container, and face indexing on watermarked previews.]]></description><link>https://ashishsikka.hashnode.dev/how-we-built-selfie-search-for-event-photos-on-cloudflare-and-kept-the-originals-out-of-it</link><guid isPermaLink="true">https://ashishsikka.hashnode.dev/how-we-built-selfie-search-for-event-photos-on-cloudflare-and-kept-the-originals-out-of-it</guid><category><![CDATA[cloudflare]]></category><category><![CDATA[serverless]]></category><category><![CDATA[architecture]]></category><category><![CDATA[image processing]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Ashish sikka]]></dc:creator><pubDate>Mon, 09 Nov 2026 04:20:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeff8153a42503095e3acac/aadbd224-d52e-438f-95de-945e5df30568.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>I work on <a href="https://sellphotos.online/blog/selfie-search-event-photos-cloudflare?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=selfie-search-event-photos-cloudflare">sellphotos.online</a>, where this was originally published. Written with AI assistance.</em></p>
<p>Our sample gallery has 1,240 photos from a school spring festival. A parent opens it on their phone, takes one selfie, and sees "7 photos of you". Behind that one tap sits a pipeline that takes 100 MB originals from a photographer's browser, turns them into watermarked previews, and indexes faces, without the originals ever leaving storage.</p>
<p>sellphotos.online is a store for event photographers. They upload a shoot and share one link or QR code. Guests find their photos with a selfie and pay the photographer directly. This post covers the plumbing. Three constraints shaped all of it:</p>
<ol>
<li><strong>The originals are the product.</strong> Buyers pay for them. A leaked original is a lost sale.</li>
<li><strong>Guests won't install an app.</strong> They're at a gate or in a WhatsApp group, opening a link on whatever phone they have.</li>
<li><strong>Photographers upload thousands of files at once,</strong> in whatever their camera or phone writes, including iPhone HEIC.</li>
</ol>
<p>The app is React Router (framework mode, SSR) on Cloudflare Workers, with D1, R2, Queues and a Cloudflare Container for image work.</p>
<h2>The whole pipeline</h2>
<pre><code>photographer's browser
  │  presigned PUT (≤ 20 MiB) or multipart (10 MiB parts)
  ▼
R2 originals ──(object-create notification)──▶ Queue ──▶ processor Worker
                                                              │ streams the original in
                                                              ▼
                                               Container: Node 22 + sharp/libvips
                                               (no internet, no credentials)
                                                              │ meta + watermarked WebP + smaller sizes
                                                              ▼
                                processor Worker: writes previews to R2, status = ready in D1
                                                              │
                                                              ▼
                                face indexing: the gateway fetches the WATERMARKED preview

guest's phone ─▶ gallery of previews ─▶ selfie ─▶ matches ─▶ pays the photographer ─▶ approved ─▶ original
</code></pre>
<h2>1. Uploads go straight from the browser to R2</h2>
<p>The Worker never proxies photo bytes on the way in. The browser asks for upload slots, up to 50 files per call. The app checks the plan's quota, inserts rows with status <code>uploading</code>, and returns presigned URLs: one PUT for files up to 20 MiB, or a multipart upload with one presigned part per 10 MiB above that. The URLs live for an hour and can be re-minted while the photo is still uploading.</p>
<p>Two details matter more than they look.</p>
<p><strong>The client never picks the object key.</strong> Keys are <code>{storeId}/{photoId}.{ext}</code>, the extension comes from the MIME type, and the store and photo IDs are signed into the URL as object metadata. The original filename lives only in the database, after normalisation: bidi controls are stripped, so a <code>U+202E</code> can't fake an extension.</p>
<p><strong>Duplicates are caught twice.</strong> Before uploading, the browser asks which files the event already has. It matches on name, size and <code>lastModified</code> first, then MD5-hashes only the size matches, so re-dropping a folder doesn't re-upload it. Those browser hashes are hints, though. The processor computes the MD5 of the original and has the final say.</p>
<h2>2. R2 tells a queue, and a backup message covers lost events</h2>
<p>An object-create notification on the originals bucket puts a message on a Queue. Before doing anything, the processor checks three things: the key is canonical, it matches the row's <code>original_key</code>, and its prefix is the row's store.</p>
<p>Event notifications can go missing, so there's a second path. When the browser calls <code>/uploads/complete</code>, the app enqueues a <code>render</code> message delayed by 15 minutes, which only acts on a photo still stuck in <code>processing</code>. An hourly reconciler picks up anything both of them miss.</p>
<h2>3. A container that can't call anyone</h2>
<p>The processor Worker streams the original into a Cloudflare Container running Node 22 with sharp and libvips. The container decodes and auto-orients the photo, then draws one of 27 watermark designs. It sends back a multipart response with three parts: JSON metadata (MD5, dimensions, capture time, dominant colour), a 2048 px WebP preview, and smaller sizes.</p>
<p>The container runs with <code>enableInternet = false</code> and holds no credentials. The Worker does every R2 and D1 write itself.</p>
<p>The reason is HEIC. sharp's prebuilt libvips can't decode HEVC, so HEIC files go through libheif's <code>heif-convert</code> to a lossless PNG first. libheif and libde265 are C/C++ parsers of untrusted input, with a steady stream of memory-safety CVEs. We pin libheif to an upstream release and build only the HEVC decoder. Its default security limits stay on, sharp's 300 MP pixel limit is checked against the header before decoding, and it runs via <code>execFile</code> with fixed arguments.</p>
<p>All of that helps, but the real defence is the box. Suppose someone got code execution inside the decoder. They could reach the one image they were handed and nothing else: no bucket, no other store's photos, no network.</p>
<p>HEIC had other surprises:</p>
<ul>
<li><strong>Gain maps.</strong> iOS 18 HEICs carry an ISO 21496-1 gain map, roughly 40% of the photos in a recent iPhone library. The older libheif in Debian bookworm refused every one of them. The preview now uses the SDR base image.</li>
<li><strong>Orientation, once.</strong> libheif applies <code>irot</code>/<code>imir</code>, and iPhones also write EXIF Orientation, so that path must not auto-orient a second time.</li>
<li><strong>Colour.</strong> iPhones embed a Display P3 profile. Some Android phones signal P3 only through an <code>nclx</code> box, so we insert the P3 profile before converting to sRGB.</li>
<li><strong>Bursts and edited stacks</strong> have several top-level images. We keep the primary one.</li>
</ul>
<h3>Watermark or nothing</h3>
<p>This rule has the most code behind it. No code path copies original bytes into the previews bucket. The container rasterises the overlay as its own RGBA layer, then counts its visible pixels:</p>
<pre><code class="language-js">// workers/processor/container/watermark.js (simplified)
const MIN_COVERAGE = 0.0005; // 0.05% of the frame
let visible = 0;
for (let i = 3; i &lt; data.length; i += 4) if (data[i] &gt;= 8) visible++;
if (visible &lt; width * height * MIN_COVERAGE) {
  throw new WatermarkError(`design ${designId} produced an empty overlay`, "watermark_empty");
}
</code></pre>
<p>A bad SVG, a logo that won't decode or an empty overlay each returns a permanent <code>422</code>, and the Worker only writes previews from a <code>200</code>.</p>
<h3>Smaller sizes are drawn, not downscaled</h3>
<p>Phones load images from 320 to 1536 px wide. Downscaling the 2048 px preview was the obvious move, but fine watermark patterns average away when you shrink them. We measured the share of pixels the mark visibly changes, per mille, over six real photos:</p>
<table>
<thead>
<tr>
<th>design</th>
<th>2048</th>
<th>320, downscaled</th>
<th>320, drawn at 320</th>
</tr>
</thead>
<tbody><tr>
<td>Microtext</td>
<td>71</td>
<td>16</td>
<td>57</td>
</tr>
<tr>
<td>Diamonds</td>
<td>39</td>
<td>5</td>
<td>63</td>
</tr>
<tr>
<td>Brick</td>
<td>108</td>
<td>39</td>
<td>110</td>
</tr>
<tr>
<td>Tiled</td>
<td>75</td>
<td>70</td>
<td>69</td>
</tr>
</tbody></table>
<p>So the container draws the watermark again at 1536, 1024, 640, 480 and 320 px. It costs CPU: when we added the four smaller sizes, a render of about a second on an M3 got roughly 0.4 s slower. In exchange, a 320 px thumbnail is as hard to reuse as the full preview.</p>
<p>Each photo is also rendered exactly once. Changing your watermark later only affects new uploads, so a store with 50,000 photos doesn't re-decode 50,000 originals over a logo tweak.</p>
<h2>4. Retries that don't lie</h2>
<p>Queues deliver at least once, and the container can be busy, restarting or gone. Each failure gets its own lane:</p>
<table>
<thead>
<tr>
<th>failure</th>
<th>what happens</th>
</tr>
</thead>
<tbody><tr>
<td>container busy (<code>503 busy</code>)</td>
<td>ack, re-send as a new message, backoff 15 s → 5 min; doesn't use up retries</td>
</tr>
<tr>
<td>container unreachable</td>
<td>re-send, backoff 60 s → 8 min, for up to 30 min</td>
</tr>
<tr>
<td>transient (5xx, D1, R2)</td>
<td><code>message.retry()</code> at 30 / 60 / 120 s with jitter; <code>max_retries</code> 3</td>
</tr>
<tr>
<td>permanent (bad file, broken logo)</td>
<td>marked failed now; the photographer can press Retry</td>
</tr>
<tr>
<td>still failing after all that</td>
<td>the dead-letter consumer marks it failed</td>
</tr>
</tbody></table>
<p>Back-pressure shouldn't eat a photo's retry budget. Queues has no retry that doesn't count, so "busy" and "unreachable" become fresh messages that carry their own counters in the body.</p>
<p>D1 has no transactions you can hold open and no row locks, so correctness comes from guarded statements. The final write looks like this:</p>
<pre><code class="language-sql">-- simplified
UPDATE photos
SET status = 'ready', preview_key = ?, md5 = ?, width = ?, height = ?
WHERE id = ? AND deleted_at IS NULL AND original_key = ?
  AND NOT (status = 'ready' AND preview_key IS NOT NULL);
</code></pre>
<p>The first render to commit wins. If zero rows change, either the photo was deleted mid-flight or a duplicate delivery got there first, and the preview we just wrote gets cleaned up. The duplicate check works the same way: one atomic <code>batch()</code> sets the MD5, soft-deletes newer copies in the same event (never one that has sold), and queues their R2 deletes.</p>
<p>Capacity is matched end to end. Ten container instances run two images each, so 20 at once, and the consumer runs <code>max_concurrency</code> 4 × <code>max_batch_size</code> 5, also 20.</p>
<h2>5. Face indexing sees only what the public sees</h2>
<p>Faces are indexed by SightRadar, our face-recognition service, with one collection per event. We don't send it files. It fetches each photo's watermarked preview from the public image route at <code>?w=2048</code>, and that route has no code path to the originals bucket.</p>
<p>Indexing is batched, up to 1,000 photos at a time, and arranged so a crash can neither lose a batch nor pay for it twice:</p>
<ol>
<li>In one D1 batch, write an <code>intent</code> row and mark the photos <code>sent</code>. The idempotency key is a hash of the sorted photo IDs and their attempt counts.</li>
<li>Submit the batch under that key.</li>
<li>If the Worker dies between steps 1 and 2, the next run re-submits the intent under the same key. The gateway replays a key it has already seen without charging again.</li>
<li>Poll the batch with backoff for about an hour. When configured, a signed webhook (HMAC-SHA256 over timestamp and body, ±300 s) settles results sooner.</li>
</ol>
<h2>6. The guest side: a link, a camera, no app</h2>
<p>The guest scans a QR code on a poster and lands in a gallery of previews. "Find my photos" opens a short notice first, covering what a face template is, that it's used for this event only, and how long it's kept. The camera opens only after "Agree and take a selfie".</p>
<p>The camera is plain <code>getUserMedia</code>. It opens front-facing, mirrored in the preview, though the image that's sent isn't mirrored. "Find someone else" opens the rear camera, for a parent photographing their child. The phone checks the selfie before anything is uploaded. A photo under 160 px on its short side, one that's too dark (95th-percentile luma under 50), or one with two faces of similar size (where the browser has <code>FaceDetector</code>) is refused locally, without using up a search.</p>
<p>On the server, the selfie image is never stored. It becomes a face template, and the search result is cached for 24 hours for the browser that made it. Photographers can turn on "Live camera only", so nobody can search with someone else's photo from their camera roll. They can also delete an event's face data entirely.</p>
<p>Then the guest buys single photos, "all my photos" or the whole event. They pay the photographer directly, by UPI, PayPal, bank transfer or whatever the photographer accepts, and upload a screenshot. Once the photographer approves, <code>/dl</code> checks the buyer's entitlement and streams the original from R2 with <code>Cache-Control: private, no-store</code>.</p>
<h2>What we'd tell someone building this</h2>
<ul>
<li><strong>Put the parser of untrusted files in a box with no network.</strong> Then keep patching it anyway.</li>
<li><strong>On D1, write every multi-step change as one <code>batch()</code> guarded by <code>WHERE</code> clauses.</strong> "First commit wins, then clean up" is simpler than locks you don't have.</li>
<li><strong>Give back-pressure its own retry counter.</strong> Otherwise one busy hour fails good photos.</li>
<li><strong>Measure what the user actually sees.</strong> We almost shipped thumbnails whose watermark had mostly vanished.</li>
<li><strong>Send the face service the same image the public sees.</strong> Then "what if it leaks?" has a boring answer.</li>
</ul>
<p>What's next: AVIF came in at about half the bytes of WebP at 1024 px, but it encodes about twice as slowly and needs a <code>Vary: Accept</code> that our cache keys don't have, so it's parked for now.</p>
<p>If you want to see the guest side without uploading anything, there's a <a href="https://sellphotos.online/demo?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=selfie-search-event-photos-cloudflare">sample gallery with a simulated selfie search</a>. Questions about any of the pieces above are welcome in the comments.</p>
]]></content:encoded></item></channel></rss>