<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://zackdesign.biz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zackdesign.biz/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-05-28T12:07:06+00:00</updated><id>https://zackdesign.biz/feed.xml</id><title type="html">Zack Design</title><subtitle>Software engineering, web development, and digital solutions by industry experts</subtitle><author><name>Isaac Rowntree</name><email>isaac@zackdesign.biz</email></author><entry><title type="html">Offline maps that look like 2026, not 2013 — a vector→raster MBTiles pipeline</title><link href="https://zackdesign.biz/offline-maps-vector-raster-mbtiles/" rel="alternate" type="text/html" title="Offline maps that look like 2026, not 2013 — a vector→raster MBTiles pipeline" /><published>2026-05-28T00:00:00+00:00</published><updated>2026-05-28T00:00:00+00:00</updated><id>https://zackdesign.biz/offline-maps-vector-raster-mbtiles</id><content type="html" xml:base="https://zackdesign.biz/offline-maps-vector-raster-mbtiles/"><![CDATA[<p>Most “offline maps” tutorials route you through one of two corners. <strong>Corner A</strong> is a 2013-era Mapnik stack rendering OSM-Carto — beautiful in its day, but the day is over. <strong>Corner B</strong> is a paid Mapbox or MapTiler subscription that solves the aesthetic problem and bills you for the privilege. There’s a third corner that no tutorial walks you to: a fully self-hosted vector-to-raster pipeline using modern open-source tools, producing tiles that look like Mapbox or Apple Maps, hosted on object storage with free egress.</p>

<p>This is the pipeline I built to ship offline basemaps for <a href="https://campermate.com">CamperMate</a> — the go-to free-camping and campground app across Australia and New Zealand, <a href="https://apps.apple.com/app/campermate/id578975305">iOS</a> and <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a>, 1M+ downloads, made at <a href="https://triptechtravel.com">Triptech Travel</a>. Users are in Fiordland, Kakadu, the Pilbara, the Tasmanian highlands. Cell coverage is a luxury, not a baseline. If the map doesn’t work without bars, the app doesn’t work. The pipeline below is platform-agnostic — the output is a <code class="language-plaintext highlighter-rouge">.mbtiles</code> SQLite file that any client can read. I’ll walk through how it ships in a React Native consumer at the end, but the pipeline itself is independent of where the bytes are rendered.</p>

<!-- more -->

<h2 id="the-wrong-path-mapnik-with-osm-carto">The wrong path: Mapnik with OSM-Carto</h2>

<p>The first thing every “offline OSM tiles” guide tells you is to spin up <a href="https://hub.docker.com/r/overv/openstreetmap-tile-server/"><code class="language-plaintext highlighter-rouge">overv/openstreetmap-tile-server</code></a> — a Docker container with <code class="language-plaintext highlighter-rouge">osm2pgsql</code>, PostGIS, and Mapnik rendering the canonical <code class="language-plaintext highlighter-rouge">openstreetmap-carto</code> style. That’s what powers <code class="language-plaintext highlighter-rouge">openstreetmap.org</code>.</p>

<p>I tried it. The pipeline works, but the output looks like 2013. Olive landuse fills, mustard buildings, brick-coloured motorways, that classic OSM look that every modern map product has moved on from. It’s not what people expect when they tap “offline maps” in 2026.</p>

<p>It’s also a <em>single-stage</em> pipeline that’s deceptively hard to evolve. Want to tweak the aesthetic? You’re editing CartoCSS and re-baking from PostGIS. Want a different style entirely? You’re rebuilding the whole stack. Mapnik is excellent at what it does, but what it does is render in a tradition that no longer matches what mobile users see daily on Apple Maps and Google Maps.</p>

<h2 id="the-right-path-vector--raster-with-maplibre-gl-native">The right path: vector → raster with MapLibre GL Native</h2>

<p>The trick is to <strong>split rendering from data extraction</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 (one-time per region)              (per style)
                       ↓                                ↓
   OSM PBF  →  planetiler  →  vector MBTiles  →  tileserver-gl  →  raster MBTiles
                                  (single source of truth)            (re-renderable
                                                                       any time)
</code></pre></div></div>

<p>Two stages. The vector MBTiles is a <em>neutral</em> intermediate — same data, no styling. The raster MBTiles is what your app loads. You can re-render the raster in any MapLibre GL style — Positron, OSM Bright, Voyager, Dark Matter, a custom one — without touching the data pipeline.</p>

<p>Stage 1 (<code class="language-plaintext highlighter-rouge">planetiler</code>) is <strong>minutes</strong>. Stage 2 (<code class="language-plaintext highlighter-rouge">tileserver-gl</code>) is <strong>CPU-bound rendering</strong> — minutes for a city, hours for a country. Both run from Docker, both are open source, neither requires a third-party API key.</p>

<p>The rendering engine is <a href="https://github.com/maplibre/maplibre-native">MapLibre GL Native</a>, the same C++ engine that powers Mapbox GL JS and MapLibre GL JS on the web. That’s why the output looks identical to a modern web map — because it <em>is</em> a modern web map, rendered offline.</p>

<h2 id="the-tools">The tools</h2>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Job</th>
      <th>License</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://download.geofabrik.de">Geofabrik</a></td>
      <td>OSM PBF source data, per country and per state</td>
      <td>ODbL</td>
    </tr>
    <tr>
      <td><a href="https://osmcode.org/osmium-tool/"><code class="language-plaintext highlighter-rouge">osmium-tool</code></a></td>
      <td>Slice country PBFs into city/region bboxes</td>
      <td>GPL-3</td>
    </tr>
    <tr>
      <td><a href="https://github.com/onthegomap/planetiler"><code class="language-plaintext highlighter-rouge">planetiler</code></a></td>
      <td>OSM PBF → vector MBTiles (OpenMapTiles schema)</td>
      <td>Apache-2</td>
    </tr>
    <tr>
      <td><a href="https://github.com/maptiler/tileserver-gl"><code class="language-plaintext highlighter-rouge">tileserver-gl</code></a></td>
      <td>Vector MBTiles + GL style → raster PNG via MapLibre GL Native</td>
      <td>BSD-2</td>
    </tr>
    <tr>
      <td><a href="https://developers.google.com/speed/webp"><code class="language-plaintext highlighter-rouge">cwebp</code></a> (libwebp)</td>
      <td>Re-encode rendered PNGs to WebP q80 — ~5× smaller (see below)</td>
      <td>BSD</td>
    </tr>
    <tr>
      <td><a href="https://github.com/openmaptiles/fonts">OpenMapTiles fonts</a></td>
      <td>Pre-built glyph PBFs for label rendering</td>
      <td>OFL</td>
    </tr>
    <tr>
      <td><a href="https://github.com/openmaptiles">OpenMapTiles styles</a></td>
      <td>Free MapLibre GL styles (Positron, OSM Bright, Dark Matter)</td>
      <td>BSD-3</td>
    </tr>
  </tbody>
</table>

<p>All free. No keys. No bills. The whole stack runs on a MacBook.</p>

<h2 id="the-build-script">The build script</h2>

<p>The CamperMate offline-tiles build script is ~200 lines of bash that wires those tools together. Inputs: a region name, a Geofabrik path, a bbox, a max-zoom, a style name. Output: a single <code class="language-plaintext highlighter-rouge">.mbtiles</code> file ready to upload.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># NZ South Island, z0–15, rendered in OSM Bright</span>
scripts/build-offline-tiles.sh nz-south <span class="se">\</span>
  australia-oceania/new-zealand <span class="se">\</span>
  <span class="s1">'166.4,-47.3,174.5,-40.4'</span> <span class="se">\</span>
  15 <span class="se">\</span>
  osm-bright
</code></pre></div></div>

<p>The pipeline:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Cache the source PBF (one-time per Geofabrik region)</span>
curl <span class="nt">-fL</span> <span class="nt">-o</span> offline-tiles/pbf/nz.osm.pbf <span class="se">\</span>
  https://download.geofabrik.de/australia-oceania/new-zealand-latest.osm.pbf

<span class="c"># 2. Slice by bbox (skipped when Geofabrik already has per-state PBFs)</span>
osmium extract <span class="nt">--bbox</span><span class="o">=</span>166.4,-47.3,174.5,-40.4 <span class="nt">--strategy</span><span class="o">=</span>smart <span class="nt">--set-bounds</span> <span class="se">\</span>
  <span class="nt">-o</span> offline-tiles/pbf/nz-south-extract.osm.pbf <span class="se">\</span>
  offline-tiles/pbf/nz.osm.pbf

<span class="c"># 3. Generate vector MBTiles with planetiler (OpenMapTiles schema)</span>
docker run <span class="nt">--rm</span> <span class="nt">-e</span> <span class="nv">JAVA_TOOL_OPTIONS</span><span class="o">=</span><span class="s2">"-Xmx4g"</span> <span class="nt">-v</span> offline-tiles:/data <span class="se">\</span>
  ghcr.io/onthegomap/planetiler:latest <span class="se">\</span>
    <span class="nt">--osm_path</span><span class="o">=</span>/data/pbf/nz-south-extract.osm.pbf <span class="se">\</span>
    <span class="nt">--mbtiles</span><span class="o">=</span>/data/nz-south-vector.mbtiles <span class="se">\</span>
    <span class="nt">--bounds</span><span class="o">=</span>166.4,-47.3,174.5,-40.4 <span class="nt">--maxzoom</span><span class="o">=</span>15 <span class="nt">--download</span> <span class="nt">--force</span>

<span class="c"># 4. Render vector → raster via tileserver-gl</span>
docker run <span class="nt">-d</span> <span class="nt">--name</span> tileserver-gl-nz-south <span class="nt">-p</span> 8765:8080 <span class="se">\</span>
  <span class="nt">-v</span> offline-tiles:/data <span class="se">\</span>
  maptiler/tileserver-gl:latest <span class="se">\</span>
    <span class="nt">-c</span> /data/tileserver-config-nz-south.json

<span class="c"># 5. curl-loop every tile in the bbox; pack into raster MBTiles</span>
python3 render_and_pack.py nz-south <span class="s1">'166.4,-47.3,174.5,-40.4'</span> 15
</code></pre></div></div>

<p>A few non-obvious things that took me a day to learn:</p>

<ul>
  <li><strong>Planetiler needs Java 21+.</strong> If you have Zulu 17 installed for Android dev you’ll see <code class="language-plaintext highlighter-rouge">UnsupportedClassVersionError: class file version 65.0</code>. The Docker image avoids the JDK juggle.</li>
  <li><strong>OpenMapTiles styles ship with Maptiler-hosted source URLs.</strong> The default Positron <code class="language-plaintext highlighter-rouge">style.json</code> points at <code class="language-plaintext highlighter-rouge">api.maptiler.com</code> and needs a key. Rewrite <code class="language-plaintext highlighter-rouge">sources.openmaptiles.url</code> to <code class="language-plaintext highlighter-rouge">mbtiles://{openmaptiles}</code> and let tileserver-gl resolve it from the local MBTiles: <code class="language-plaintext highlighter-rouge">jq '.sources.openmaptiles = { type: "vector", url: "mbtiles://{openmaptiles}" }' positron.json &gt; positron.local.json</code>.</li>
  <li><strong>Fonts are not in the <code class="language-plaintext highlighter-rouge">openmaptiles/fonts</code> master branch.</strong> Master ships only TTF sources. The pre-built PBF glyph ranges are in the <a href="https://github.com/openmaptiles/fonts/releases/tag/v2.0">v2.0 release asset</a>. Without them tileserver-gl 500s on every tile containing a label, which is everything past z4.</li>
  <li><strong>Planetiler writes <code class="language-plaintext highlighter-rouge">bounds</code> and <code class="language-plaintext highlighter-rouge">center</code> metadata that breaks MapLibre GL Native.</strong> Strip them after planetiler runs: <code class="language-plaintext highlighter-rouge">sqlite3 *.mbtiles "DELETE FROM metadata WHERE name IN ('bounds', 'center');"</code>.</li>
</ul>

<h2 id="shipping-it-in-a-react-native-app">Shipping it in a React Native app</h2>

<p>The pipeline above is platform-agnostic — the output is just an MBTiles file. Web clients can read it via MapLibre GL JS + the <a href="https://github.com/maplibre/maplibre-gl-mbtiles"><code class="language-plaintext highlighter-rouge">mbtiles</code> protocol plugin</a>. Native iOS / Android can read it via their SQLite stack directly. Flutter has <a href="https://docs.fleaflet.dev/"><code class="language-plaintext highlighter-rouge">flutter_map</code></a> with MBTiles plugins. The thing that needs care is <em>how</em> the consumer reads tile bytes from the archive.</p>

<p>For CamperMate’s React Native app, the existing map stack is <code class="language-plaintext highlighter-rouge">react-native-maps</code>, which wraps Google Maps (Android) and Apple MapKit (iOS). Both expose a <code class="language-plaintext highlighter-rouge">&lt;UrlTile&gt;</code> primitive — but it expects an HTTP URL template:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">UrlTile</span> <span class="na">urlTemplate</span><span class="p">=</span><span class="s">"https://server/{z}/{x}/{y}.png"</span> <span class="p">/&gt;</span>
</code></pre></div></div>

<p>That’s fine for online tile servers. For an offline <code class="language-plaintext highlighter-rouge">.mbtiles</code> archive, there are three obvious options, all bad:</p>

<ol>
  <li><strong>Pre-extract <code class="language-plaintext highlighter-rouge">{z}/{x}/{y}.png</code> files and use <code class="language-plaintext highlighter-rouge">&lt;LocalTile&gt;</code>.</strong> Loses MBTiles’ single-file storage win and doesn’t work over a CDN.</li>
  <li><strong>Run a localhost HTTP server inside the app</strong> that serves tiles from the archive on demand. Adds startup cost, port management, battery, and JS-bridge contention per tile.</li>
  <li><strong>Switch to MapLibre RN.</strong> Solves the problem natively. But it’s an entire map-view replacement, losing every line of code that touches markers, callouts, gesture handlers, and providers.</li>
</ol>

<p>The fourth option — and the one I shipped — is a <strong>small native patch</strong> to <code class="language-plaintext highlighter-rouge">react-native-maps</code> that teaches <code class="language-plaintext highlighter-rouge">&lt;UrlTile&gt;</code> to read tile bytes directly from an MBTiles SQLite file via a custom <code class="language-plaintext highlighter-rouge">mbtiles://</code> URL scheme. The JSX surface stays identical:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">UrlTile</span>
  <span class="na">urlTemplate</span><span class="p">=</span><span class="s">"mbtiles:///var/.../offline/nz-north.mbtiles"</span>
  <span class="na">maximumNativeZ</span><span class="p">=</span><span class="si">{</span><span class="mi">15</span><span class="si">}</span>
  <span class="na">maximumZ</span><span class="p">=</span><span class="si">{</span><span class="mi">18</span><span class="si">}</span>
  <span class="na">shouldReplaceMapContent</span>
<span class="p">/&gt;</span>
</code></pre></div></div>

<p>The patch is ~750 lines across iOS and Android, applied via <a href="https://github.com/ds300/patch-package"><code class="language-plaintext highlighter-rouge">patch-package</code></a>. It teaches <code class="language-plaintext highlighter-rouge">MapTileProvider</code> (Android) and <code class="language-plaintext highlighter-rouge">AIRMapUrlTile</code> (iOS) to detect the <code class="language-plaintext highlighter-rouge">mbtiles://</code> URL scheme and route to a new SQLite-backed tile reader instead of the HTTP path. The internals — connection caching, TMS y-flip, overzoom via in-memory parent-bitmap reuse — are a separate post. The user-facing surface is exactly the snippet above. I’ll open-source the patch once it’s been in production for a few weeks; <a href="https://github.com/react-native-maps/react-native-maps/issues/5863">issue #5863</a> tracks it.</p>

<h2 id="region-splits-and-zoom-levels">Region splits and zoom levels</h2>

<p>Two practical decisions shape the file layout. <strong>Where to split</strong> comes from how Geofabrik ships data: country-level PBFs for NZ (split by bbox into north/south islands with <code class="language-plaintext highlighter-rouge">osmium extract</code>), per-state PBFs for AU (no slicing needed). The split should also match how users travel — for a campervan app, per-island and per-state is the right grain because that’s what people fly between.</p>

<p><strong>How deep to render</strong> is the other consequential decision. Each zoom level quadruples tile count, and real-world size grows faster than the math suggests because inked tiles compress worse than empty ones. I shipped NZ at native z15 (full Apple-Maps-style detail: trail heads, suburb names, motorway shields, ~420 MB per island after the optimisations below). AU at native z14 with overzoom (the patch stretches the largest available tile up to z18) is a 4× saving across an entire continent — road names still readable, dense urban POI labels the only loss. The asymmetry is deliberate: NZ is small enough that z15 doesn’t blow up storage, AU isn’t.</p>

<h2 id="the-dedup-schema--50-savings-for-free">The dedup schema — 50% savings for free</h2>

<p>The MBTiles spec defines two acceptable schemas. The naive one — a single <code class="language-plaintext highlighter-rouge">tiles(zoom_level, tile_column, tile_row, tile_data)</code> table — is what most ad-hoc scripts produce. The normalised one trades a tiny bit of read complexity for <strong>massive</strong> storage savings:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">images</span> <span class="p">(</span><span class="n">tile_id</span> <span class="nb">TEXT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span> <span class="n">tile_data</span> <span class="nb">BLOB</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="k">map</span> <span class="p">(</span><span class="n">zoom_level</span><span class="p">,</span> <span class="n">tile_column</span><span class="p">,</span> <span class="n">tile_row</span><span class="p">,</span> <span class="n">tile_id</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">UNIQUE</span> <span class="k">INDEX</span> <span class="n">map_index</span> <span class="k">ON</span> <span class="k">map</span> <span class="p">(</span><span class="n">zoom_level</span><span class="p">,</span> <span class="n">tile_column</span><span class="p">,</span> <span class="n">tile_row</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">VIEW</span> <span class="n">tiles</span> <span class="k">AS</span>
  <span class="k">SELECT</span> <span class="k">map</span><span class="p">.</span><span class="n">zoom_level</span><span class="p">,</span> <span class="k">map</span><span class="p">.</span><span class="n">tile_column</span><span class="p">,</span> <span class="k">map</span><span class="p">.</span><span class="n">tile_row</span><span class="p">,</span> <span class="n">images</span><span class="p">.</span><span class="n">tile_data</span>
  <span class="k">FROM</span> <span class="k">map</span> <span class="k">JOIN</span> <span class="n">images</span> <span class="k">ON</span> <span class="n">images</span><span class="p">.</span><span class="n">tile_id</span> <span class="o">=</span> <span class="k">map</span><span class="p">.</span><span class="n">tile_id</span><span class="p">;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tile_id</code> is <code class="language-plaintext highlighter-rouge">sha1(tile_data)</code>. Identical tiles — every “pure ocean” tile, every patch of empty desert at mid-zoom, every uniform Southern Alps slope at z15 — collapse to one row in <code class="language-plaintext highlighter-rouge">images</code>, with many rows in <code class="language-plaintext highlighter-rouge">map</code> pointing at the same tile_id.</p>

<p>The <code class="language-plaintext highlighter-rouge">tiles</code> VIEW makes this completely transparent to consumers. Any <code class="language-plaintext highlighter-rouge">SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?</code> works identically against either schema.</p>

<p>The measured impact on NZ (PNG tiles, before the WebP step below):</p>

<ul>
  <li><strong>nz-north</strong>: 716,859 tiles → 1.9 GB on disk (51% saving over flat schema)</li>
  <li><strong>nz-south</strong>: 859,891 tiles → 242,217 unique blobs (<strong>71.8% tile dedup rate</strong>), 1.8 GB on disk (57% saving)</li>
</ul>

<p>Why so high? A region the size of New Zealand has <em>enormous</em> repetition at mid-zooms — endless ocean tiles, identical bush-cover tiles in the Fiordland interior, hundreds of identical “purple Southern Alps shading” tiles. Dense urban tiles (Auckland CBD) are all unique and don’t dedup, but they’re a small fraction of any region’s total tile count.</p>

<p>Hashing every tile during pack adds CPU but it’s microseconds per tile — invisible compared to the actual rendering time. Reading via the VIEW adds one indexed JOIN which doesn’t measurably affect tile-fetch latency on mobile.</p>

<h2 id="webp-not-png--another-5-shrink">WebP, not PNG — another ~5× shrink</h2>

<p>The next surprise was the format choice. PNG is what tileserver-gl emits and what every MBTiles tutorial uses, but PNG is the wrong codec for inked map tiles. Roads, anti-aliased coastlines, gradient hillshading, transparent green parks — none of it palettises well, which is what PNG’s compression relies on. WebP’s lossy mode is designed for exactly this kind of mixed graphical content.</p>

<p>I sampled 1,000 random unique tiles from the dedup-packed TAS archive and compressed each as PNG (baseline), <code class="language-plaintext highlighter-rouge">pngquant --quality 75-95</code>, and WebP at four qualities:</p>

<table>
  <thead>
    <tr>
      <th>Codec</th>
      <th style="text-align: right">Size vs PNG</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PNG (baseline)</td>
      <td style="text-align: right">100%</td>
    </tr>
    <tr>
      <td>pngquant 75–95</td>
      <td style="text-align: right">29%</td>
    </tr>
    <tr>
      <td>WebP q75</td>
      <td style="text-align: right">16%</td>
    </tr>
    <tr>
      <td><strong>WebP q80</strong></td>
      <td style="text-align: right"><strong>19%</strong></td>
    </tr>
    <tr>
      <td>WebP q85</td>
      <td style="text-align: right">26%</td>
    </tr>
    <tr>
      <td>WebP q90</td>
      <td style="text-align: right">38%</td>
    </tr>
  </tbody>
</table>

<p>WebP at q80 beats pngquant at every setting tested. The visual difference at street zoom is invisible — labels stay crisp, terrain shading stays smooth. The end-to-end re-render of TAS confirmed it: <strong>376 MB → 61 MB</strong>.</p>

<p>The pipeline change is one line: after fetching the rendered PNG from tileserver-gl, pipe it through <code class="language-plaintext highlighter-rouge">cwebp -q 80</code> before hashing and inserting into the <code class="language-plaintext highlighter-rouge">images</code> table. Update the MBTiles <code class="language-plaintext highlighter-rouge">format</code> metadata from <code class="language-plaintext highlighter-rouge">png</code> to <code class="language-plaintext highlighter-rouge">webp</code> so the spec stays honest; consumers that ignore that field (like my native patch, which passes raw bytes straight to <code class="language-plaintext highlighter-rouge">UIImage</code> / <code class="language-plaintext highlighter-rouge">BitmapFactory</code>) don’t notice the change. Both platforms have decoded WebP natively for years — iOS 14+, Android API 14+.</p>

<p>End-to-end size for all 9 ANZ regions, walking through the optimisations:</p>

<table>
  <thead>
    <tr>
      <th>Pipeline state</th>
      <th style="text-align: right">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Flat schema, PNG (naive baseline, projected)</td>
      <td style="text-align: right">~21 GB</td>
    </tr>
    <tr>
      <td>Dedup schema, PNG</td>
      <td style="text-align: right">10.7 GB</td>
    </tr>
    <tr>
      <td><strong>Dedup schema, WebP q80</strong></td>
      <td style="text-align: right"><strong>2.3 GB</strong></td>
    </tr>
  </tbody>
</table>

<p>About 9× smaller than the naive starting point, and within rounding error of the legacy ZIP-tier total CamperMate already shipped — the “5× bigger download” story is gone.</p>

<p>R2 cost: <strong>~$0.04/month storage</strong> for the whole tier, and <strong>egress to devices is free</strong> — the killer feature R2 has over S3. A user who only ever visits Tasmania downloads 61 MB once, free, never pays storage either. The “I’m doing all of NSW” worst case is now 305 MB. The biggest single download in the tier is the North Island at 444 MB.</p>

<h2 id="style-picks">Style picks</h2>

<p>For CamperMate I tested the free OpenMapTiles styles. All open-licensed, all render against the same vector MBTiles:</p>

<ul>
  <li><strong>Positron</strong> — minimal, white, designed as a backdrop for <em>other</em> content. Beautiful but wrong for an “offline map replacement” use case where the map <em>is</em> the content.</li>
  <li><strong>OSM Bright</strong> — what I shipped with. Coloured roads, green parks, blue water, motorway shields, full POI labels. Reads like Apple Maps in light mode.</li>
  <li><strong>Dark Matter</strong> — dark-mode equivalent of Positron. Future option for a night-mode toggle.</li>
</ul>

<p>The aesthetic decision changes which file you ship to users; it doesn’t change anything upstream. Vector MBTiles → re-render → upload. Hours, not days.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>If you’re building any kind of outdoor, overland, or regional travel app and your users care about offline coverage, this pipeline is repeatable. The tools are mature, the licensing is permissive (OSM is ODbL, the styles are BSD/MIT, planetiler is Apache-2, tileserver-gl is BSD-2), the storage is cheap, and the aesthetic is finally something you can put in a shipping app without an apology.</p>

<p>If you’re heading to Australia or New Zealand and want to see the pipeline in production, <a href="https://apps.apple.com/app/campermate/id578975305">grab CamperMate on iOS</a> or <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a> — free, no account required, offline maps under the “Downloads” tab. Your offline maps don’t have to look like 2013 anymore.</p>

<hr />

<p><em>Header photo by <a href="https://unsplash.com/@marekpiwnicki">Marek Piwnicki</a> on <a href="https://unsplash.com">Unsplash</a>.</em></p>]]></content><author><name>Isaac Rowntree</name></author><category term="engineering" /><category term="offline-maps" /><category term="mbtiles" /><category term="openmaptiles" /><category term="openstreetmap" /><category term="planetiler" /><category term="tileserver-gl" /><category term="maplibre" /><category term="cloudflare-r2" /><category term="react-native" /><category term="campermate" /><summary type="html"><![CDATA[How to ship offline basemaps with a modern MapLibre aesthetic (Positron, OSM Bright) from OpenStreetMap data: OSM PBF → planetiler → tileserver-gl → raster MBTiles → Cloudflare R2. Platform-agnostic, free, no API keys, no Mapbox bill. The pipeline I built for CamperMate.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/offline-maps-pipeline.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/offline-maps-pipeline.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Shutterdrop — wireless tethered phone camera for your Mac</title><link href="https://zackdesign.biz/shutterdrop/" rel="alternate" type="text/html" title="Shutterdrop — wireless tethered phone camera for your Mac" /><published>2026-04-22T00:00:00+00:00</published><updated>2026-04-22T00:00:00+00:00</updated><id>https://zackdesign.biz/shutterdrop</id><content type="html" xml:base="https://zackdesign.biz/shutterdrop/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/shutterdrop"><code class="language-plaintext highlighter-rouge">shutterdrop</code></a> — a wireless tethered camera that turns the phone in your pocket into a wifi shutter for your Mac. Tap the screen on your phone, the photo lands in a watched folder on your Mac a moment later. Like Capture One tether, but over wifi from your iPhone or Android instead of a USB DSLR. No cable, no cloud, no account.</p>

<!-- more -->

<h2 id="why-this-exists">Why this exists</h2>

<p>I take a lot of product photos for eBay listings — bike parts, electronics, miscellaneous resale. The iPhone in my pocket has a vastly better camera than my MacBook’s built-in webcam, but the friction of “shoot on phone → AirDrop → import to listing tool” was killing the throughput. Existing wireless tether tools either want a subscription, push photos through someone else’s cloud, or are tied to a specific desktop app I don’t use.</p>

<p>Shutterdrop is the smallest possible thing that solves the problem: tap shutter, file shows up. That’s it. The receiver writes straight to a watched folder, so whatever workflow you already have (Finder smart folder, Hazel rule, Lightroom auto-import, eBay listing CLI) just sees new files appear.</p>

<h2 id="what-its-like-to-use">What it’s like to use</h2>

<ol>
  <li>Start the receiver on your Mac. It prints a 6-digit pairing code in the terminal.</li>
  <li>Open the Shutterdrop app on your phone. It finds your Mac on the wifi automatically and asks for the code.</li>
  <li>Type the code once. You’re paired forever — your phone remembers your Mac.</li>
  <li>Frame the shot, tap anywhere on the camera preview, and a moment later the photo appears in <code class="language-plaintext highlighter-rouge">~/Pictures/Shutterdrop/</code> on your Mac. Drag it straight into your eBay listing, your Lightroom catalogue, or wherever you already work.</li>
</ol>

<p>If you walk out of wifi range mid-shoot, captures queue up on the phone and flush as soon as you’re back online. Nothing gets lost.</p>

<h2 id="whats-in-it">What’s in it</h2>

<ul>
  <li><strong>iPhone app</strong> for iOS 17+, with a manual lens picker on Pro phones (0.5× / 1× / 3×) and a built-in torch toggle. Photos are HEIC at full quality.</li>
  <li><strong>Android app</strong> for Android 8 and up. JPEG capture, edge-to-edge layout, accessible to TalkBack screen readers.</li>
  <li><strong>A small Mac receiver</strong> written in Python. It runs in the background, advertises itself on the local network, and drops every incoming photo into a folder of your choice. Linux works too.</li>
  <li><strong>Pairing is private.</strong> A one-time 6-digit code shown on your Mac, with rate limits and a 5-minute window so nobody on the same wifi can guess their way in. The shared key lives in your phone’s secure storage (iOS Keychain or Android EncryptedSharedPreferences).</li>
</ul>

<h2 id="status">Status</h2>

<p>Working end-to-end on both iPhone and Android, with an automated test suite for the Mac receiver that runs on every push. The wire protocol between phone and Mac is small enough that you could write your own receiver — drop incoming photos into S3, pipe them through <code class="language-plaintext highlighter-rouge">pngcrush</code>, auto-import to Lightroom, whatever you want. MIT-licensed, source on <a href="https://github.com/isaacrowntree/shutterdrop">GitHub</a>.</p>

<h2 id="under-the-hood-for-the-curious">Under the hood (for the curious)</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Phone (iOS or Android)              Mac (or Linux)
┌───────────────────────┐           ┌────────────────────────┐
│ Camera preview        │  HTTP     │ receiver.py            │   drop
│ Tap-to-capture (HEIC  ├──over────▶│ (Python stdlib +       ├──────▶  ~/Pictures/Shutterdrop/
│  on iOS / JPEG on     │  LAN +    │  zeroconf)             │
│  Android)             │  Bonjour  │ advertises             │
│ Offline outbox        │           │ _shutterdrop._tcp      │
│ Bonjour discovery     │           └────────────────────────┘
└───────────────────────┘
</code></pre></div></div>

<p>Three endpoints, that’s the whole protocol:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET  /health  → {"ok":true}                          unauthenticated
POST /pair    → {"code":"123456","peerName":"…"}     returns {"secret","peer"}
POST /submit  → multipart/form-data, "photo" part, Bearer auth required
</code></pre></div></div>

<p>Build details and architecture notes are in the <a href="https://github.com/isaacrowntree/shutterdrop">README</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="swift" /><category term="kotlin" /><category term="python" /><category term="ios" /><category term="android" /><category term="mac" /><category term="photography" /><category term="bonjour" /><category term="open-source" /><summary type="html"><![CDATA[An iOS + Android + Python receiver that turns your phone into a tap-and-drop wireless tether for your Mac. LAN + Bonjour, no cable, no cloud.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/shutterdrop.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/shutterdrop.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript</title><link href="https://zackdesign.biz/bezant/" rel="alternate" type="text/html" title="bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript" /><published>2026-04-20T00:00:00+00:00</published><updated>2026-04-20T00:00:00+00:00</updated><id>https://zackdesign.biz/bezant</id><content type="html" xml:base="https://zackdesign.biz/bezant/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/bezant"><strong>bezant</strong></a> — a typed async Rust client for the Interactive Brokers Client Portal Web API, with HTTP, CLI, MCP, and TypeScript surfaces all generated from the same vendored OpenAPI spec. It’s the first Rust project we’ve open-sourced, and it exists because trading against IBKR from modern code shouldn’t mean hand-rolling 155 HTTP endpoints from a PDF.</p>

<!-- more -->

<h2 id="why-it-exists">Why it exists</h2>

<p>Interactive Brokers’ Client Portal Web API (CPAPI) is the gateway most retail-adjacent trading tools reach for — it covers accounts, positions, orders, market data, watchlists, scanners, PnL, and more. The surface area is <strong>155 paths, 167 methods, 1030 types</strong>. The official docs ship an OpenAPI spec, but the spec has real-world quirks: missing or duplicate <code class="language-plaintext highlighter-rouge">operationId</code>s, malformed <code class="language-plaintext highlighter-rouge">security[]</code> blocks, integer fields with floating-point example values, and a few other gremlins that break naive code generators.</p>

<p>Bezant vendors that spec, normalises it through a 13-step pipeline, and regenerates every client surface from a single command. When IBKR revises the spec, one <code class="language-plaintext highlighter-rouge">./scripts/codegen.sh</code> re-runs the whole thing and every language/runtime updates together.</p>

<h2 id="five-surfaces-one-spec">Five surfaces, one spec</h2>

<table>
  <thead>
    <tr>
      <th>Crate / package</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-core</code></strong></td>
      <td>Ergonomic async Rust facade — <code class="language-plaintext highlighter-rouge">Client</code>, session keepalive, health, WebSocket streaming, pagination, symbol cache, typed errors</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-api</code></strong></td>
      <td>Auto-generated Rust client covering every CPAPI endpoint</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-server</code></strong></td>
      <td>HTTP sidecar — exposes CPAPI as plain REST+JSON so any language can consume it</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-cli</code></strong></td>
      <td><code class="language-plaintext highlighter-rouge">bezant</code> CLI — <code class="language-plaintext highlighter-rouge">bezant health</code>, <code class="language-plaintext highlighter-rouge">bezant positions DU123456</code>, <code class="language-plaintext highlighter-rouge">bezant conid AAPL</code></td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-mcp</code></strong></td>
      <td>Model Context Protocol server — exposes IBKR as MCP tools for Claude Code, Cursor, Continue</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-client</code></strong></td>
      <td>TypeScript client for Node / Deno / browser</td>
    </tr>
  </tbody>
</table>

<p>The MCP surface is the one that surprised us the most. Once it was there, driving IBKR from a conversation — <em>“what are my open positions in my paper account?”</em> — became a single <code class="language-plaintext highlighter-rouge">/plugin install</code> away. The same spec drove the typed Rust client, the CLI, and the TypeScript package. Zero duplication.</p>

<h2 id="rust-because-it-earns-the-weight">Rust, because it earns the weight</h2>

<p>Rust is new to our open-source lineup. We chose it for bezant specifically because:</p>

<ul>
  <li><strong>A long-running trading session wants to not crash.</strong> Rust’s memory safety and absence of GC pauses feel right for code that holds an authenticated session, streams over a WebSocket, and needs to keepalive a 5-minute-expiring cookie cleanly.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">reqwest</code> + <code class="language-plaintext highlighter-rouge">tokio</code> is genuinely pleasant.</strong> Strong types end-to-end, async streaming via <code class="language-plaintext highlighter-rouge">tokio-tungstenite</code>, and error handling via <code class="language-plaintext highlighter-rouge">thiserror</code> — the Rust HTTP/WebSocket story in 2026 is excellent.</li>
  <li><strong>Codegen is unforgiving.</strong> When you regenerate a client from a noisy third-party spec, the compiler catches mismatches the moment you rebuild. Dynamic languages find out at runtime.</li>
</ul>

<p>The ergonomic facade in <code class="language-plaintext highlighter-rouge">bezant-core</code> sits on top of the raw generated client so callers don’t have to think about method-naming quirks or type re-wrapping:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">use</span> <span class="nn">std</span><span class="p">::</span><span class="nn">time</span><span class="p">::</span><span class="n">Duration</span><span class="p">;</span>

<span class="nd">#[tokio::main]</span>
<span class="k">async</span> <span class="k">fn</span> <span class="nf">main</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="nn">bezant</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">()</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">client</span> <span class="o">=</span> <span class="nn">bezant</span><span class="p">::</span><span class="nn">Client</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="s">"https://localhost:5000/v1/api"</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">_keepalive</span> <span class="o">=</span> <span class="n">client</span><span class="nf">.spawn_keepalive</span><span class="p">(</span><span class="nn">Duration</span><span class="p">::</span><span class="nf">from_secs</span><span class="p">(</span><span class="mi">60</span><span class="p">));</span>
    <span class="n">client</span><span class="nf">.health</span><span class="p">()</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>

    <span class="k">let</span> <span class="n">positions</span> <span class="o">=</span> <span class="n">client</span><span class="nf">.all_positions</span><span class="p">(</span><span class="s">"DU123456"</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">aapl</span> <span class="o">=</span> <span class="nn">bezant</span><span class="p">::</span><span class="nn">SymbolCache</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="n">client</span><span class="p">)</span><span class="nf">.conid_for</span><span class="p">(</span><span class="s">"AAPL"</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="nd">println!</span><span class="p">(</span><span class="s">"{} positions; AAPL = conid {aapl}"</span><span class="p">,</span> <span class="n">positions</span><span class="nf">.len</span><span class="p">());</span>
    <span class="nf">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-spec-normalisation-pipeline">The spec-normalisation pipeline</h2>

<p>The most unexpectedly interesting piece of this project is the 13-step spec-normalisation pipeline. IBKR’s published OpenAPI isn’t wrong — it’s <em>realistic</em>. Real specs have duplicate operation IDs, missing required fields, and security definitions that don’t validate.</p>

<p>Rather than patch-forward into our codegen, bezant normalises the spec <em>before</em> codegen runs. Each step is idempotent and documented:</p>

<ol>
  <li>Add missing <code class="language-plaintext highlighter-rouge">operationId</code>s deterministically from path + method</li>
  <li>De-duplicate the operationIds that IBKR repeats</li>
  <li>Repair malformed <code class="language-plaintext highlighter-rouge">security[]</code> blocks</li>
  <li>Coerce integer fields with float example values</li>
  <li>Upgrade OAS 3.0 → 3.1 where it matters for our generator</li>
  <li>…and eight more</li>
</ol>

<p>The output is a clean, modern OpenAPI 3.1 document that <strong>every</strong> downstream generator can consume without complaint. The full pipeline is documented at <a href="https://isaacrowntree.github.io/bezant/internals/normalisation.html">Spec normalisation</a> — if you have your own fights with a gnarly third-party spec, the pattern is worth stealing.</p>

<h2 id="testing-against-reality">Testing against reality</h2>

<p>34 tests across the workspace, all green in CI:</p>

<ul>
  <li><strong>Unit</strong> for the facade and the CLI</li>
  <li><strong>Snapshot tests</strong> keyed to real IBKR example payloads — catches upstream spec drift before users feel it</li>
  <li><strong>Integration</strong> against <code class="language-plaintext highlighter-rouge">wiremock</code> for fault-injection (session expiry, 5xx retries)</li>
  <li><strong>End-to-end</strong> through Docker Compose against a mocked Gateway</li>
</ul>

<p>The Docker Compose quickstart is one command: <code class="language-plaintext highlighter-rouge">docker compose up</code>, log in to the IBKR Gateway once in a browser, and the HTTP sidecar is live on <code class="language-plaintext highlighter-rouge">http://localhost:8080</code>.</p>

<h2 id="mcp-ibkr-as-a-tool-for-claude">MCP: IBKR as a tool for Claude</h2>

<p>One of the weirder, more fun surfaces is <code class="language-plaintext highlighter-rouge">bezant-mcp</code> — a Model Context Protocol server that exposes IBKR endpoints as MCP tools. Drop it into Claude Code or Cursor, and you can ask <em>“show me the PnL on my paper account this week”</em> and the model drives the actual CPAPI to answer. The MCP tools are generated from the same spec, so new IBKR endpoints become new MCP tools automatically.</p>

<h2 id="status-and-licensing">Status and licensing</h2>

<ul>
  <li><strong>Alpha — v0.1.</strong> Works end-to-end against IBKR paper accounts; API surface will evolve until v1.0</li>
  <li><strong>Dual-licensed MIT / Apache-2.0</strong> following Rust ecosystem convention</li>
  <li><strong>Not affiliated with Interactive Brokers</strong> — the vendored spec is IBKR’s IP, included under fair-use for interoperability</li>
  <li><strong>Docs:</strong> <a href="https://isaacrowntree.github.io/bezant/">isaacrowntree.github.io/bezant</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/isaacrowntree/bezant">github.com/isaacrowntree/bezant</a></li>
</ul>

<p>If you’re building a trading bot, an analytics tool, an AI-assistant-with-broker-access, or anything that wants typed access to IBKR without the PDF-reading phase — bezant is waiting. Contributions welcome, especially on the spec-normaliser and on new client languages.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="rust" /><category term="ibkr" /><category term="trading" /><category term="openapi" /><category term="mcp" /><category term="typescript" /><category term="interactive-brokers" /><category term="client-portal" /><summary type="html"><![CDATA[A Rust-first async client for the Interactive Brokers Client Portal Web API — with HTTP, CLI, MCP, and TypeScript surfaces auto-generated from one vendored OpenAPI spec.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/bezant.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/bezant.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing SessionHQ — our flagship SaaS</title><link href="https://zackdesign.biz/sessionhq-launch/" rel="alternate" type="text/html" title="Introducing SessionHQ — our flagship SaaS" /><published>2026-04-17T00:00:00+00:00</published><updated>2026-04-17T00:00:00+00:00</updated><id>https://zackdesign.biz/sessionhq-launch</id><content type="html" xml:base="https://zackdesign.biz/sessionhq-launch/"><![CDATA[<p>After months of design, engineering, and iteration with real studio operators, Zack Design is proud to launch <strong><a href="https://sessionhq.org">SessionHQ</a></strong> — the modern check-in platform for class-based studios. It is the most ambitious product we have ever shipped, and it now runs nightly check-ins at our founding partner <a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> in Port Macquarie.</p>

<!-- more -->

<h2 id="what-sessionhq-does">What SessionHQ does</h2>

<p>SessionHQ replaces the spreadsheets, paper sign-in sheets, and duct-taped Mindbody workarounds that most small studios tolerate because the alternatives are too expensive, too clunky, or too generic. We built it by sitting at the front desk on a Tuesday night and asking, <em>“what actually needs to happen here?”</em></p>

<p>The answer, it turns out, is:</p>

<ul>
  <li><strong>Members walk in and check in fast.</strong> PIN pad, NFC wristband tap, or QR scan from their phone. No app install required. No “where’s my card.”</li>
  <li><strong>Passes just work.</strong> Class packs, casual rates, unlimited passes. Credits deduct automatically on check-in. Cards-on-file auto-renew the moment a pack runs out.</li>
  <li><strong>Payments happen where the student is.</strong> Square integration handles card payments inline. PCI-compliant. No raw card numbers ever touch our servers.</li>
  <li><strong>Admins see the truth.</strong> Tonight’s attendance, revenue, unpaid check-ins, LTV, retention cohorts — all updating in real time.</li>
</ul>

<p>No per-member fees. No transaction surcharges on top of Square. One flat monthly subscription.</p>

<h2 id="the-technology-behind-it">The technology behind it</h2>

<p>SessionHQ is a serious piece of software infrastructure. A quick tour of the stack:</p>

<ul>
  <li><strong>Next.js 16 &amp; React 19</strong> on the frontend, with Tailwind 4 and a custom 19-primitive design system (not shadcn — we wanted the ownership).</li>
  <li><strong>Cloudflare Workers</strong> via OpenNext for the runtime. Global edge deployment, sub-100ms cold starts, one Worker cron handling pass-lifecycle, database backup, prune, and retention sweeps.</li>
  <li><strong>Supabase</strong> for auth, Postgres, realtime, and row-level security. Every tenant-owned table enforces <code class="language-plaintext highlighter-rouge">auth_tenant_id()</code> at the database layer — a studio <em>cannot</em> see another studio’s data, period.</li>
  <li><strong>Square</strong> for payments, with Supabase Vault for token storage and PCI-safe tokenisation.</li>
  <li><strong>Resend</strong> for lifecycle email, <strong>Sentry</strong> for observability, <strong>R2</strong> for storage, <strong>Playwright</strong> and <strong>Vitest</strong> for 800+ tests across unit, integration, and E2E.</li>
</ul>

<p>Multi-tenancy, GDPR-readiness (consent capture, data export, right-to-erasure, full audit trail), idempotency, rate limiting, feature flags — all in from day one, not bolted on later.</p>

<h2 id="why-we-built-it">Why we built it</h2>

<p>We have spent 20+ years building software for other people. SessionHQ is different: <strong>it is our product.</strong> We own the roadmap, the pricing, the customer relationship. We decide which features matter. We eat the bug reports.</p>

<p>It is also a proof point. We believe small businesses deserve software that is as thoughtfully engineered as anything the enterprise market gets — without the enterprise price tag, the 12-month implementation, or the 400-page MSA. SessionHQ is our demonstration that a small, focused team can ship serious SaaS.</p>

<h2 id="founding-partner-havana-on-the-hastings">Founding partner: Havana on the Hastings</h2>

<p>SessionHQ did not launch in a vacuum. It launched with a customer.</p>

<p><a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> is Port Macquarie’s Latin dance community — Cuban salsa, bachata, urban kiz, and rueda (the dance that brought founders Mike and Kellie together). They run on passes, practicas, and real connection, with the warmth of a studio where “everyone starts somewhere” is not just a slogan but a weekly reality.</p>

<p>They were already operating on the pass system that SessionHQ is built around. Partnering with them meant we did not have to guess what studio operators needed — we had one telling us, in real time, what worked and what did not. Every feature in SessionHQ has been stress-tested at their front desk on a Tuesday night.</p>

<p>If you are in Port Macquarie and want to dance, <a href="https://www.havanahastingsdance.com.au/classes">drop in</a>. Absolute beginners are welcome every week.</p>

<h2 id="whats-next">What’s next</h2>

<p>SessionHQ is onboarding new studios now. If you run a dance studio, gym, yoga or pilates studio, martial arts school, or climbing gym — or if you know someone who does — we would love to talk.</p>

<ul>
  <li><strong>Visit</strong> <a href="https://sessionhq.org">sessionhq.org</a> to see the product.</li>
  <li><strong>Request access</strong> on the site, or <strong>book a 15-minute demo</strong> via <code class="language-plaintext highlighter-rouge">info@sessionhq.org</code>.</li>
  <li><strong>Founding-studio pricing is locked in</strong> for the studios who sign on before general availability.</li>
</ul>

<p>This is the start of something we are going to spend years building on. Thanks for being here for the beginning.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="product" /><category term="sessionhq" /><category term="saas" /><category term="nextjs" /><category term="supabase" /><category term="cloudflare" /><category term="square" /><category term="product-launch" /><summary type="html"><![CDATA[SessionHQ — our modern multi-tenant check-in platform for dance studios, gyms, and martial arts schools — is live, with founding partner Havana on the Hastings.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/sessionhq-launch.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/sessionhq-launch.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stamp Scanner — iPhone + Mac + SAM 3 for cataloguing stamp collections</title><link href="https://zackdesign.biz/stamp-scanner/" rel="alternate" type="text/html" title="Stamp Scanner — iPhone + Mac + SAM 3 for cataloguing stamp collections" /><published>2026-04-16T00:00:00+00:00</published><updated>2026-04-16T00:00:00+00:00</updated><id>https://zackdesign.biz/stamp-scanner</id><content type="html" xml:base="https://zackdesign.biz/stamp-scanner/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/stamp-scanner"><code class="language-plaintext highlighter-rouge">stamp-scanner</code></a> — a two-device workflow for cataloguing stamp collections. The iPhone acts as a tethered macro scanner. The Mac runs SAM 3 segmentation, perceptual-hash deduplication, rotation correction, and a local Qwen3-VL for identification. Everything lives in a queryable SQLite library you can point external tools at.</p>

<!-- more -->

<h2 id="the-architecture-in-ascii">The architecture, in ASCII</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iPhone (ios-app/)                    Mac (mac-app/)                 Python (tools/)
┌───────────────────┐   HTTP over    ┌───────────────────┐   file   ┌─────────────────┐
│ Capture (HEIC)    ├───LAN+Bonjour─▶│ PhoneIngestServer ├──drop───▶│ sam_worker.py   │
│ MotionGate        │                │ (accepts uploads) │          │ SAM 3 + dedup   │
│ Lens picker       │                └───────────────────┘          │ + white balance │
└───────────────────┘                         │                     └────────┬────────┘
                                              │                              │ writes
                                              ▼                              ▼
                                     ┌────────────────────┐         ┌─────────────────────┐
                                     │ SwiftUI library UI │◀──GRDB──│ library.sqlite      │
                                     │ grid · detail      │         │ (~/Library/App Sup) │
                                     │ rotate · identify  │         └──────────▲──────────┘
                                     │ colnect lookup     │                    │ writes
                                     └────────────────────┘                    │
                                                │ spawns                       │
                                                ▼                              │
                                     ┌────────────────────┐                    │
                                     │ orientation_worker │───── Ollama ───────┤
                                     │   (Qwen3-VL)       │                    │
                                     │ colnect_lookup.py  │───── HTTP ─────────┘
                                     └────────────────────┘
</code></pre></div></div>

<h2 id="the-data-flow">The data flow</h2>

<ol>
  <li><strong>iPhone captures HEIC.</strong> <code class="language-plaintext highlighter-rouge">MotionGate</code> waits for the phone to be steady (accelerometer settled) before taking the shot, the lens picker selects the macro-capable camera, and the captured HEIC is uploaded over Bonjour/LAN to the paired Mac.</li>
  <li><strong>Mac receives it.</strong> <code class="language-plaintext highlighter-rouge">PhoneIngestServer</code> — a SwiftUI app wrapping a tiny HTTP listener — drops the file into <code class="language-plaintext highlighter-rouge">.run/sam_inbox/</code>.</li>
  <li><strong>SAM 3 segments the stamp.</strong> <code class="language-plaintext highlighter-rouge">sam_worker.py</code> runs the Segment Anything 3 model to cut the stamp out of the page, perceptual-hashes it to detect duplicates already in the library, warps it square, and white-balances against the untouched corners of the page.</li>
  <li><strong>SQLite writes.</strong> The segmented, deduplicated, white-balanced stamp lands in <code class="language-plaintext highlighter-rouge">library.sqlite</code> via a GRDB schema.</li>
  <li><strong>SwiftUI UI renders.</strong> The Mac app exposes a grid, a detail view, rotation tools, and “identify” / “Colnect lookup” buttons.</li>
  <li><strong>Identification is VLM-driven.</strong> Hitting “identify” spawns <code class="language-plaintext highlighter-rouge">orientation_worker</code> against a local Ollama-hosted Qwen3-VL instance. Hitting “Colnect lookup” queries the Colnect catalogue API for an official ID match.</li>
</ol>

<h2 id="why-two-devices">Why two devices</h2>

<p>Because an iPhone’s macro camera + image signal processor is genuinely excellent at stamp-sized subjects — better than a flatbed scanner at 1200 dpi for small dense subjects, and much faster. A Mac, meanwhile, is the right place for the heavy lifting: SAM 3 wants a GPU, the local VLM wants 20 GB of unified memory, and GRDB + SwiftUI want a real filesystem and a large screen. Splitting capture from processing plays to each device’s strengths.</p>

<h2 id="why-local">Why local</h2>

<p>A stamp collection is personal. You do not want to upload it to a third-party cataloguing service that might vanish in two years or quietly start charging a subscription. Local models, local SQLite, local UI. The only optional outbound call is the Colnect catalogue API, and that is a lookup against their public IDs — no collection data leaves your Mac.</p>

<h2 id="status">Status</h2>

<p>Working end-to-end for single-subject captures, deduplication, rotation, and VLM-based identification. Full architecture and build instructions in the <a href="https://github.com/isaacrowntree/stamp-scanner">README</a>. If you have a collection that deserves better than a spreadsheet, this is a solid starting point.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="ai" /><category term="swift" /><category term="python" /><category term="ios" /><category term="mac" /><category term="sam" /><category term="vlm" /><category term="philately" /><category term="local-ai" /><category term="open-source" /><summary type="html"><![CDATA[A two-device workflow that turns an iPhone into a macro scanner and a Mac into a SAM-3 segmentation, deduplication, and VLM identification pipeline for philately.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/stamp-scanner.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/stamp-scanner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">bike-shock-planner — test-driven MTB shock fitment modelling</title><link href="https://zackdesign.biz/bike-shock-planner/" rel="alternate" type="text/html" title="bike-shock-planner — test-driven MTB shock fitment modelling" /><published>2026-04-12T00:00:00+00:00</published><updated>2026-04-12T00:00:00+00:00</updated><id>https://zackdesign.biz/bike-shock-planner</id><content type="html" xml:base="https://zackdesign.biz/bike-shock-planner/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/bike-shock-planner"><code class="language-plaintext highlighter-rouge">bike-shock-planner</code></a> — a <strong>test-driven, code-as-data</strong> planner for mountain bike rear shock replacements, coil conversions, and ebike suspension builds. It began as “can I fit a coil shock to a 2013 Trek Fuel EX 5 ebike conversion?” and grew into a reusable framework that models rear suspension geometry, shock fitment, spring rates, frame clearance, conversion hardware, and global sourcing paths for <em>any</em> bike.</p>

<!-- more -->

<h2 id="it-is-not-a-bike-specific-script">It is not a bike-specific script</h2>

<p>The 2013 Fuel EX 5 is the first “recipe” — a self-contained config describing one bike, one rider, and a set of candidate parts. Everything is written so you can drop in a new recipe for your own frame and the same fit-check and spring-rate logic runs against it. That is the whole point of the project: a single, testable model of rear-shock dimensions and fitment rules, with as many recipes layered on top as people are willing to contribute.</p>

<h2 id="who-it-is-for">Who it is for</h2>

<ul>
  <li><strong>DIY mechanics</strong> restoring an old MTB frame and trying to work out whether a modern shock will bolt up.</li>
  <li><strong>Ebike converters</strong> putting a mid-drive motor on a non-ebike frame and needing to recalculate spring rates for the extra mass and torque.</li>
  <li><strong>Frame hunters</strong> cross-checking a secondhand frame’s shock spec against catalog reality before buying.</li>
  <li><strong>Bike shops</strong> who want a reusable, forkable model of rear-shock dimensions — the catalog is just TypeScript, extend it for whatever you stock and rerun the tests to lint your inventory against real frames.</li>
  <li><strong>Anyone</strong> who has spent hours in a Trek fitment PDF trying to work out whether a shock advertised as “7.25×2.0 imperial” fits their old DRCV mount. (Spoiler: only via a conversion kit.)</li>
</ul>

<h2 id="what-it-does">What it does</h2>

<ul>
  <li><strong>Bike model.</strong> Eye-to-eye, stroke, mount styles, eyelet widths, bolt sizes, leverage ratio, progression, and the frame clearance envelope — all captured in code.</li>
  <li><strong>Shock catalog.</strong> Aftermarket shocks modelled as code, with body dimensions, piggyback status, coil spring rate range, Australian sourcing notes, and verified product URLs.</li>
  <li><strong>Fit check.</strong> Frame slot × candidate shock returns each dimensional mismatch separately — eye-to-eye, stroke, upper/lower eyelet width, bolt sizes, mount styles, body length, body diameter, reservoir clearance. No yes/no black boxes.</li>
  <li><strong>Conversion kits.</strong> Kits that rewrite a shock’s mounting hardware are modelled as functions that transform a candidate. So you can ask “does this imperial shock fit if I use the Shockcraft Deaktiv kit?” and get a real answer.</li>
  <li><strong>Spring-rate calculator.</strong> A <em>practical</em> formula that accounts for rear weight distribution — not the theoretical Fox “quick formula” that overshoots real-world spring picks by 40%.</li>
  <li><strong>Ebike load correction.</strong> Weights 40% of battery + motor mass onto the rear shock and adds a high-torque correction for ≥100 Nm motors.</li>
  <li><strong>Progression flag.</strong> Warns when a frame’s linkage does not really want a coil — e.g. Trek’s Full Floater is only ~13% progressive and is tuned for a DRCV air spring, so a linear coil will bottom harshly.</li>
  <li><strong>Documented-build flag.</strong> If no published build exists for the exact frame generation, every candidate gets an <em>experimental</em> warning.</li>
  <li><strong>Research library.</strong> Verified references to conversion kits, manufacturer product pages, global retailers, used-market venues, forum threads, and vendor email contacts — with tests enforcing that every link is HTTPS and every group is populated.</li>
  <li><strong>Pivot hardware model.</strong> OEM bearing/bolt spec plus a four-step health check so you can decide whether a full frame rebuild is required alongside the shock swap.</li>
</ul>

<h2 id="status-today">Status today</h2>

<p>Primarily a <strong>2013 Trek Fuel EX 5</strong> model. The coil catalog includes Push ElevenSix (the only currently-buildable imperial 7.25×2.0 coil in April 2026), plus Marzocchi Bomber CR, Fox DHX2, DVO Jade X, MRP Hazzard Coil, and Cane Creek DB Coil IL entries marked used-market-only. The air catalog includes Fox Float X2, RockShox Super Deluxe Ultimate, and Marzocchi Bomber Air. Real VALT Progressive sizes are captured with the 45 mm stroke that fits inside a 50 mm shock; Sprindex 55 mm is flagged as not-fitting. The conversion kit catalog covers Offset Bushings, Shockcraft Deaktiv, an unpublished custom-machine path for Huber Bushings, plus a speculative metric-to-Trek kit flagged <code class="language-plaintext highlighter-rouge">publishedSku: false</code> so the test suite warns on it.</p>

<h2 id="why-code-as-data">Why code-as-data</h2>

<p>Because every existing shock “compatibility chart” is a PDF, and PDFs cannot be run against a test suite. If you model the data in TypeScript, the test suite can assert things like “no reservoir clash on any frame in the catalog”, “every link in the research library is reachable”, and “every catalog entry has a spring rate range if it is a coil”. That turns a messy research task into something a contributor can submit a pull request against. Source on <a href="https://github.com/isaacrowntree/bike-shock-planner">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="typescript" /><category term="bikes" /><category term="mtb" /><category term="suspension" /><category term="testing" /><category term="open-source" /><summary type="html"><![CDATA[A TypeScript framework for modelling rear-shock fitment, coil conversions, ebike spring rates, and global parts sourcing for any mountain bike — starting with a 2013 Trek Fuel EX 5.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/bike-shock-planner.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/bike-shock-planner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Clean Backdrop — free, GPU-accelerated studio backdrop cleanup</title><link href="https://zackdesign.biz/clean-backdrop/" rel="alternate" type="text/html" title="Clean Backdrop — free, GPU-accelerated studio backdrop cleanup" /><published>2026-03-25T00:00:00+00:00</published><updated>2026-03-25T00:00:00+00:00</updated><id>https://zackdesign.biz/clean-backdrop</id><content type="html" xml:base="https://zackdesign.biz/clean-backdrop/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/clean-backdrop"><code class="language-plaintext highlighter-rouge">clean-backdrop</code></a> — a free, open-source tool for cleaning up studio portrait backdrops. Shadow lift plus frequency separation on a high-quality portrait segmentation mask, running on a CUDA GPU, no AI inpainting artifacts anywhere. It is a clean-math alternative to paid tools like Retouch4me Clean Backdrop.</p>

<!-- more -->

<h2 id="the-problem">The problem</h2>

<p>Studio paper backdrops are never as clean as they look before the shoot. A six-hour session leaves scuff marks, footprints, seam shadows where the paper meets the floor, uneven lighting where the key light rolled off, and the occasional crease from the roll dispenser. Fixing those by hand in Photoshop — with the healing brush, dodge/burn layers, and a feathered mask around the subject — is a real job. For a shoot with two hundred keepers, it is unreasonable.</p>

<p>The commercial tools that automate this are excellent and expensive. <code class="language-plaintext highlighter-rouge">clean-backdrop</code> is the free alternative.</p>

<h2 id="how-it-works">How it works</h2>

<p>Two complementary techniques, run on GPU:</p>

<ol>
  <li><strong>Shadow Lift.</strong> Samples a patch of clean wall, then blends cast shadows toward that reference. Preserves the natural wall gradient (studios are not lit perfectly flat and should not be rendered that way). Adjustable 0–100%.</li>
  <li><strong>Texture Smoothing (Frequency Separation).</strong> Splits the image into a low-frequency lighting gradient and a high-frequency detail layer. Smooths the detail layer — where marks, scuffs, and paper texture live — while leaving the gradient untouched. No smudging, no false positives on the subject’s hair.</li>
</ol>

<p>Both passes run on a <strong><a href="https://github.com/ZhengPeng7/BiRefNet">BiRefNet-Portrait</a></strong> subject segmentation mask with distance-based feathering, so the boundary between subject and cleaned background has no visible “bar” artifact at any crop size.</p>

<h2 id="smart-edge-handling">Smart edge handling</h2>

<ul>
  <li><strong>Smooth subject masking.</strong> Feathering scales with image size, so a 24 MP portrait has the same clean transition as a 45 MP headshot.</li>
  <li><strong>Automatic floor detection.</strong> Real floors (wood, tile, concrete) are distinguished from wall shadow/vignetting by a colour-analysis heuristic. Real floors stay; darkening on walls gets cleaned.</li>
  <li><strong>Vertical floor transition.</strong> The wall-to-floor boundary uses a row-based ramp so the floor texture and contact shadows around the subject’s feet are never disturbed.</li>
</ul>

<h2 id="running-it">Running it</h2>

<p>There are two ways to use it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Web UI — drag-and-drop with live sliders</span>
pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
python app.py
<span class="c"># open http://localhost:5000</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Batch — directory in, directory out</span>
python batch.py <span class="s2">"D:</span><span class="se">\P</span><span class="s2">hotos</span><span class="se">\E</span><span class="s2">xport</span><span class="se">\M</span><span class="s2">y Shoot"</span> <span class="nt">--lift</span> 70 <span class="nt">--texture</span> 50
</code></pre></div></div>

<p>The web UI shows four tabs — Original, Shadows, Texture, Preview — so you can dial shadow lift and texture smoothing independently and watch the separation happen. Outputs are saved next to the original with a <code class="language-plaintext highlighter-rouge">_clean</code> suffix, ICC colour profiles and EXIF metadata preserved.</p>

<h2 id="why-open-source">Why open-source</h2>

<p>Because the underlying math is not exotic — shadow lift and frequency separation have been in the photo-retouching toolkit for twenty years — and because a high-quality portrait segmentation model exists under a permissive licence. The commercial offerings are polished, but the core workflow does not need to be proprietary. If you shoot regularly against studio paper, <a href="https://github.com/isaacrowntree/clean-backdrop">clone the repo</a> and stop paying a per-seat fee for a batch operation.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="python" /><category term="photography" /><category term="cuda" /><category term="birefnet" /><category term="image-processing" /><category term="open-source" /><summary type="html"><![CDATA[An open-source alternative to Retouch4me Clean Backdrop — shadow lift plus frequency separation on a BiRefNet-Portrait mask, run on CUDA. No AI inpainting artifacts.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/clean-backdrop.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/clean-backdrop.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ledger — Australian personal finance ETL and ATO tax dashboard</title><link href="https://zackdesign.biz/ledger/" rel="alternate" type="text/html" title="Ledger — Australian personal finance ETL and ATO tax dashboard" /><published>2026-03-20T00:00:00+00:00</published><updated>2026-03-20T00:00:00+00:00</updated><id>https://zackdesign.biz/ledger</id><content type="html" xml:base="https://zackdesign.biz/ledger/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/ledger"><code class="language-plaintext highlighter-rouge">ledger</code></a> — a terminal-first personal finance tool that ingests bank statements from multiple Australian banks, categorises transactions with regex-based rules, and renders an ATO-ready tax return view plus a net worth dashboard. Local-first, SQLite under the hood, no cloud dependency.</p>

<!-- more -->

<h2 id="the-itch">The itch</h2>

<p>Every mid-year I rebuild the same Excel spreadsheet: paste in ING transactions, paste in PayPal, paste in the Bankwest credit card, then hand-categorise everything, then try to remember which expense was for which business, then double-count a $200 transaction that appeared on both the credit card and the bank account it was paid from. Then I hand it to my accountant and we do it all over again. Ledger is the version I should have built five years ago.</p>

<h2 id="sources-supported-today">Sources supported today</h2>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Formats</th>
      <th>Parser</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ING Australia</td>
      <td>PDF statements, CSV export</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/ing_pdf.py</code>, <code class="language-plaintext highlighter-rouge">ing_csv.py</code></td>
    </tr>
    <tr>
      <td>PayPal</td>
      <td>CSV activity download</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/paypal_csv.py</code></td>
    </tr>
    <tr>
      <td>Bankwest</td>
      <td>PDF eStatements, CSV</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/bankwest_pdf.py</code>, <code class="language-plaintext highlighter-rouge">bankwest_csv.py</code></td>
    </tr>
    <tr>
      <td>HSBC</td>
      <td>PDF statements</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/hsbc_pdf.py</code></td>
    </tr>
    <tr>
      <td>Coles Mastercard</td>
      <td>PDF statements</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/coles_pdf.py</code></td>
    </tr>
    <tr>
      <td>Amex</td>
      <td>CSV download</td>
      <td><code class="language-plaintext highlighter-rouge">etl/parsers/amex_csv.py</code></td>
    </tr>
  </tbody>
</table>

<p>Drop a statement into <code class="language-plaintext highlighter-rouge">staging/&lt;source&gt;/</code>, run <code class="language-plaintext highlighter-rouge">ledger ingest</code>, and the right parser picks it up. PDF parsing is per-bank because every Australian bank has a different statement layout and none of them offer a clean machine-readable export.</p>

<h2 id="what-it-does">What it does</h2>

<ul>
  <li><strong>Multi-source ingestion.</strong> The above parsers, with dedup rules to prevent double-counting when a transaction appears on both a bank account and a credit card.</li>
  <li><strong>Auto-categorisation.</strong> Regex-based merchant rules assign categories automatically and learn from manual overrides.</li>
  <li><strong>Business splits.</strong> A percentage of any expense can be allocated to a business — essential for anyone running a sole-trader side or a company with home-office overlap.</li>
  <li><strong>ATO tax return view.</strong> Output structured to match the sections of an Australian individual tax return: salary, rental schedule, business schedule, deductions.</li>
  <li><strong>Financial year view.</strong> Outgoing / incoming / rental / work-trip sub-tabs replacing the Excel sheet I had been rebuilding by hand every year.</li>
  <li><strong>Net worth dashboard.</strong> Accounts, credit cards, property, vehicles — balances pulled from the same statements.</li>
  <li><strong>Tags.</strong> Orthogonal to categories. A transaction can be in category “Travel” and tagged <code class="language-plaintext highlighter-rouge">flight</code>, <code class="language-plaintext highlighter-rouge">biz-hosting</code>, <code class="language-plaintext highlighter-rouge">rental-income</code> for finer reporting without having to invent a deeper category tree.</li>
</ul>

<h2 id="why-local-first">Why local-first</h2>

<p>Because my financial data is mine. No cloud dependency, no third-party aggregator pulling read-only access to my bank accounts, no “we are deprecating the Xero integration” email six months from now. SQLite sits in a folder, the dashboard runs on <code class="language-plaintext highlighter-rouge">localhost</code>, and if I want to back it all up I copy a single file.</p>

<h2 id="quick-start">Quick start</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/isaacrowntree/ledger.git
<span class="nb">cd </span>ledger
python3 <span class="nt">-m</span> venv .venv <span class="o">&amp;&amp;</span> <span class="nb">source</span> .venv/bin/activate
pip <span class="nb">install</span> <span class="nt">-e</span> <span class="nb">.</span>

<span class="nb">cp </span>config/accounts.yaml.example config/accounts.yaml
<span class="nb">cp </span>config/categories.yaml.example config/categories.yaml
<span class="nb">cp </span>config/tax.yaml.example config/tax.yaml

ledger init
<span class="nb">mkdir</span> <span class="nt">-p</span> staging/ing staging/paypal
<span class="c"># Drop PDFs/CSVs into those folders</span>
ledger ingest
python <span class="nt">-m</span> api
<span class="c"># Open http://localhost:5050</span>
</code></pre></div></div>

<h2 id="who-it-is-for">Who it is for</h2>

<p>Anyone in Australia with more than one bank account, a side business or two, and an accountant who currently gets a hand-assembled spreadsheet every July. Source on <a href="https://github.com/isaacrowntree/ledger">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="python" /><category term="etl" /><category term="finance" /><category term="tax" /><category term="ato" /><category term="sqlite" /><category term="open-source" /><category term="local-first" /><summary type="html"><![CDATA[A terminal-first personal finance tool that ingests ING, Bankwest, HSBC, PayPal, Amex, and Coles statements, categorises transactions, and renders an ATO-ready tax view.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/ledger.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/ledger.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">local-llm-coding-guide — Qwen, Gemma, and llama.cpp as a coding assistant</title><link href="https://zackdesign.biz/local-llm-coding-guide/" rel="alternate" type="text/html" title="local-llm-coding-guide — Qwen, Gemma, and llama.cpp as a coding assistant" /><published>2026-03-14T00:00:00+00:00</published><updated>2026-03-14T00:00:00+00:00</updated><id>https://zackdesign.biz/local-llm-coding-guide</id><content type="html" xml:base="https://zackdesign.biz/local-llm-coding-guide/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/local-llm-coding-guide"><code class="language-plaintext highlighter-rouge">local-llm-coding-guide</code></a> — a no-fluff, benchmark-driven guide to running a genuinely useful local LLM as a coding assistant on consumer hardware. It covers Qwen3.5 and Gemma 4 across llama.cpp, Ollama (with MLX), and vllm-mlx, with real tokens-per-second numbers from three real machines.</p>

<!-- more -->

<h2 id="why-local">Why local</h2>

<p>Cloud LLMs are wonderful until you are on a flight, behind a client VPN, editing code with sensitive data, or burning through a monthly token budget faster than is reasonable. The quality gap between the best frontier models and the best <em>local-runnable</em> models has narrowed dramatically — a quantised 9B Qwen model on a modest NVIDIA card is now perfectly capable of the “reformat this function, add a docstring, write a test” type of work that makes up most of a coding assistant’s day.</p>

<h2 id="the-benchmarks">The benchmarks</h2>

<p>Measured on release builds, real completions, real contexts:</p>

<table>
  <thead>
    <tr>
      <th>GPU</th>
      <th>Model</th>
      <th>Tok/s</th>
      <th>Context</th>
      <th>Memory</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RTX 4070 Ti 12GB</td>
      <td>Nemotron 3 Nano 4B Q4_K_M</td>
      <td>TBD</td>
      <td>262K</td>
      <td>~5GB</td>
    </tr>
    <tr>
      <td>RTX 4070 Ti 12GB</td>
      <td>Qwen3.5-9B Q4_K_M</td>
      <td>~65</td>
      <td>131K</td>
      <td>7.8GB</td>
    </tr>
    <tr>
      <td>RTX 3060 12GB</td>
      <td>Qwen3.5-9B Q4_K_M</td>
      <td>~43</td>
      <td>128K</td>
      <td>~7.8GB</td>
    </tr>
    <tr>
      <td>RTX 3090 24GB</td>
      <td>Qwen3.5-27B Q4_K_M</td>
      <td>~30</td>
      <td>262K</td>
      <td>~18GB</td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td><strong>Qwen3.5-35B-A3B Q4_K_M</strong></td>
      <td><strong>~29</strong></td>
      <td>131K</td>
      <td><strong>~22GB</strong></td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td>Qwen3.5-9B Q4_K_M</td>
      <td>~20</td>
      <td>131K</td>
      <td>~7GB</td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td>Qwen3.5-27B Q4_K_M</td>
      <td>~9*</td>
      <td>131K</td>
      <td>~18GB</td>
    </tr>
    <tr>
      <td>M3 Pro 36GB</td>
      <td><strong>Gemma 4 26B-A4B Q4_K_M (Ollama MLX)</strong></td>
      <td><strong>~31</strong></td>
      <td>256K</td>
      <td><strong>~17GB</strong></td>
    </tr>
  </tbody>
</table>

<p>*The dense 27B is slower than the 35B-A3B MoE on 36 GB machines — see “Why MoE?” in the repo for the full story.</p>

<h2 id="why-moe-wins-on-apple-silicon">Why MoE wins on Apple Silicon</h2>

<p>Apple’s unified memory is generous but its memory <em>bandwidth</em> is not as high as a discrete NVIDIA card’s. A dense 27B model saturates that bandwidth on every token. A mixture-of-experts model like Qwen3.5-35B-A3B only activates 3B parameters per token, which means each token reads a fraction of the weights — and the model runs faster <em>and</em> smarter than the dense option it replaces. The guide walks through the tradeoff properly.</p>

<h2 id="test-machines">Test machines</h2>

<ul>
  <li><strong>Windows/WSL2:</strong> RTX 4070 Ti (12 GB), Intel Core Ultra 9 285K, 48 GB DDR5</li>
  <li><strong>macOS:</strong> M3 MacBook Pro, 36 GB unified memory</li>
</ul>

<h2 id="quick-start">Quick start</h2>

<p>The guide walks through llama.cpp from source (with <code class="language-plaintext highlighter-rouge">-DGGML_CUDA=ON</code> or <code class="language-plaintext highlighter-rouge">-DGGML_METAL=ON</code>), the <code class="language-plaintext highlighter-rouge">llama-server</code> binary, wiring it into VS Code via the Continue extension, and wiring it into Claude Code as a local endpoint. Ollama + MLX is covered as the one-command alternative for Apple Silicon.</p>

<h2 id="who-it-is-for">Who it is for</h2>

<p>Developers who want a serious coding assistant that runs on their own hardware, without a subscription, without a round-trip to a cloud inference endpoint, and without hand-tuning flags for six hours. Read it on <a href="https://github.com/isaacrowntree/local-llm-coding-guide">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="ai" /><category term="guides" /><category term="llm" /><category term="llama-cpp" /><category term="ollama" /><category term="qwen" /><category term="gemma" /><category term="local-ai" /><category term="coding-assistant" /><category term="guides" /><summary type="html"><![CDATA[A benchmarks-first guide to running Qwen3.5 and Gemma 4 locally as a coding assistant — on a 4070 Ti, a 3090, and an M3 Pro MacBook — with real tok/s numbers.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/local-llm-coding-guide.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/local-llm-coding-guide.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">audio-analysis-and-recut — reconstructing a live set from the studio master</title><link href="https://zackdesign.biz/audio-analysis-and-recut/" rel="alternate" type="text/html" title="audio-analysis-and-recut — reconstructing a live set from the studio master" /><published>2026-03-13T00:00:00+00:00</published><updated>2026-03-13T00:00:00+00:00</updated><id>https://zackdesign.biz/audio-analysis-and-recut</id><content type="html" xml:base="https://zackdesign.biz/audio-analysis-and-recut/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/audio-analysis-and-recut"><code class="language-plaintext highlighter-rouge">audio-analysis-and-recut</code></a> — a small Python + FFmpeg pipeline that takes a noisy live performance recording, works out exactly which sections of the original studio track the band played, and generates a high-fidelity recut that follows the live arrangement.</p>

<!-- more -->

<h2 id="the-problem">The problem</h2>

<p>You have two audio files:</p>

<ul>
  <li><strong>Original</strong> — the studio recording. High fidelity, the version on Spotify, the one you actually want to listen to.</li>
  <li><strong>Performance</strong> — a live recording of the same song. Great arrangement, maybe some improvisation, but also crowd noise, ambient PA colouration, and a phone mic’s idea of bass response.</li>
</ul>

<p>The live arrangement is <em>better</em> — it is the one the band actually performed — but the audio fidelity is <em>worse</em>. What you want is: the live arrangement, with studio fidelity. That is what this tool does.</p>

<h2 id="how-it-works">How it works</h2>

<ol>
  <li><strong>Band-pass filter</strong> to 200–4000 Hz on both tracks. Vocals live in that range, crowd noise and room rumble largely do not. This is what makes matching robust to a noisy live environment.</li>
  <li><strong>Sliding-window cross-correlation</strong> between chunks of the performance and the full studio track. Each chunk’s best match pins down where in the original it came from.</li>
  <li><strong>Segment detection</strong> by grouping matches with consistent time offsets. A 30-second verse played live will produce 30 seconds of chunks that all agree on the same studio offset.</li>
  <li><strong>FFmpeg concatenation</strong> of the identified original segments, in the order the live performance used them, into a clean output file.</li>
</ol>

<h2 id="example-output">Example output</h2>

<p>For “Ya Te Olvide” by Los 4 ft Laritza Bacallao (4:40 studio original → 1:56 live performance):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SEGMENT MAP:
  1  0:00-0:19  |  Orig 0:12-0:30  |  18.5s  (intro/verse start)
  2  0:19-1:04  |  Orig 0:50-1:35  |  44.5s  (verse/chorus)
  3  1:04-1:36  |  Orig 2:44-3:15  |  31.5s  (montuno section)
  4  1:36-1:46  |  Orig 4:13-4:23  |   9.5s  (ending)
  5  1:46-1:48  |  Orig 4:33-4:35  |   2.0s  (final tag)

Skipped from original:
  0:00-0:12  (11.8s) - pre-intro
  0:30-0:50  (20.1s) - transition/repeat
  1:35-2:44  (69.1s) - repeated verse section
  3:15-4:13  (57.9s) - extended montuno/breakdown
  4:23-4:33  (10.3s) - outro padding
</code></pre></div></div>

<p>The segment map reads like a director’s cut list: the band skipped the pre-intro, compressed the long breakdown, and landed on a different ending. Feeding that back into FFmpeg reconstructs the performance from clean studio audio.</p>

<h2 id="why-bother">Why bother</h2>

<p>Latin dance classes like <a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> often rehearse to studio recordings but perform to the band’s own arrangement — which means choreographies that work in rehearsal do not always align with the live record they are showcased against. A recut reconciles the two: same arrangement the dancers know, but clean enough to cue on.</p>

<h2 id="usage">Usage</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp </span>original_song.mp3 staging/original.mp3
<span class="nb">cp </span>performance_recording.mp3 staging/performance.mp3
python3 analyze.py
<span class="c"># Output: output/ya_te_olvide_recut.mp3</span>
</code></pre></div></div>

<p>Dependencies are Python 3 with NumPy, plus a working FFmpeg on <code class="language-plaintext highlighter-rouge">$PATH</code>. Source on <a href="https://github.com/isaacrowntree/audio-analysis-and-recut">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="audio" /><category term="dsp" /><category term="python" /><category term="ffmpeg" /><category term="cross-correlation" /><category term="open-source" /><summary type="html"><![CDATA[A Python tool that cross-correlates a noisy live performance recording against the original studio track and rebuilds a high-fidelity recut following the live arrangement.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/audio-analysis-and-recut.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/audio-analysis-and-recut.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>