<?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: ethan</title>
    <description>The latest articles on DEV Community by ethan (@_0e2a47381d01cda8b08).</description>
    <link>https://dev.to/_0e2a47381d01cda8b08</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%2F4239152%2F9f0cecaa-215a-4ee6-ac77-cec3764415b2.png</url>
      <title>DEV Community: ethan</title>
      <link>https://dev.to/_0e2a47381d01cda8b08</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_0e2a47381d01cda8b08"/>
    <language>en</language>
    <item>
      <title>Bookmarks before transcripts: what we learned building a lecture recorder</title>
      <dc:creator>ethan</dc:creator>
      <pubDate>Thu, 12 Nov 2026 14:29:20 +0000</pubDate>
      <link>https://dev.to/_0e2a47381d01cda8b08/bookmarks-before-transcripts-what-we-learned-building-a-lecture-recorder-28d5</link>
      <guid>https://dev.to/_0e2a47381d01cda8b08/bookmarks-before-transcripts-what-we-learned-building-a-lecture-recorder-28d5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.ai_lecture_recorder" rel="noopener noreferrer"&gt;AI Lecture Recorder&lt;/a&gt; records lectures offline, lets you bookmark the moments that matter with one tap, and transcribes only when you ask it to. This post is about why the bookmark button ended up more important than the AI.&lt;/p&gt;

&lt;p&gt;🤖 &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.ai_lecture_recorder" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; · 📱 &lt;a href="https://apps.apple.com/us/app/ai-lecture-recorder/id6791456539" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvk19io6uofok51pumw5e.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvk19io6uofok51pumw5e.webp" alt="AI Lecture Recorder: record, bookmark and review lectures" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.nexateam.dev/blog/bookmarks-before-transcripts" rel="noopener noreferrer"&gt;Nexa Tech blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When we started AI Lecture Recorder, the obvious headline feature was transcription: record an hour, get an hour of text. Students told us they rarely read a full transcript. What they wanted was to find the five minutes that mattered. So we organised the product around three moments in a student's day.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before: start recording fast.&lt;/strong&gt; Open, tap record, put the phone on the desk. No setup screen in the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;During: mark what matters.&lt;/strong&gt; One tap to bookmark the moment the lecturer says "this will be on the exam".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After: find it again.&lt;/strong&gt; Jump straight to bookmarks, and transcribe only when text actually helps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before: getting out of the way
&lt;/h2&gt;

&lt;p&gt;Lectures start whether you are ready or not. The app opens straight to a record button, and recording works fully offline. Nothing about capture depends on a network or an account.&lt;/p&gt;

&lt;h2&gt;
  
  
  During: one tap beats a note
&lt;/h2&gt;

&lt;p&gt;The biggest button on the recording screen, after stop, is bookmark. One tap marks the current moment. A label can be added later, but doesn't have to be. Marking something takes less effort than writing it down, so people actually do it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The most advanced feature is not always the most useful one. Sometimes it is a single button that fits the moment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  After: review, not replay
&lt;/h2&gt;

&lt;p&gt;Reviewing a lecture is different from listening to music. You skip, rewind a few seconds, slow down a dense part, speed through a tangent.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record&lt;/strong&gt;: phone on the desk, screen off&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tap to bookmark&lt;/strong&gt;: mark the moment, label it later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jump back&lt;/strong&gt;: playback starts just before the mark&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcribe&lt;/strong&gt;: optional, when text helps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bookmarks do the finding; transcription is there when you want words.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Playback starts slightly before each bookmark so you hear the lead-in.&lt;/li&gt;
&lt;li&gt;Skip buttons are sized for the short jumps people make most.&lt;/li&gt;
&lt;li&gt;Speed control is one tap, not buried in a menu.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where transcription fits
&lt;/h2&gt;

&lt;p&gt;Transcription is still there, powered by Groq, for when text genuinely helps: searching for a term, copying a definition into notes, or reviewing in a quiet library without headphones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to…&lt;/th&gt;
&lt;th&gt;Best tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find the exam hint&lt;/td&gt;
&lt;td&gt;Bookmarks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-hear an explanation&lt;/td&gt;
&lt;td&gt;Bookmarks + playback speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search for a term&lt;/td&gt;
&lt;td&gt;Transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy a definition into notes&lt;/td&gt;
&lt;td&gt;Transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Optional on purpose.&lt;/strong&gt; Transcription is a choice per recording, not a default. People decide when audio is sent for processing, and recording never depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update: what's new in 2.0
&lt;/h2&gt;

&lt;p&gt;Since this post first went up we shipped recording with the screen off, trimming, folders, and export to audio, TXT or PDF. We also removed subscriptions: you can now earn points to unlock the premium AI features instead of paying monthly.&lt;/p&gt;

&lt;p&gt;If you're a student (or build tools for students), I'd love feedback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Android: &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.ai_lecture_recorder" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.nexa.ai_lecture_recorder&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;iPhone: &lt;a href="https://apps.apple.com/us/app/ai-lecture-recorder/id6791456539" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/ai-lecture-recorder/id6791456539&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What would make you actually review a lecture recording instead of letting it sit in a folder?&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>ux</category>
      <category>mobile</category>
      <category>ai</category>
    </item>
    <item>
      <title>A QR scanner that shows you the link before it opens</title>
      <dc:creator>ethan</dc:creator>
      <pubDate>Thu, 12 Nov 2026 05:04:25 +0000</pubDate>
      <link>https://dev.to/_0e2a47381d01cda8b08/a-qr-scanner-that-shows-you-the-link-before-it-opens-43dg</link>
      <guid>https://dev.to/_0e2a47381d01cda8b08/a-qr-scanner-that-shows-you-the-link-before-it-opens-43dg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.qr_code" rel="noopener noreferrer"&gt;ScanQR&lt;/a&gt; is a free QR code and barcode scanner for Android and iOS that never jumps straight to a website. It shows you the real domain first and warns about suspicious links. This post explains why we built it that way.&lt;/p&gt;

&lt;p&gt;🤖 &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.qr_code" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; · 📱 &lt;a href="https://apps.apple.com/us/app/scanqr-qr-code-barcode/id6779862851" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.nexateam.dev/blog/qr-scanner-that-shows-the-link" rel="noopener noreferrer"&gt;Nexa Tech blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Many scanners open a link the instant they read a QR code. It feels fast. It also means visiting a website without ever seeing its address, which is exactly what a fake sticker on a parking meter is counting on. ScanQR adds one step: a preview. This post explains what that preview shows and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a suspicious link
&lt;/h2&gt;

&lt;p&gt;Here is a made-up address of the kind found on fake parking stickers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://pay.citypark-0nline.com/meter/4471?ref=sticker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"pay" and "meter" make it look official, but the real domain (&lt;code&gt;citypark-0nline.com&lt;/code&gt;) is not the city's, and the zero in "0nline" is a classic lookalike trick. Shown full size, it is easy to spot. Hidden behind an instant redirect, nobody sees it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every scan passes through a preview
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scan&lt;/strong&gt; a QR code or barcode&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify&lt;/strong&gt; it: link, Wi-Fi, contact or text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview&lt;/strong&gt; it, with the real domain shown large&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Act&lt;/strong&gt;: open, copy, connect or save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing opens until the user decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the preview shows for each type
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Preview shows&lt;/th&gt;
&lt;th&gt;Main action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web link&lt;/td&gt;
&lt;td&gt;Real domain, large, separate from the rest&lt;/td&gt;
&lt;td&gt;Open in browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wi-Fi&lt;/td&gt;
&lt;td&gt;Network name and security type&lt;/td&gt;
&lt;td&gt;Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contact&lt;/td&gt;
&lt;td&gt;Name, phone, email&lt;/td&gt;
&lt;td&gt;Save to contacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain text&lt;/td&gt;
&lt;td&gt;The text itself&lt;/td&gt;
&lt;td&gt;Copy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Signals we point out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shortened links&lt;/strong&gt;: they hide where they really go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;http&lt;/code&gt;&lt;/strong&gt; without encryption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookalike domains&lt;/strong&gt;: a well-known name spelled slightly differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very long addresses&lt;/strong&gt; where the real domain is pushed out of view.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A preview is not a guarantee.&lt;/strong&gt; None of this proves a link is safe. It gives people the information to make their own decision, which is more than an instant redirect does.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Half a second of friction is a fair price for knowing where you are going.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Beyond scanning
&lt;/h2&gt;

&lt;p&gt;Scans are saved to a searchable history on the device, so the menu from lunch or the event link from a poster is still there tomorrow. The same app generates codes for Wi-Fi networks and contact cards, which turned out to be popular with small shops and cafes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update: what ScanQR 2.0 added
&lt;/h2&gt;

&lt;p&gt;Since this post first went up, the preview grew into a fuller set of features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More scam checks:&lt;/strong&gt; raw IP addresses, look-alike characters, and links pretending to be banks or big retailers now get a warning before you open them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment QR codes:&lt;/strong&gt; reads and creates Pix, UPI, SEPA/EPC GiroCode, Swiss QR-bill, PromptPay, VietQR and other EMVCo formats, so you can see who you're paying before your banking app opens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product and book lookups:&lt;/strong&gt; EAN/UPC and ISBN barcodes are looked up via Open Food Facts, Open Beauty Facts and Open Library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every common format:&lt;/strong&gt; QR, Data Matrix, Aztec, PDF417, EAN, UPC, ITF, Code 39/93/128 and Codabar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private by default:&lt;/strong&gt; no account, and history stays on the device. Available in 22 languages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try it free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Android: &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.qr_code" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.nexa.qr_code&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;iPhone: &lt;a href="https://apps.apple.com/us/app/scanqr-qr-code-barcode/id6779862851" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/scanqr-qr-code-barcode/id6779862851&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How do you handle link previews in your own apps? I'd love to hear other approaches in the comments.&lt;/p&gt;

</description>
      <category>android</category>
      <category>mobile</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How we find duplicate photos and pick the best shot on-device (React Native + Expo modules)</title>
      <dc:creator>ethan</dc:creator>
      <pubDate>Wed, 11 Nov 2026 13:20:22 +0000</pubDate>
      <link>https://dev.to/_0e2a47381d01cda8b08/how-we-find-duplicate-photos-and-pick-the-best-shot-on-device-react-native-expo-modules-5b0</link>
      <guid>https://dev.to/_0e2a47381d01cda8b08/how-we-find-duplicate-photos-and-pick-the-best-shot-on-device-react-native-expo-modules-5b0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://apps.apple.com/us/app/clean-free-up-your-space/id6818406418" rel="noopener noreferrer"&gt;CleanMaster&lt;/a&gt; is a free iOS &amp;amp; Android app that frees up phone storage: it finds duplicate and similar photos, keeps the best shot, clears old screenshots, chat-app media and big videos, and locks private files in a vault. Everything runs on the device. This post is how the photo part works under the hood.&lt;/p&gt;

&lt;p&gt;📱 &lt;a href="https://apps.apple.com/us/app/clean-free-up-your-space/id6818406418" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; · 🤖 &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.free_up_your_space" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Phones fill up with near-copies: the same meme saved three times, a forwarded photo that came back from WhatsApp, a burst of 12 shots where only one has everyone's eyes open.&lt;/p&gt;

&lt;p&gt;While building &lt;strong&gt;CleanMaster&lt;/strong&gt;, a storage cleaner for iOS and Android, we set two rules: &lt;strong&gt;nothing leaves the phone&lt;/strong&gt;, and &lt;strong&gt;nothing is deleted that the user hasn't reviewed&lt;/strong&gt;. That ruled out sending photos to a cloud model. Here's how the on-device pipeline works, with the actual code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Fingerprinting photos with difference hashes
&lt;/h2&gt;

&lt;p&gt;To find "the same picture saved more than once", we need a fingerprint that survives re-compression and resizing (what chat apps do to a forwarded photo) but changes when the picture really changes.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;difference hash (dHash)&lt;/strong&gt; fits well: shrink the image to a tiny grayscale grid, then store one bit per pixel saying whether it is brighter than its right-hand neighbour. Re-encoding a JPEG barely moves those comparisons; cropping or editing does.&lt;/p&gt;

&lt;p&gt;We compute two of them in a native Expo module (Swift on iOS, Kotlin on Android):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;coarse 64-bit hash&lt;/strong&gt; (9×8 grid) to gather candidates quickly&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;fine 256-bit hash&lt;/strong&gt; (17×16 grid) to make the final decision&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thresholds are deliberately tight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Differing bits allowed. The coarse 64-bit hash only gathers candidates; the
 * 256-bit one decides. Kept tight: two different screenshots of the same app
 * screen look alike at this size, and a false match means a wrong deletion.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;COARSE_MAX&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="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;FINE_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cm"&gt;/** Copies keep their shape; a different aspect ratio is a different picture (or a crop). */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ASPECT_TOLERANCE&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="cm"&gt;/** Fewer set (or unset) bits than this in the 256-bit hash means a near-flat picture. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;MIN_DETAIL_BITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last constant matters more than it looks. Black frames, white walls and pocket shots all hash to almost the same value, so "near-flat" pictures are excluded from duplicate matching entirely.&lt;/p&gt;

&lt;p&gt;Comparing hashes is a Hamming distance. We split the hex string into 32-bit words and popcount the XOR, bailing out as soon as the limit is passed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;popcount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x55555555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x33333333&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x33333333&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x0f0f0f0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mh"&gt;0x01010101&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int32Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int32Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;d&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="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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;popcount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;d&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;Hashes are cached by asset ID in the app's cache folder (they can always be recomputed), and we hash four photos at a time. A nice side effect on iOS: the small local thumbnail of an iCloud-only photo hashes the same as the full image, so we don't need to download anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Deciding which copy to keep
&lt;/h2&gt;

&lt;p&gt;For true duplicates the rule is simple: keep the copy with the most pixels, and if that's a tie, the oldest one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;better&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;WinItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;WinItem&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="n"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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="n"&gt;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&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="n"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;tb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ta&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;tb&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;tb&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;h2&gt;
  
  
  3. Best Shot: picking the winner in a burst
&lt;/h2&gt;

&lt;p&gt;Bursts are harder. The shots aren't duplicates; they're different moments a fraction of a second apart, and "keep the first one" is often wrong. So the native module scores each photo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sharpness&lt;/strong&gt;: variance of the Laplacian on a grayscale copy. Blur flattens edges, so the variance drops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exposure&lt;/strong&gt;: mean brightness plus the share of clipped (blown-out or crushed) pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faces&lt;/strong&gt;: on iOS, Vision's &lt;code&gt;VNDetectFaceCaptureQualityRequest&lt;/code&gt; plus face landmarks, where we compute an eye aspect ratio (about 0.3 is wide open, under 0.1 is shut). On Android, ML Kit face detection's eye-open probability. The &lt;strong&gt;worst&lt;/strong&gt; face in the frame counts, because one person blinking ruins a group shot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then each burst is ranked relative to itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;maxSharp&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharpness&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;maxFaces&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sharp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharpness&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;maxSharp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;exposure&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brightness&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clipped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;maxFaces&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="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sharp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;exposure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&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="n"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eyesOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;faceQuality&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Someone missing from the frame (turned away, cropped) counts against it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;faces&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;maxFaces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sharp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;face&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;exposure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&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;Two details made a real difference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sharpness is relative to the sharpest shot in the same burst.&lt;/strong&gt; A dim restaurant photo is never as "sharp" as a sunny beach, and it shouldn't be punished for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;faces / maxFaces&lt;/code&gt;&lt;/strong&gt; penalises the frame where someone turned away or walked out of shot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app also shows &lt;em&gt;why&lt;/em&gt; a photo won ("Sharpest · eyes open"), and you can tap to compare before deleting. People trust a cleaner far more when it explains itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bias towards false negatives.&lt;/strong&gt; Missing a duplicate costs a few MB; a wrong match costs someone a photo they cared about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make caches recomputable&lt;/strong&gt; and store them where the OS is allowed to purge them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native modules pay off.&lt;/strong&gt; Hashing and scoring in Swift/Kotlin through Expo modules, with JS only orchestrating, kept scans of thousands of photos usable on mid-range Android phones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-device is good enough.&lt;/strong&gt; Apple Vision and Google ML Kit cover faces and eyes for free, with no privacy questions to answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;CleanMaster is free on both stores. It also cleans old screenshots, chat-app media and big videos, and includes a PIN-locked private vault:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iPhone: &lt;a href="https://apps.apple.com/us/app/clean-free-up-your-space/id6818406418" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/clean-free-up-your-space/id6818406418&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Android: &lt;a href="https://play.google.com/store/apps/details?id=com.nexa.free_up_your_space" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.nexa.free_up_your_space&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions about the hashing or the scoring in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>ios</category>
      <category>android</category>
    </item>
  </channel>
</rss>
