<?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: Kamelyoul</title>
    <description>The latest articles on DEV Community by Kamelyoul (@kamelyoul).</description>
    <link>https://dev.to/kamelyoul</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%2F4167487%2F62bf62ed-845c-4e8c-9419-e8f903c7a9e5.png</url>
      <title>DEV Community: Kamelyoul</title>
      <link>https://dev.to/kamelyoul</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kamelyoul"/>
    <language>en</language>
    <item>
      <title>Filtering a 2 GB CSV in the browser without uploading it: streams, a Web Worker and a 64-bit hash set</title>
      <dc:creator>Kamelyoul</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:24:36 +0000</pubDate>
      <link>https://dev.to/kamelyoul/filtering-a-2-gb-csv-in-the-browser-without-uploading-it-streams-a-web-worker-and-a-64-bit-hash-3mfp</link>
      <guid>https://dev.to/kamelyoul/filtering-a-2-gb-csv-in-the-browser-without-uploading-it-streams-a-web-worker-and-a-64-bit-hash-3mfp</guid>
      <description>&lt;p&gt;Excel stops at 1,048,576 rows. Most online CSV tools want you to upload the file first, which is a non-starter for a customer list or an accounting export. I wanted something in between: open a multi-gigabyte CSV in a normal browser tab, filter it, remove duplicates, split it into Excel-sized files, and &lt;strong&gt;never send a byte of it anywhere&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://csv-geant.pages.dev/?ref=devto2" rel="noopener noreferrer"&gt;CSV Géant&lt;/a&gt;, a static site with no framework and no runtime dependency. The UI is in French by default (there's an EN button in the header). This post covers the architecture, the actual code, and numbers I measured on my own machine, including the parts that don't stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Main thread&lt;/strong&gt;: UI only. It sends the &lt;code&gt;File&lt;/code&gt; object to a module Web Worker with &lt;code&gt;postMessage&lt;/code&gt;. A &lt;code&gt;File&lt;/code&gt; is a handle, so this doesn't copy the content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker&lt;/strong&gt;: reads the file in 8 MB slices, decodes, parses, filters, deduplicates and writes the output. Progress messages go back to the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output sinks&lt;/strong&gt;: either an in-memory &lt;code&gt;Blob&lt;/code&gt; that becomes a download, or, in Chrome/Edge, files written directly into a folder the user picks (File System Access API).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server&lt;/strong&gt;: one Cloudflare Pages Function with three routes: anonymous counters, license check, and the price/purchase link. No CSV content, row or column name goes through it. The CSP has &lt;code&gt;connect-src 'self'&lt;/code&gt;, and once the page is loaded the tool keeps working offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core (&lt;code&gt;csv.js&lt;/code&gt; and &lt;code&gt;traitement.js&lt;/code&gt;) has no DOM dependency, so the same code runs in Node for the tests (&lt;code&gt;fs.openAsBlob&lt;/code&gt; gives a Blob-like file).&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Reading: &lt;code&gt;slice()&lt;/code&gt; plus a streaming &lt;code&gt;TextDecoder&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The whole read loop is short. Identifiers are in French; &lt;code&gt;parcourir&lt;/code&gt; means "walk through":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TAILLE_BLOC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 8 Mo lus à la fois&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parcourir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fichier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;encodage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;surEnregistrement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;surBloc&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;decodeur&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encodage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// retire le BOM éventuel&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;index&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;lecteur&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;LecteurCSV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;champs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;surEnregistrement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;champs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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;o&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;o&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;fichier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;TAILLE_BLOC&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;tampon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fichier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TAILLE_BLOC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;lecteur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pousser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decodeur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tampon&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stream&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;surBloc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;surBloc&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;o&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TAILLE_BLOC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fichier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;lecteur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pousser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decodeur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;lecteur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;index&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;&lt;code&gt;{ stream: true }&lt;/code&gt; matters: an 8 MB boundary can fall in the middle of a multi-byte UTF-8 character ("é" is two bytes). In streaming mode the decoder keeps the incomplete bytes and prepends them to the next chunk.&lt;/p&gt;

&lt;p&gt;Encoding detection runs on the first 2 MB: BOM first, then a strict UTF-8 decode (&lt;code&gt;new TextDecoder("utf-8", { fatal: true })&lt;/code&gt;), and Windows-1252 if that throws. Since the sample can itself end mid-character, a helper strips a truncated trailing sequence before the strict decode. Without it, a valid UTF-8 file would be misdetected as Windows-1252 whenever the 2 MB cut lands inside an accented character.&lt;/p&gt;

&lt;p&gt;The delimiter (&lt;code&gt;;&lt;/code&gt;, &lt;code&gt;,&lt;/code&gt;, tab, &lt;code&gt;|&lt;/code&gt;) is guessed by parsing the sample with each candidate and keeping the one that gives the most regular column count (at least 2 columns), then the most columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A parser that can stop anywhere
&lt;/h2&gt;

&lt;p&gt;The parser is fed arbitrary chunks of text, so a record can be cut anywhere, including inside a quoted field that contains newlines. The internal &lt;code&gt;#analyser&lt;/code&gt; returns the index of the first character it could not consume as a complete record, and the caller keeps that tail for the next chunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;pousser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;morceau&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reste&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reste&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;morceau&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;morceau&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;consomme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nf"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reste&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;consomme&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;consomme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reste&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;LONGUEUR_MAX_ENREGISTREMENT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENREGISTREMENT_TROP_LONG&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LONGUEUR_MAX_ENREGISTREMENT&lt;/code&gt; is 16 MB. Without it, a single unclosed quote (or a wrong delimiter/encoding choice) would make &lt;code&gt;reste&lt;/code&gt; grow until the tab runs out of memory. With it, the user gets an error message that points at the likely cause.&lt;/p&gt;

&lt;p&gt;The parser handles RFC 4180 quoting (doubled quotes, newlines inside quotes), &lt;code&gt;\n&lt;/code&gt;, &lt;code&gt;\r\n&lt;/code&gt; and &lt;code&gt;\r&lt;/code&gt;, and tolerates text after a closing quote (&lt;code&gt;"abc"def&lt;/code&gt;). Rows with an unexpected number of columns are counted and reported, not dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deduplication: 64-bit fingerprints in a &lt;code&gt;Uint32Array&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Storing every distinct row in a JS &lt;code&gt;Set&lt;/code&gt; doesn't work at 20 million rows. Instead, each row (or the chosen column, optionally trimmed and lower-cased) is reduced to two independent 32-bit hashes, stored in a flat open-addressing table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nf"&gt;inserer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h2&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;masque&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capacite&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h1&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x9e3779b1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;masque&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;table&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="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;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taille&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="kc"&gt;true&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="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;h1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;masque&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;h2&lt;/code&gt; is never 0, so 0 marks an empty slot. The table doubles when it would be more than half full.&lt;/p&gt;

&lt;p&gt;Two honest consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory is 16 to 32 bytes per distinct key&lt;/strong&gt;, depending on where you are in the doubling cycle (8 bytes per slot, load factor between 25% and 50%). For the 21.3 million distinct rows in my test file, the final table is 512 MiB, plus the previous 256 MiB table while it's being copied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's probabilistic.&lt;/strong&gt; Two different rows with the same 64-bit fingerprint would be treated as duplicates. The probability is about n²/2⁶⁵, so roughly 1 in 80,000 for 21 million distinct rows. For a deduplication tool, I decided that was acceptable; you may disagree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Writing: BOM, CRLF, and two sinks
&lt;/h2&gt;

&lt;p&gt;Output rows are serialized (quoted only when needed, CRLF line endings), accumulated in a string, and encoded with &lt;code&gt;TextEncoder&lt;/code&gt; in batches of about 4 million characters. Each output file starts with a UTF-8 BOM and repeats the header row. The BOM is what makes Excel read accents correctly instead of guessing a legacy encoding. Splitting "for Excel" means 1,048,575 data rows per file, plus the header.&lt;/p&gt;

&lt;p&gt;The download sink groups the encoded chunks into 64 MB Blobs as it goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;ecrire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;octets&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;courant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;morceaux&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;octets&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enAttente&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;octets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taille&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;octets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enAttente&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;morceaux&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;morceaux&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enAttente&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole result is still assembled before the download starts. For results over 1 GB, the UI recommends the other sink: &lt;code&gt;showDirectoryPicker()&lt;/code&gt;, then one &lt;code&gt;createWritable()&lt;/code&gt; stream per output file. That one only exists in Chromium browsers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The part that doesn't stream: sorting
&lt;/h2&gt;

&lt;p&gt;You can't sort a file you only see 8 MB at a time without an external merge sort, and I haven't built one. So sorting keeps the filtered result in memory, as compactly as I could manage: each row is stored already serialized in UTF-8 inside 16 MB blocks, numeric and date keys go into a &lt;code&gt;Float64Array&lt;/code&gt;, and what gets sorted is a &lt;code&gt;Uint32Array&lt;/code&gt; of indices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ordreIdx&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;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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="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;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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;this&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="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;ordreIdx&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="o"&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;ordreIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&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="nf"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;cles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&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;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| a - b&lt;/code&gt; makes the sort stable. Hard limits: 3 million rows or 600 million characters. Beyond that, the export stops with an explicit message (filter first, then sort).&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Conditions&lt;/strong&gt;: AMD Ryzen 5 4500U (6 cores), 16 GB RAM, NVMe SSD, Windows 11. Chrome 154 headless driven by Playwright (the &lt;code&gt;tests/e2e-tres-gros.mjs&lt;/code&gt; script from the repo), app served from localhost. Test file generated by the repo's &lt;code&gt;scripts/generer-csv.mjs&lt;/code&gt;: 22 million rows, 9 columns, &lt;strong&gt;2.22 GB&lt;/strong&gt;, semicolon-delimited UTF-8 with accents, about 427,000 quoted fields containing line breaks, and 660,441 exact duplicates. Each result was checked against the expected counts written by the generator.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation (Chrome, 2.22 GB file)&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Preview (encoding + delimiter detection, first 100 rows)&lt;/td&gt;
&lt;td&gt;0.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact row count (22,000,000)&lt;/td&gt;
&lt;td&gt;17.4 s / 17.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicate everything + split into 21 Excel-sized files, written to a folder (2.18 GB out)&lt;/td&gt;
&lt;td&gt;102.2 s / 106.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter &lt;code&gt;pays&lt;/code&gt; (country) &lt;code&gt;= FR&lt;/code&gt; + deduplicate, single download (8,001,769 rows, 0.82 GB out)&lt;/td&gt;
&lt;td&gt;71.8 s / 72.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two runs each: roughly 125 MB/s for counting, 20 to 30 MB/s once every row is hashed and re-serialized. In headless mode, the origin private file system stood in for the native folder picker.&lt;/p&gt;

&lt;p&gt;On a second file (2 million rows, 198 MB): numeric sort of all rows, descending, in 9.8 s and 10.7 s.&lt;/p&gt;

&lt;p&gt;The same library in Node 24 gives the same counts; full dedup peaked there at about 1 GB of resident memory, consistent with the hash table sizes above.&lt;/p&gt;

&lt;p&gt;These are one laptop's numbers. I haven't measured anything above 2.22 GB: the reading loop has no size-dependent state, but deduplication and sorting do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free version and license
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt;: preview, encoding/delimiter detection and row counting with no limit. Exports (filter, dedup, sort, split) write at most &lt;strong&gt;100,000 rows per operation&lt;/strong&gt;. The worker still reads the whole file, so the result screen tells you how many rows the full result would contain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro&lt;/strong&gt;: a €29 one-time license sold through Gumroad, which removes the row cap in the browser where the key is entered. The key goes to the Pages Function, which calls Gumroad's license verification API and refuses refunded or disputed purchases. The browser stores it in &lt;code&gt;localStorage&lt;/code&gt; and re-checks it every 7 days; offline, the stored key stays valid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since everything runs client-side, the cap is enforced in client-side code (&lt;code&gt;limite: m.pro ? Infinity : LIMITE_GRATUITE&lt;/code&gt;). It's a pricing boundary, not DRM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server sees
&lt;/h2&gt;

&lt;p&gt;Anonymous daily counters (a visit, a preview, an export, an error), the &lt;code&gt;?ref=&lt;/code&gt; tag of the visit if there is one, and the license key if you type one. No cookies and no third-party analytics. The easy way to verify it: load the page, cut the network, and run an export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tested in Chrome only.&lt;/strong&gt; Edge uses the same engine but I haven't tested it; Firefox and Safari aren't tested. Writing to a folder requires Chrome or Edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sorting is in memory&lt;/strong&gt;: 3 million rows / 600 million characters max.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication costs 16 to 32 bytes per distinct key&lt;/strong&gt; and is probabilistic, as described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One worker, one core.&lt;/strong&gt; Parsing isn't parallelized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV in, CSV out.&lt;/strong&gt; No &lt;code&gt;.xlsx&lt;/code&gt; output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single record over 16 MB stops processing&lt;/strong&gt; (usually an unclosed quote).&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;a href="https://csv-geant.pages.dev/?ref=devto2" rel="noopener noreferrer"&gt;csv-geant.pages.dev&lt;/a&gt;: no account, and it can generate sample files up to 2 million rows. I'd like to hear about timings on other hardware, and about CSV dialects that break the delimiter detection.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building an MCP server in Python on open data: French company lookup, tested end to end</title>
      <dc:creator>Kamelyoul</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:22:29 +0000</pubDate>
      <link>https://dev.to/kamelyoul/building-an-mcp-server-in-python-on-open-data-french-company-lookup-tested-end-to-end-27ga</link>
      <guid>https://dev.to/kamelyoul/building-an-mcp-server-in-python-on-open-data-french-company-lookup-tested-end-to-end-27ga</guid>
      <description>&lt;p&gt;I wanted a small, real MCP server to learn the protocol properly: not a "hello world" tool, but something an assistant could use for an actual task. French public data is a good playground. Two official APIs answer most "who is this company?" questions, need no key, and are published under the &lt;strong&gt;Licence Ouverte 2.0&lt;/strong&gt; (Etalab's open licence, which allows reuse, including commercial, as long as you credit the source):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API Recherche d'entreprises&lt;/strong&gt; (DINUM, behind annuaire-entreprises.data.gouv.fr), built on INSEE Sirene and the national business register.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BODACC&lt;/strong&gt;, the official gazette of commercial notices (insolvency judgments, deregistrations, accounts filings), published by DILA on OpenDataSoft.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/Kamelyoul/mcp-french-company-data" rel="noopener noreferrer"&gt;mcp-french-company-data&lt;/a&gt;: about 460 lines of Python across two modules, three read-only tools, stdio transport, MIT licence. This post covers the choices that mattered: which tools to expose, how the schemas are built, how errors reach the model, how it's tested, and what it can't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tools, and why only three
&lt;/h2&gt;

&lt;p&gt;The tempting design is one tool per API endpoint. I went the other way and started from the questions a user actually asks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"What's the SIREN of X?" → &lt;code&gt;search_companies&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;"Tell me everything public about this company" → &lt;code&gt;get_company&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;"Is it in trouble?" → &lt;code&gt;get_bodacc_announcements&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three tools with clear, non-overlapping names are easier for a model to choose between than ten thin wrappers. The server also ships an &lt;code&gt;instructions&lt;/code&gt; string that tells the model how they chain together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;french-company-data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;__version__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Look up French companies in official open data. Use search_companies to find a &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIREN from a name, get_company for the full profile, and get_bodacc_announcements &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;for legal notices (insolvency, deregistration, accounts filing, changes). &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Data is public and may lag the registries by a few days; always cite the source URL.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other big choice was what &lt;em&gt;not&lt;/em&gt; to return. The search API can include company officers (names, birth month and year). The server never asks for them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per_page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;per_page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&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="c1"&gt;# Leave out `dirigeants` (names and birth months of individuals).
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minimal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;siege,complements,finances,tva&lt;/span&gt;&lt;span class="sh"&gt;"&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;Responses are also reshaped. The raw API uses French field names and INSEE codes (&lt;code&gt;5599&lt;/code&gt;, &lt;code&gt;"21"&lt;/code&gt; for a headcount bracket). The server maps the most common ones to readable labels and keeps the code next to them, e.g. &lt;code&gt;"SA with board of directors (5599)"&lt;/code&gt;. Unknown codes are returned as-is rather than guessed.&lt;/p&gt;

&lt;p&gt;Every tool is annotated read-only, so clients can treat it accordingly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;READ_ONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destructive_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;idempotent_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;open_world_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Schemas: let the type hints do the work
&lt;/h2&gt;

&lt;p&gt;This uses the v2 Python SDK (&lt;code&gt;mcp&lt;/code&gt; 2.3.0 when I ran the tests). Most tutorials you'll find use the v1 &lt;code&gt;FastMCP&lt;/code&gt; class; the decorator pattern is the same. Each parameter is an &lt;code&gt;Annotated&lt;/code&gt; type with a Pydantic &lt;code&gt;Field&lt;/code&gt;, and the SDK turns that into the JSON Schema the client sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;READ_ONLY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_bodacc_announcements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;siren&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIREN (9 digits) or SIRET (14 digits)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&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="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Max announcements, newest first (1-20)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BodaccCategory&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Optional filter: collective (insolvency), radiation, dpc (accounts), &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification, creation, immatriculation, vente, conciliation, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retablissement_professionnel, divers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BodaccCategory&lt;/code&gt; is a &lt;code&gt;Literal[...]&lt;/code&gt; of the ten BODACC families, so the generated schema contains a real &lt;code&gt;enum&lt;/code&gt;, and the &lt;code&gt;ge&lt;/code&gt;/&lt;code&gt;le&lt;/code&gt; bounds become &lt;code&gt;minimum: 1&lt;/code&gt; / &lt;code&gt;maximum: 20&lt;/code&gt;. The model sees the allowed values instead of having to guess French category codes. On &lt;code&gt;search_companies&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt; has &lt;code&gt;min_length=2&lt;/code&gt; and the optional postal code has &lt;code&gt;pattern=r"^\d{5}$"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The docstring becomes the tool description, so I wrote those for the model, not for humans reading the code: what it returns, and what it deliberately doesn't ("Personal data of company officers is intentionally not returned").&lt;/p&gt;

&lt;p&gt;Because the functions return a &lt;code&gt;dict&lt;/code&gt;, the SDK also publishes an output schema and returns structured content, which is what the tests assert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error handling: three layers
&lt;/h2&gt;

&lt;p&gt;A tool that raises a random exception gives the model nothing to work with. Here every failure ends up as a tool result with &lt;code&gt;isError: true&lt;/code&gt; and a sentence the model can act on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Schema validation&lt;/strong&gt;, done by the SDK before my code runs. Calling &lt;code&gt;search_companies&lt;/code&gt; with &lt;code&gt;query: "d"&lt;/code&gt; returns (real output):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error executing tool search_companies: 1 validation error for search_companiesArguments
query
  String should have at least 2 characters [type=string_too_short, ...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Domain validation&lt;/strong&gt;, before any network call. A SIREN has a Luhn check digit, so a typo can be caught locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_siren_or_fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;siren&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_siren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;siren&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is not a valid SIREN (9 digits) or SIRET (14 digits).&lt;/span&gt;&lt;span class="sh"&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;siren&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;normalize_siren&lt;/code&gt; strips spaces, truncates a 14-digit SIRET to its SIREN, and has one hard-coded exception: La Poste (&lt;code&gt;356000000&lt;/code&gt;), whose SIREN doesn't pass the Luhn key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Upstream failures.&lt;/strong&gt; The HTTP layer only raises one exception type, &lt;code&gt;SourceError&lt;/code&gt;, with a message meant to be shown as-is. Tools convert it to &lt;code&gt;ToolError&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bodacc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;famille&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SourceError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A "not found" is also an explicit error, with the most likely reason. Some companies opt out of public listing ("non-diffusible"), and the API simply doesn't return them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No public record for SIREN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. It may not exist, or be &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;non-diffusible&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; (opted out of public listing).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Being a polite API client
&lt;/h2&gt;

&lt;p&gt;Recherche d'entreprises documents a limit of 7 requests per second per IP. BODACC on OpenDataSoft exposes an anonymous daily quota in its &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers (20,000 calls per day when I looked). An assistant looping over a list of suppliers can hit those fast, so the client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spaces requests (at most 4/s for the search API, 2/s for BODACC) with a small async limiter;&lt;/li&gt;
&lt;li&gt;caches identical requests in memory for 5 minutes;&lt;/li&gt;
&lt;li&gt;sends a descriptive &lt;code&gt;User-Agent&lt;/code&gt; with the repo URL;&lt;/li&gt;
&lt;li&gt;on HTTP 429, waits &lt;strong&gt;once&lt;/strong&gt; if &lt;code&gt;Retry-After&lt;/code&gt; is 10 seconds or less, otherwise gives up with a clear message instead of blocking the conversation:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;attempt&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_retry_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_RETRY_AFTER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SourceError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: rate limit reached, retry later &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(Retry-After: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n/a&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The limiter takes &lt;code&gt;clock&lt;/code&gt; and &lt;code&gt;sleep&lt;/code&gt; as parameters, which makes it testable without waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests: three levels, no network by default
&lt;/h2&gt;

&lt;p&gt;The suite has 27 offline tests plus one live test, opt-in. I re-ran them while writing this (Windows, Python 3.13.15, &lt;code&gt;mcp&lt;/code&gt; 2.3.0, pytest 9.1.1):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pytest
collected 28 items
tests\test_live.py s
tests\test_server.py ............
tests\test_sources.py ...............
======================== 27 passed, 1 skipped in 4.58s ========================

$ LIVE=1 pytest -m live
====================== 1 passed, 27 deselected in 2.80s =======================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;HTTP is simulated&lt;/strong&gt; with &lt;code&gt;httpx.MockTransport&lt;/code&gt;, serving JSON fixtures recorded from the real APIs and logging every request. That lets tests assert on what is &lt;em&gt;sent&lt;/em&gt;, not only on what comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_get_company_invalid_siren_is_tool_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;siren&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_error&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not a valid SIREN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&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="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;  &lt;span class="c1"&gt;# rejected before any HTTP call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The MCP layer is tested through a real client.&lt;/strong&gt; &lt;code&gt;Client(server)&lt;/code&gt; connects in-process, so &lt;code&gt;list_tools&lt;/code&gt; and &lt;code&gt;call_tool&lt;/code&gt; go through the SDK exactly as a host would: schema generation, validation, structured output, error wrapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stdio launch is tested twice&lt;/strong&gt;: once by spawning &lt;code&gt;python -m french_company_data&lt;/code&gt; with the SDK's stdio client, and once with a hand-written JSON-RPC exchange (&lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;notifications/initialized&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;) over the process pipes. That second test catches a classic stdio bug: anything printed to stdout that isn't protocol. It's also why &lt;code&gt;main()&lt;/code&gt; turns down &lt;code&gt;httpx&lt;/code&gt; logging before starting.&lt;/p&gt;

&lt;p&gt;One fixture needed care: the insolvency example comes from a real BODACC notice about a small company. The recording script keeps the structure and wording but replaces the identity with a fictitious one (&lt;code&gt;SAMPLE FLOORING SARL&lt;/code&gt;, SIREN 732829320) and redacts the name of the court-appointed person. The search fixtures also drop sole proprietorships, which are named after private individuals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugging it into Claude Desktop or another client
&lt;/h2&gt;

&lt;p&gt;It's a stdio server, so any MCP host that can launch a command works. For Claude Desktop, add this to &lt;code&gt;claude_desktop_config.json&lt;/code&gt; (Settings &amp;gt; Developer &amp;gt; Edit Config) and restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"french-company-data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uvx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--from"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"git+https://github.com/Kamelyoul/mcp-french-company-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp-french-company-data"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same block works in &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; for Cursor; the README has the Claude Code one-liner.&lt;/p&gt;

&lt;p&gt;To be precise about what's verified: the tests launch the server over stdio the way these hosts do, and &lt;code&gt;examples/demo_client.py&lt;/code&gt; runs all three tools against the live APIs. No GUI client is part of the test suite.&lt;/p&gt;

&lt;p&gt;A prompt like &lt;em&gt;"Check the BODACC for SIREN 552032534: any insolvency proceedings?"&lt;/em&gt; gives the model &lt;code&gt;get_bodacc_announcements&lt;/code&gt;, which returns the notices newest first, an &lt;code&gt;insolvency_in_results&lt;/code&gt; flag, and each notice's &lt;code&gt;bodacc.fr&lt;/code&gt; URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits (the real ones)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not the full Sirene database.&lt;/strong&gt; Companies that opted out of public listing are not returned by the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freshness.&lt;/strong&gt; Data can lag the official registries by a few days. BODACC notices for sole proprietors are sometimes not linked to a SIREN and can be missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-process state.&lt;/strong&gt; The rate limiter and the cache live in memory, in one process. Two clients running two copies of the server each get their own budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stdio only.&lt;/strong&gt; No HTTP transport, no authentication: it runs locally, next to the assistant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial code tables.&lt;/strong&gt; Only 10 legal forms get a readable label; the rest come back as raw INSEE codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint risk.&lt;/strong&gt; The BODACC OpenDataSoft URL is the one documented on data.gouv.fr today. If DILA moves it, it's one constant to change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not legal or financial advice.&lt;/strong&gt; For a credit decision or a claim deadline, open the official notice; every result links to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Attribution
&lt;/h2&gt;

&lt;p&gt;Data: API Recherche d'entreprises (DINUM, INSEE Sirene, RNE) and BODACC (DILA), both under Licence Ouverte 2.0. Every tool response carries that attribution in a &lt;code&gt;source&lt;/code&gt; field. The project isn't affiliated with these administrations.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/Kamelyoul/mcp-french-company-data" rel="noopener noreferrer"&gt;github.com/Kamelyoul/mcp-french-company-data&lt;/a&gt;. If you need a custom MCP server for your own API or data, I take on small projects &lt;a href="https://www.fiverr.com/rdch76" rel="noopener noreferrer"&gt;on Fiverr&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I built in-browser animated subtitles with Whisper on WebGPU (no upload)</title>
      <dc:creator>Kamelyoul</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:37:41 +0000</pubDate>
      <link>https://dev.to/kamelyoul/how-i-built-in-browser-animated-subtitles-with-whisper-on-webgpu-no-upload-4bii</link>
      <guid>https://dev.to/kamelyoul/how-i-built-in-browser-animated-subtitles-with-whisper-on-webgpu-no-upload-4bii</guid>
      <description>&lt;p&gt;Most "auto captions" tools start the same way: upload your video, wait, download the result. I wanted the opposite for short vertical videos (TikTok, Reels, Shorts): &lt;strong&gt;the file never leaves the browser&lt;/strong&gt;. Transcription, editing, rendering and export all run on the user's machine.&lt;/p&gt;

&lt;p&gt;The result is a small static site: &lt;a href="https://sous-titres.pages.dev/?ref=devto" rel="noopener noreferrer"&gt;sous-titres.pages.dev&lt;/a&gt; (the UI is in French, but it transcribes 8 languages). This post walks through the architecture, the real code, and the limits I hit.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Decode the audio track in the page (&lt;code&gt;AudioContext&lt;/code&gt; at 16 kHz, mixed down to mono).&lt;/li&gt;
&lt;li&gt;Send the samples to a &lt;strong&gt;Web Worker&lt;/strong&gt; running Whisper through &lt;a href="https://huggingface.co/docs/transformers.js" rel="noopener noreferrer"&gt;transformers.js&lt;/a&gt;, on &lt;strong&gt;WebGPU&lt;/strong&gt; if available, &lt;strong&gt;WASM&lt;/strong&gt; otherwise.&lt;/li&gt;
&lt;li&gt;Turn word-level timestamps into short caption segments (pure functions, unit-tested in Node).&lt;/li&gt;
&lt;li&gt;Draw every frame on a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;: source video + captions where the current word lights up.&lt;/li&gt;
&lt;li&gt;Record the canvas plus the original audio with &lt;strong&gt;MediaRecorder&lt;/strong&gt; and hand the user a file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No backend touches the media. The only server code is a tiny Cloudflare Pages Function for anonymous counters and license checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Whisper in a worker, WebGPU first
&lt;/h2&gt;

&lt;p&gt;The worker picks a device and a precision. WebGPU gets fp16 for the encoder when the adapter supports &lt;code&gt;shader-f16&lt;/code&gt;, and a 4-bit decoder; the WASM fallback uses 8-bit weights for both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detecterWebGPU&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;gpu&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;adaptateur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestAdapter&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="nx"&gt;adaptateur&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;ok&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="na"&gt;fp16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;adaptateur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shader-f16&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webgpu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wasm&lt;/span&gt;&lt;span class="dl"&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;dtype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;encoder_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fp16&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fp16&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fp32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder_model_merged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q4&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;encoder_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decoder_model_merged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q8&lt;/span&gt;&lt;span class="dl"&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 models are offered: &lt;code&gt;onnx-community/whisper-base_timestamped&lt;/code&gt; (~90 MB) and &lt;code&gt;whisper-small_timestamped&lt;/code&gt; (~250 MB). The &lt;code&gt;_timestamped&lt;/code&gt; exports matter: they expose the cross-attention needed for &lt;code&gt;return_timestamps: "word"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;navigator.gpu&lt;/code&gt; existing is not enough: an adapter can be returned and the pipeline can still fail to build on it. So creation is wrapped and retried in WASM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;transcripteur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt; &lt;span class="p"&gt;}&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;obtenirTranscripteur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modele&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`WebGPU indisponible (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), passage en mode compatible.`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;transcripteur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt; &lt;span class="p"&gt;}&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;obtenirTranscripteur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modele&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a second fallback: if word-level timestamps throw, the worker asks for sentence-level timestamps and the UI spreads the words across each sentence, weighted by length.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. From Whisper chunks to captions
&lt;/h2&gt;

&lt;p&gt;Whisper's word output is messier than it looks. Tokens sometimes arrive split (&lt;code&gt;" micro"&lt;/code&gt; + &lt;code&gt;"-entrepreneur."&lt;/code&gt;), and noise markers like &lt;code&gt;[Musique]&lt;/code&gt; show up. The rule I settled on: a chunk that does not start with a space belongs to the previous word.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;precedent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brut&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[\[&lt;/span&gt;&lt;span class="sr"&gt;(&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texte&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;colle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;precedent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;texte&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;texte&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;precedent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fin&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="nx"&gt;precedent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;mots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;texte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;debut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fin&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;Segmentation is a greedy pass with a few cut conditions: max words per line (user setting, 1 to 5), max 22 characters, a pause longer than 0.6 s, end of sentence, or a comma once the line has two words. Each segment stays on screen 0.3 s after its last word, without ever overlapping the next one. All of this lives in a DOM-free module, so it runs under &lt;code&gt;node --test&lt;/code&gt; and also generates the &lt;code&gt;.srt&lt;/code&gt; and the &lt;code&gt;.ass&lt;/code&gt; karaoke (&lt;code&gt;\k&lt;/code&gt; tags) exports.&lt;/p&gt;

&lt;p&gt;When the user fixes a typo, the segment keeps its start and end times and the new words are spread inside them. Small, but it means editing never breaks the sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rendering: one function, three styles
&lt;/h2&gt;

&lt;p&gt;The same &lt;code&gt;dessinerSousTitres(ctx, segments, t, settings)&lt;/code&gt; draws the live preview and the export. It measures each word, wraps at 86% of the 1080 px width, then draws word by word with a thick black stroke, a soft shadow, and a short "pop" on the active word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mots&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;debut&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;pop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;estActif&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="mf"&gt;0.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;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="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.18&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&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;translate&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="nx"&gt;w&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="nx"&gt;y&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;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three styles are just branches in that loop: highlight the active word's color, draw a rounded box behind it, or reveal words only once they've been spoken. The font (Montserrat 900) is self-hosted and preloaded, and the app awaits &lt;code&gt;document.fonts.load()&lt;/code&gt; before the first draw. Otherwise the first frames get measured with a fallback font.&lt;/p&gt;

&lt;p&gt;Because this module only needs a 2D context, I reused it outside the browser: the promo clip for the tool was rendered in Node with &lt;code&gt;@napi-rs/canvas&lt;/code&gt;, the same &lt;code&gt;rendu.js&lt;/code&gt;, and transformers.js on CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Export: canvas + MediaRecorder
&lt;/h2&gt;

&lt;p&gt;The export plays the video in a hidden &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;, redraws each frame on requestAnimationFrame, and records &lt;code&gt;canvas.captureStream(30)&lt;/code&gt; plus the original audio routed through &lt;code&gt;createMediaElementSource&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/mp4;codecs=avc1.640028,mp4a.40.2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/mp4;codecs=avc1,mp4a.40.2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/webm;codecs=vp9,opus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/webm;codecs=vp8,opus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/webm&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;candidats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isTypeSupported&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;))&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent Chrome, Edge and Safari produce MP4; Firefox produces WebM. The honest trade-off: &lt;strong&gt;export runs in real time&lt;/strong&gt; (60 s of video takes about 60 s) and the tab must stay visible, because background tabs throttle rAF. WebCodecs plus a JS muxer would allow faster-than-real-time export and more consistent output. That's the next step, and it isn't shipped yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The deployment surprise: Hugging Face and &lt;code&gt;*.workers.dev&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I first served the app from a &lt;code&gt;*.workers.dev&lt;/code&gt; subdomain, and the model never loaded there: model file requests answered &lt;strong&gt;404&lt;/strong&gt; whenever the &lt;code&gt;Referer&lt;/code&gt; was a &lt;code&gt;workers.dev&lt;/code&gt; origin. Same files, same code, different referrer.&lt;/p&gt;

&lt;p&gt;Rather than fight it, I moved the static app to &lt;strong&gt;Cloudflare Pages&lt;/strong&gt; (&lt;code&gt;*.pages.dev&lt;/code&gt;), where the model loads normally, and left a one-line Worker on the old address that answers &lt;code&gt;301&lt;/code&gt; to the new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CIBLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://sous-titres.pages.dev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requete&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;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requete&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CIBLE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: if your app fetches third-party assets client-side, test from the real production origin early. The &lt;code&gt;Referer&lt;/code&gt; your users send is part of your dependency surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy, verifiable
&lt;/h2&gt;

&lt;p&gt;"No upload" is easy to claim, so the FAQ tells users how to check it: open DevTools → Network, run a transcription, and you'll see only the model (Hugging Face), the library (jsDelivr) and anonymous event counters (a visit, an export). No cookies, no third-party analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits (the real ones)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desktop Chrome/Edge recommended&lt;/strong&gt; (that's where I tested it). Firefox and Safari aren't tested yet; without WebGPU the app falls back to WASM, which is slower. Phones can run out of memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First run downloads the model&lt;/strong&gt; (~90 MB or ~250 MB), then it's cached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed depends on hardware.&lt;/strong&gt; With WebGPU, a one-minute clip usually transcribes in under a minute. Without it, expect several minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whisper makes mistakes&lt;/strong&gt; on names, numbers and acronyms. The editor exists for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-form only.&lt;/strong&gt; Beyond 3–5 minutes, browser memory becomes the bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time export&lt;/strong&gt;, as described above.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;a href="https://sous-titres.pages.dev/?ref=devto" rel="noopener noreferrer"&gt;sous-titres.pages.dev&lt;/a&gt;: free, no account, with a small watermark on exported videos. A one-time paid license removes it. Feedback on the WebGPU path, especially from AMD and Intel GPUs, is very welcome in the comments.&lt;/p&gt;

</description>
      <category>webgpu</category>
      <category>javascript</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Look Up: an open-weight model on your phone reads the clouds, then tells you to put the phone away</title>
      <dc:creator>Kamelyoul</dc:creator>
      <pubDate>Tue, 06 Oct 2026 23:13:36 +0000</pubDate>
      <link>https://dev.to/kamelyoul/look-up-an-open-weight-model-on-your-phone-reads-the-clouds-then-tells-you-to-put-the-phone-away-10og</link>
      <guid>https://dev.to/kamelyoul/look-up-an-open-weight-model-on-your-phone-reads-the-clouds-then-tells-you-to-put-the-phone-away-10og</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Look Up&lt;/strong&gt; is a tiny web app for cloud spotting.&lt;/p&gt;

&lt;p&gt;You point your phone at the sky and take one photo. An open-weight vision model, running &lt;strong&gt;on the phone itself&lt;/strong&gt;, tells you which of the ten cloud families you're looking at, what that cloud usually means for the next few hours, and gives you one small thing to watch for. Then it asks you to put the phone away.&lt;/p&gt;

&lt;p&gt;That last part is the whole point. The best thing about clouds is that they &lt;em&gt;move&lt;/em&gt;: a cumulus grows or evaporates in five minutes, a contrail vanishes or spreads, a halo appears around the sun. You can only see that if you stop looking at a screen. So every reading ends with a mission:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cumulus&lt;/strong&gt;: &lt;em&gt;Pick one cloud and watch it for five minutes. Is it growing upward, or fraying at the edges and evaporating?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cirrus&lt;/strong&gt;: &lt;em&gt;Pick one wisp and watch which way its tail is combed. That is the wind direction kilometres above you.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You tap &lt;strong&gt;Put the phone away&lt;/strong&gt;. The screen goes black and counts the time until you come back (you can lock the phone). When you return, you tap what the cloud did ("It grew", "It faded", "It moved on"), and the app keeps a small &lt;strong&gt;sky log&lt;/strong&gt; with one number I care about: &lt;strong&gt;how many seconds you spent looking up for every second you spent looking at the screen.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's for anyone who has ever looked up and wondered "what &lt;em&gt;is&lt;/em&gt; that?", but especially for people who reach for the phone out of habit. This time, the phone sends you back to the sky.&lt;/p&gt;

&lt;p&gt;And when it sees a &lt;strong&gt;cumulonimbus&lt;/strong&gt;, there is no timer. The mission is "go indoors".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1f66042dg655qbuzdo8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1f66042dg655qbuzdo8.jpg" alt="Look Up home screen" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://look-up-sky-smoky.vercel.app" rel="noopener noreferrer"&gt;https://look-up-sky-smoky.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open it on your phone, tap &lt;strong&gt;Get ready for no signal&lt;/strong&gt; once on Wi-Fi (it downloads the model, about 90 MB), then go outside and tap &lt;strong&gt;Read the sky&lt;/strong&gt;. No account and no install (you can add it to your home screen if you like).&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Kamelyoul" rel="noopener noreferrer"&gt;
        Kamelyoul
      &lt;/a&gt; / &lt;a href="https://github.com/Kamelyoul/look-up" rel="noopener noreferrer"&gt;
        look-up
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Look Up — point your phone at the sky, learn the cloud, then put the phone away. On-device CLIP, works offline.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Look Up&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Point your phone at the sky. An open-weight model running on your phone names the clouds, tells you what they usually mean, and gives you a small thing to watch for. Then you put the phone away.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05" rel="nofollow"&gt;DEV Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass&lt;/a&gt; (October 5 to 11, 2026). The project and this repository were created during the challenge window.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the sky&lt;/strong&gt;: take a photo of the sky (or pick one you already took).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/openai/clip-vit-base-patch16" rel="nofollow noopener noreferrer"&gt;CLIP ViT-B/16&lt;/a&gt; (open weights, MIT) runs &lt;strong&gt;in the browser&lt;/strong&gt; through &lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;Transformers.js&lt;/a&gt; and ONNX Runtime Web. It classifies the photo zero-shot into the 10 WMO cloud genera, plus &lt;em&gt;contrail&lt;/em&gt;, &lt;em&gt;clear sky&lt;/em&gt; and &lt;em&gt;not the sky&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;You get a card with what to check with your own eyes, what that cloud usually means for the next hours (hedged weather lore), and a &lt;strong&gt;mission&lt;/strong&gt;…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Kamelyoul/look-up" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Vanilla TypeScript and Vite, with no framework and no backend. MIT licensed. &lt;code&gt;npm test&lt;/code&gt; runs the 34 unit tests; &lt;code&gt;npm run eval&lt;/code&gt; reproduces the accuracy numbers below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The model: CLIP, zero-shot, in the browser
&lt;/h3&gt;

&lt;p&gt;The core is &lt;a href="https://huggingface.co/openai/clip-vit-base-patch16" rel="noopener noreferrer"&gt;OpenAI's CLIP ViT-B/16&lt;/a&gt;, an open-weight (MIT) image–text model. I run it with &lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;Transformers.js&lt;/a&gt; on top of ONNX Runtime Web (WebAssembly), using &lt;a href="https://huggingface.co/Xenova/clip-vit-base-patch16" rel="noopener noreferrer"&gt;Xenova's ONNX export&lt;/a&gt; at 8-bit.&lt;/p&gt;

&lt;p&gt;CLIP was never trained to classify clouds. It doesn't need to be: it maps images and sentences into the same space, so I describe each class in plain English and pick the closest description. Each of the 13 classes (the ten &lt;a href="https://cloudatlas.wmo.int/en/clouds-genera.html" rel="noopener noreferrer"&gt;WMO cloud genera&lt;/a&gt;, plus &lt;em&gt;contrail&lt;/em&gt;, &lt;em&gt;clear sky&lt;/em&gt; and &lt;em&gt;not the sky&lt;/em&gt;) gets 3 to 6 prompts like:&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="nx"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a photo of cumulus clouds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fluffy white cotton-ball cumulus clouds with flat bases in a blue sky&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puffy cauliflower-shaped white clouds on a sunny day&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The descriptive prompts matter. CLIP has seen "fluffy white clouds" millions of times and "stratocumulus" far less.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trick: the text half never ships
&lt;/h3&gt;

&lt;p&gt;CLIP has two towers: one for text and one for images. The prompts are fixed, so I run the &lt;strong&gt;text tower once, at build time, in Node&lt;/strong&gt;, and ship only the 43 resulting vectors: a 120 KB JSON file. The phone downloads only the &lt;strong&gt;vision&lt;/strong&gt; tower. That saves a download of tens of MB and makes classification a dot product:&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="c1"&gt;// score of a class = its best-matching prompt (max, not mean)&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;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dot&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="nx"&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;dim&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="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;dot&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;img&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dim&lt;/span&gt; &lt;span class="o"&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;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&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;dot&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// then softmax(100 * score) over the 13 classes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Max, not mean.&lt;/strong&gt; The "not the sky" class mixes very different things (grass, a room, a screen, a street). Averaging over them gives a blurry "average non-sky" that matches nothing well. So a class scores as its &lt;em&gt;best-matching&lt;/em&gt; prompt. On my test set, that rejected 22 of 24 non-sky photos, against 18 with averaging. Point it at your feet and it tells you to point it up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's allowed to be unsure.&lt;/strong&gt; If the top class is under 45%, the card says &lt;em&gt;"Altocumulus, or maybe Cirrocumulus — I'm torn"&lt;/em&gt;. Real skies often contain several genera at once, and I'd rather the app say so than bluff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storms get special treatment.&lt;/strong&gt; If "cumulonimbus" is the top guess, the runner-up, or simply above 10%, the card adds a red line: &lt;em&gt;"This could also be a thunderstorm cloud. If it is tall and dark, or you hear thunder, go indoors instead."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Did it actually work? I measured it.
&lt;/h3&gt;

&lt;p&gt;Zero-shot demos are easy to cherry-pick, so I built a small, reproducible test set from Wikimedia Commons: the first 12 JPEG photos in each cloud category whose file name names only that genus (132 sky photos), plus 24 photos of lawns and living rooms that the app should refuse. The evaluation script uses &lt;strong&gt;the exact code path the browser uses&lt;/strong&gt;: same weights, same prompts, same scoring.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (CLIP ViT-B/16, 8-bit)&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top-1 accuracy, 13 classes (chance ≈ 7.7%)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;43%&lt;/strong&gt; (57/132)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correct class in the top 3&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;68%&lt;/strong&gt; (90/132)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy when the app says it's confident&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;69%&lt;/strong&gt; (25/36)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contrails&lt;/td&gt;
&lt;td&gt;11/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thunderstorm photos that get the storm warning&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9/12&lt;/strong&gt; (10 false alarms on 120 other skies)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sky photos wrongly rejected as "not the sky"&lt;/td&gt;
&lt;td&gt;0/132&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lawns and living rooms correctly rejected&lt;/td&gt;
&lt;td&gt;22/24&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's good at the distinctive skies (contrails, cumulus, the altocumulus "sheep"). It's honestly bad at the subtle ones: cirrostratus (2/12) and stratocumulus (1/12) mostly come back as something else. That's why the app shows its top guesses and phrases everything as "check with your own eyes". It's a field guide that nudges you to look, not an oracle. (Caveat: Commons labels are crowd-sourced, and many photos contain more than one genus.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choosing the model with the numbers.&lt;/strong&gt; I also ran the smaller-patch sibling, CLIP ViT-B/32. It's twice as fast and &lt;em&gt;more&lt;/em&gt; precise when confident (84% vs 69%). But it caught only 5 of 12 thunderstorm photos where B/16 caught 9, and it raised more false alarms (16 vs 10). For an app that tells people when to go indoors, that settled it. I shipped B/16 and accepted the extra ~200 ms. The full per-image tables are in the repo (&lt;code&gt;eval/&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  The rest
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline&lt;/strong&gt;: a small service worker caches the app shell. Transformers.js stores the weights and the WASM runtime in the browser's Cache Storage, so after the first load it works in airplane mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Phone away" screen&lt;/strong&gt;: it's black, has a big counter and nothing else. Time is measured from timestamps, so it keeps counting if you lock the phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sky log&lt;/strong&gt;: localStorage only. Nothing leaves the device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests&lt;/strong&gt;: 34 unit tests (Vitest) cover the scoring and the base64 embedding format. A test fails if someone edits the prompts without regenerating the embeddings. Another checks that the thunderstorm card can never offer a "watch it" timer. A GitHub Actions workflow runs the tests and the build on every push.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this app, open weights aren't a nice-to-have. Three things only work because of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The sky is where the signal isn't.&lt;/strong&gt; Hills, beaches, fields and trails are exactly where you have one bar or none. A cloud app that calls a hosted vision API fails in the very place it's meant to be used. With open weights, the model is a file. The browser caches it and the app works in airplane mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Your photos stay yours.&lt;/strong&gt; Sky photos contain more than sky: rooftops, your street, sometimes your kids at the edge of the frame. Here, nothing is uploaded, because there's no server to upload to. I didn't have to write a privacy policy for a server I'd have to trust; there just isn't one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It costs nothing to run, so it can stay free.&lt;/strong&gt; There is no API key, no per-request bill and no rate limit. The whole thing is a static site. If a thousand people use it on a sunny Saturday, my cost doesn't change.&lt;/p&gt;

&lt;p&gt;There's a fourth, more personal reason: &lt;strong&gt;I could open it up and change it.&lt;/strong&gt; Because the model is open, I could split it in two, run half of it at build time, and ship only the half the phone needs. You can't do that with a closed endpoint. The classes are just sentences in a TypeScript file too. Want to add &lt;em&gt;mammatus&lt;/em&gt; or &lt;em&gt;noctilucent clouds&lt;/em&gt;? Add a line, run &lt;code&gt;npm run embeddings&lt;/code&gt;, and redeploy. No retraining, no permission needed.&lt;/p&gt;

&lt;p&gt;Where closed models would win: a large hosted multimodal model would likely be more accurate on the hard pairs (altocumulus vs. cirrocumulus). But it would need a signal, see your photos, and cost money per look. For an app whose job is to get you &lt;em&gt;off&lt;/em&gt; the screen and &lt;em&gt;out&lt;/em&gt; where there's no Wi-Fi, a small model you can carry beats a big one you can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;Full disclosure: I built this with an AI coding agent (&lt;strong&gt;Claude Code&lt;/strong&gt;). It wrote most of the code, the tests and the evaluation scripts, and helped draft this post. Every number above comes from scripts in the repo that you can rerun (&lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;npm run eval&lt;/code&gt;), not from the agent's say-so. I don't have a DevRelay recording of the session.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
