<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>macOS · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/topics/macos/</link>
    <description>macOS as an engineering workstation and a scripting target, not just a laptop.</description>
    <language>en-us</language>
    
    <copyright>© 2026 Emmanuel Tsouris</copyright>
    <lastBuildDate>Fri, 16 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/topics/macos/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Why Is the uv Cache So Large on My Mac?</title>
      <link>https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/</guid>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Python</category>
      
      <description>Coding agents fill the uv cache when they launch MCP servers with uvx; prune it safely and see how much disk you really get back.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Quit every coding agent and editor that launches MCP servers, then run <code>uv cache prune</code>. A running session holds the cache lock, so prune prints &ldquo;Cache is currently in-use, waiting for other uv processes to finish&rdquo; and, after uv&rsquo;s lock timeout, gives up. Do not use <code>--force</code> to skip it. Expect much less back than prune reports: on this Mac it reported 111.1 GiB (about 119 GB) removed and the disk gained 27.5 GB, most likely because cloned files share blocks.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">prune</span></span></code></pre>
    </div>
  </div></div>
</div>

<p>My Mac was down to 38.4 GB free on a 1 TB disk. The largest directory on it was a 132.7 GiB uv cache that I had never thought about. The coding agents I use launch MCP servers with <code>uvx</code>, and that was enough to fill it.</p>
<p><code>uvx</code> is uv&rsquo;s command for running a Python tool from a temporary environment, without installing it. If you have never run uv by hand, an agent or editor ran it for you. This page is part of a series on <a href="https://looselydecoupled.com/series/developer-mac-disk-cleanup/">cleaning up a developer Mac&rsquo;s disk</a>. Part one of the series, finding what&rsquo;s filling the disk, is how to know the cache is the cause; you don&rsquo;t need it to follow the steps here.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>uv</dt> <dd>0.11.12</dd></div>
    <div class="tested__row"><dt>macOS</dt> <dd>27.0.1, Apple silicon, APFS</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
<h2 id="check-the-size-and-location-of-the-cache">Check the size and location of the cache<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#check-the-size-and-location-of-the-cache"
     aria-label="Link to section: Check the size and location of the cache" data-pagefind-ignore></a>
</h2>
<p>The environments <code>uvx</code> builds go in uv&rsquo;s cache. Ask uv for the size and the directory:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">warning:</span> <span class="console__token">`uv</span> <span class="console__token">cache</span> <span class="console__token">size`</span> <span class="console__token">is</span> <span class="console__token">experimental</span> <span class="console__token">and</span> <span class="console__token">may</span> <span class="console__token">change</span> <span class="console__token">without</span> <span class="console__token">warning.</span> <span class="console__token">Pass</span> <span class="console__token">`--preview-features</span> <span class="console__token">cache-size`</span> <span class="console__token">to</span> <span class="console__token">disable</span> <span class="console__token">this</span> <span class="console__token">warning.</span>
</span><span class="line"><span class="console__token">132.7GiB</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">dir</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">/Users/&lt;user&gt;/.cache/uv</span></span></code></pre>
    </div>
  </div></div><p>uv labels <code>uv cache size</code> as experimental, which is the reason for the warning. The number is still the one to start from. The <code>--cache-dir</code> option, the <code>UV_CACHE_DIR</code> and <code>XDG_CACHE_HOME</code> variables and the <code>tool.uv.cache-dir</code> setting move the directory, according to <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. Every command below reads the directory from <code>uv cache dir</code>, so it follows a moved cache.</p>
<h2 id="find-what-fills-the-cache">Find what fills the cache<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#find-what-fills-the-cache"
     aria-label="Link to section: Find what fills the cache" data-pagefind-ignore></a>
</h2>
<p>Environments that <code>uvx</code> builds sit in the cache&rsquo;s <code>archive-v0</code> directory. Count its entries, then count the ones that are Python environments, which have a <code>pyvenv.cfg</code> at their root:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">find</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">-maxdepth</span> <span class="console__token">2</span> <span class="console__token">-name</span> <span class="console__token">pyvenv.cfg</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span></span></code></pre>
    </div>
  </div></div><p>On this Mac, <code>archive-v0</code> held 4,005 entries, and 2,562 of them were environments. To see which MCP servers the environments belong to, count the executables with <code>mcp</code> in their names in the environments&rsquo; <code>bin</code> directories (a server named otherwise needs its own pattern):</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-d</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0/*/bin/*mcp*</span> <span class="console__token">|</span> <span class="console__token">xargs</span> <span class="console__token">-n1</span> <span class="console__token">basename</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">|</span> <span class="console__token">uniq</span> <span class="console__token">-c</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-rn</span> <span class="console__token">|</span> <span class="console__token">head</span></span></code></pre>
    </div>
  </div></div><p>Nearly all of the environments were copies of three MCP servers, with 1,150, 1,143 and 191 copies, plus 50 of a fourth. One server&rsquo;s environments held only 158 distinct sets of installed packages, and environments built on the same day had the same installed packages.</p>
<h3 id="why-each-launch-can-add-an-environment">Why each launch can add an environment<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#why-each-launch-can-add-an-environment"
     aria-label="Link to section: Why each launch can add an environment" data-pagefind-ignore></a>
</h3>
<p>An MCP server configured as <code>uvx &lt;server&gt;@latest</code> asks uv to resolve the latest version on every launch. With <code>uvx -v</code>, the log shows uv fetching the latest version and revalidating the index. Launching one server did not explain more than a thousand copies, so I measured when a launch adds an environment:</p>
<div class="table-wrap" data-scrollable data-label="Table: Launches">
  <table>
    <thead>
      <tr><th scope="col">Launches</th><th scope="col">Resolution</th><th class="align-right" scope="col">Environments added</th></tr>
    </thead>
    <tbody>
      <tr><td>One</td><td>New (not cached yet)</td><td class="align-right">1</td></tr>
      <tr><td>Four at the same moment</td><td>New</td><td class="align-right">4</td></tr>
      <tr><td>Four at the same moment</td><td>Already cached</td><td class="align-right">0</td></tr>
      <tr><td>Four, <code>@latest</code> and plain</td><td>Already cached</td><td class="align-right">0</td></tr>
    </tbody>
  </table>
</div>
<p>When several sessions start together and the resolution isn&rsquo;t cached yet, each launch builds its own environment. Once the resolution is cached, launches add nothing. That implies pinning a version, instead of <code>@latest</code>, would stop most of the copies. I didn&rsquo;t test that. I also didn&rsquo;t check whether a later release of uv reuses an environment that another process built moments earlier. All of this is uv 0.11.12.</p>
<h2 id="choose-between-uv-cache-prune-and-uv-cache-clean">Choose between uv cache prune and uv cache clean<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#choose-between-uv-cache-prune-and-uv-cache-clean"
     aria-label="Link to section: Choose between uv cache prune and uv cache clean" data-pagefind-ignore></a>
</h2>
<p><code>uv cache prune</code> removes the cache entries nothing uses, and all centralized project environments, which uv recreates as needed. <code>uv cache clean</code> removes every entry and empties the cache; <code>uv cache clean &lt;package&gt;</code> removes one package&rsquo;s entries. Both are described in <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. This page uses prune, which here took the cache from 132.7 GiB to 3.9 GiB and left 540 entries in place. I didn&rsquo;t run clean.</p>
<h2 id="quit-every-session-that-holds-the-cache-lock">Quit every session that holds the cache lock<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#quit-every-session-that-holds-the-cache-lock"
     aria-label="Link to section: Quit every session that holds the cache lock" data-pagefind-ignore></a>
</h2>
<p>Each running <code>uvx</code> process holds the cache lock, the <code>.lock</code> file in the cache directory, for as long as its server runs. Cache-modifying commands wait for the lock, so a prune started now waits too. List the holders:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">COMMAND</span>   <span class="console__token">PID</span>    <span class="console__token">USER</span>   <span class="console__token">FD</span>   <span class="console__token">TYPE</span> <span class="console__token">DEVICE</span> <span class="console__token">SIZE/OFF</span>    <span class="console__token">NODE</span> <span class="console__token">NAME</span>
</span><span class="line"><span class="console__token">uv</span>      <span class="console__token">&lt;pid&gt;</span> <span class="console__token">&lt;user&gt;</span>   <span class="console__token">9u</span>   <span class="console__token">REG</span>  <span class="console__token">&lt;dev&gt;</span>        <span class="console__token">0</span> <span class="console__token">&lt;inode&gt;</span> <span class="console__token">/Users/&lt;user&gt;/.cache/uv/.lock</span>
</span><span class="line"><span class="console__token">uv</span>      <span class="console__token">&lt;pid&gt;</span> <span class="console__token">&lt;user&gt;</span>   <span class="console__token">9u</span>   <span class="console__token">REG</span>  <span class="console__token">&lt;dev&gt;</span>        <span class="console__token">0</span> <span class="console__token">&lt;inode&gt;</span> <span class="console__token">/Users/&lt;user&gt;/.cache/uv/.lock</span></span></code></pre>
    </div>
  </div></div><p><code>lsof</code> prints nothing when the lock is free; empty output from a path that is not your cache directory is not a clear lock. Each row is one <code>uvx</code> process. To find the session that started it, look up the parent of each <code>&lt;pid&gt;</code>:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ps</span> <span class="console__token">-o</span> <span class="console__token">ppid=</span> <span class="console__token">-p</span> <span class="console__token">&lt;pid&gt;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">ps</span> <span class="console__token">-o</span> <span class="console__token">comm=</span> <span class="console__token">-p</span> <span class="console__token">&lt;ppid&gt;</span></span></code></pre>
    </div>
  </div></div><p>On this Mac, one holder was launched by a Claude Code session and another by the VS Code extension host. Quitting a session or an editor ends its <code>uvx</code> children, which release the lock:</p>
<ol>
<li>Run <code>lsof &quot;$(uv cache dir)/.lock&quot;</code>.</li>
<li>Look up the parent of each <code>&lt;pid&gt;</code>.</li>
<li>Quit that session or editor.</li>
<li>Run <code>lsof</code> again until it prints nothing.</li>
</ol>
<h2 id="record-free-space-before-pruning">Record free space before pruning<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#record-free-space-before-pruning"
     aria-label="Link to section: Record free space before pruning" data-pagefind-ignore></a>
</h2>
<p>The comparison needs readings from before the prune, so take them now and save the output:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span></span></code></pre>
    </div>
  </div></div><h2 id="run-the-prune">Run the prune<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#run-the-prune"
     aria-label="Link to section: Run the prune" data-pagefind-ignore></a>
</h2>
<p>This command deletes cached environments. With nothing running, it removed every environment in the cache, not only the ones that no process referenced. The next launch of each MCP server builds a fresh environment. Run it from a plain Terminal window, not from inside one of the sessions you quit:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">prune</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Pruning</span> <span class="console__token">cache</span> <span class="console__token">at:</span> <span class="console__token">.cache/uv</span>
</span><span class="line"><span class="console__token">Removed</span> <span class="console__token">7278379</span> <span class="console__token">files</span> <span class="console__token">(111.1GiB)</span></span></code></pre>
    </div>
  </div></div><p>It took 409 seconds. Do not interrupt it.</p>
<h2 id="check-the-space-you-got-back">Check the space you got back<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#check-the-space-you-got-back"
     aria-label="Link to section: Check the space you got back" data-pagefind-ignore></a>
</h2>
<p>The free space went up by much less than 111.1 GiB. Run the same three commands again and compare them with the readings you saved:</p>
<div class="table-wrap" data-scrollable data-label="Table: Reading">
  <table>
    <thead>
      <tr><th scope="col">Reading</th><th scope="col">Before</th><th scope="col">After</th></tr>
    </thead>
    <tbody>
      <tr><td><code>df -h /</code>, available</td><td>36Gi</td><td>61Gi</td></tr>
      <tr><td>Container Free Space</td><td>38.4 GB (38,372,454,400 bytes)</td><td>65.8 GB (65,847,853,056 bytes)</td></tr>
      <tr><td><code>uv cache size -H</code></td><td>132.7GiB</td><td>3.9GiB</td></tr>
    </tbody>
  </table>
</div>
<p>The container gained 27.5 GB. uv reported 111.1 GiB removed, which is about 119 GB. uv prints binary units (GiB, 2^30 bytes) and <code>diskutil</code> prints decimal ones (GB), so do not compare the two figures as equals. Even in the same unit, the disk got back about a quarter of what uv reported.</p>
<p>The explanation that fits is the link mode; I didn&rsquo;t test it directly. uv&rsquo;s default on macOS is <code>clone</code>, per <a href="https://docs.astral.sh/uv/reference/settings/#link-mode" rel="external noopener" target="_blank">uv&rsquo;s settings reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>, which makes APFS copy-on-write clones. Environments share blocks with each other and with the cached wheels. uv 0.11.12 counts every cloned file at its full size, and deleting a clone frees nothing when another file still uses its blocks. <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> describes a <code>cache-physical-space</code> preview feature that accounts for copy-on-write clones; I didn&rsquo;t test it.</p>
<p><code>du -sh</code> agreed with <code>uv cache size</code> at 3.9G after the prune. That is a drop of 128.8 GiB from 132.7GiB, while prune printed 111.1GiB removed; I did not find why the two differ.</p>
<p>A local snapshot could also hold space after a delete. On this Mac, <code>tmutil listlocalsnapshots /</code> listed none, and <code>diskutil apfs listSnapshots</code> showed none on the data volume, so no snapshot was holding any.</p>
<h2 id="confirm-your-mcp-servers-still-start">Confirm your MCP servers still start<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#confirm-your-mcp-servers-still-start"
     aria-label="Link to section: Confirm your MCP servers still start" data-pagefind-ignore></a>
</h2>
<p>The prune left <code>archive-v0</code> with 540 entries and no environments, and emptied <code>environments-v2</code>. Start each agent or editor that uses MCP servers and open a session. Each server builds a fresh environment on its next launch; on this Mac, one appeared at the next session start. Check:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">find</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">-maxdepth</span> <span class="console__token">2</span> <span class="console__token">-name</span> <span class="console__token">pyvenv.cfg</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span></span></code></pre>
    </div>
  </div></div><p>A count above zero means at least one server built an environment. Check each agent&rsquo;s MCP server list or status view for errors.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>Paste this prompt into your own local coding agent. It measures and reports, and it proposes before anything is removed:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Investigate my uv cache on this Mac. Measure and report only; change nothing yet.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Report the cache directory (uv cache dir) and its size (uv cache size -H).
</span></span><span class="line"><span class="cl">2. Report how many entries in archive-v0 are Python environments (a pyvenv.cfg
</span></span><span class="line"><span class="cl">   at the root), and which tools they belong to (the executables in their bin
</span></span><span class="line"><span class="cl">   directories).
</span></span><span class="line"><span class="cl">3. Report free space two ways: df -h / and diskutil info / (Container Free
</span></span><span class="line"><span class="cl">   Space).
</span></span><span class="line"><span class="cl">4. List every process holding the cache lock (lsof on the .lock file in the
</span></span><span class="line"><span class="cl">   cache directory), and for each one its parent process, so I can tell which
</span></span><span class="line"><span class="cl">   agent or editor session to quit.
</span></span><span class="line"><span class="cl">5. Separate what is safe from what is in use: running uv processes, open agent
</span></span><span class="line"><span class="cl">   or editor sessions, and any project whose own virtual environment lives
</span></span><span class="line"><span class="cl">   inside the cache. If this session is itself one of the lock holders, say so
</span></span><span class="line"><span class="cl">   and stop; I will run the prune from a plain Terminal.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then propose each removal as a separate item, with the command and the current
</span></span><span class="line"><span class="cl">cache size as an upper bound. uv has no dry run, so do not state an expected
</span></span><span class="line"><span class="cl">figure, and note that APFS clones make the real gain smaller. Remove nothing until I approve that item. Remove only
</span></span><span class="line"><span class="cl">through uv&#39;s own command (uv cache prune). Never delete files under the cache
</span></span><span class="line"><span class="cl">directory by hand and never use --force. Afterwards, report free space again
</span></span><span class="line"><span class="cl">with the same two readings and say how far the figure uv printed is from the
</span></span><span class="line"><span class="cl">space actually gained.</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="cache-is-currently-in-use-waiting-for-other-uv-processes-to-finish">Cache is currently in-use, waiting for other uv processes to finish<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#cache-is-currently-in-use-waiting-for-other-uv-processes-to-finish"
     aria-label="Link to section: Cache is currently in-use, waiting for other uv processes to finish" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Cache</span> <span class="console__token">is</span> <span class="console__token">currently</span> <span class="console__token">in-use,</span> <span class="console__token">waiting</span> <span class="console__token">for</span> <span class="console__token">other</span> <span class="console__token">uv</span> <span class="console__token">processes</span> <span class="console__token">to</span> <span class="console__token">finish</span> <span class="console__token">(use</span> <span class="console__token">`--force`</span> <span class="console__token">to</span> <span class="console__token">override)</span></span></code></pre>
    </div>
  </div></div><p>Another uv process holds the cache lock, and <code>uv cache prune</code> and <code>uv cache clean</code> wait for it. Run <code>lsof &quot;$(uv cache dir)/.lock&quot;</code>, quit the session that owns each holder as described earlier, and run the command again. Nothing was changed while it waited: I stopped a prune that had waited eight seconds, and the cache was unchanged.</p>
<p>These <code>uv cache</code> commands give up after a timeout; <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> gives its value and the <code>UV_LOCK_TIMEOUT</code> environment variable that changes it. Waiting longer does not help while a session still runs its server.</p>
<p>Do not use <code>--force</code>. The documentation reserves it for cases where no other uv process is reading or writing the cache, and a running <code>uvx</code> server is exactly such a process.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>What&#39;s Taking Up All the Space on My Developer Mac?</title>
      <link>https://looselydecoupled.com/mac-disk-full-developer-caches/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/mac-disk-full-developer-caches/</guid>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Python</category>
      
      <description>Find which developer tool is filling your Mac&#39;s disk, such as uv, Xcode or Docker, and which cleanup applies.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Record the free space, then narrow down one level at a time with <code>du</code> until a
directory belongs to one tool. That tells you which cleanup you need. Clean it
with that tool&rsquo;s own command where it has one, never with <code>rm</code> on a cache a
tool manages, and record the free space again; the cleanup&rsquo;s own report is not what the Mac got back.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span></span></code></pre>
    </div>
  </div></div><div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>macOS</dt> <dd>27.0.1, MacBook Pro (Apple silicon)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>

</div>

<p>My Mac had 37.7 GB free on a 1 TB disk. I expected Xcode to be the reason,
since I keep a full set of iOS simulators, and Xcode did hold 79 GiB. The
largest directory was a 132 GiB uv cache, which I had never thought about: most
of the entries in it were environments <code>uvx</code> had built for MCP servers,
covered in part 2.
Which directory is largest on your Mac depends on what you build and which
tools you run. The method for finding it is below, and the
<a href="https://looselydecoupled.com/series/developer-mac-disk-cleanup/">series</a> covers each cause.</p>
<h2 id="record-the-free-space-before-you-start">Record the free space before you start<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#record-the-free-space-before-you-start"
     aria-label="Link to section: Record the free space before you start" data-pagefind-ignore></a>
</h2>
<p>On a modern Mac, <code>df -h /</code> reports the sealed system volume, so its <code>Used</code>
column is misleading:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Filesystem</span>        <span class="console__token">Size</span>    <span class="console__token">Used</span>   <span class="console__token">Avail</span> <span class="console__token">Capacity</span> <span class="console__token">iused</span> <span class="console__token">ifree</span> <span class="console__token">%iused</span>  <span class="console__token">Mounted</span> <span class="console__token">on</span>
</span><span class="line"><span class="console__token">/dev/disk3s1s1</span>   <span class="console__token">926Gi</span>    <span class="console__token">13Gi</span>    <span class="console__token">40Gi</span>    <span class="console__token">24%</span>    <span class="console__token">484k</span>  <span class="console__token">423M</span>    <span class="console__token">0%</span>   <span class="console__token">/</span></span></code></pre>
    </div>
  </div></div><p><code>/</code> is the sealed system snapshot. Your files live on the Data volume in the
same APFS container. Every volume in a container draws on one pool of free
space, so <code>Used</code> for <code>/</code> counts the system alone, and <code>Avail</code> is the pool. The
container&rsquo;s own figure comes from the startup disk&rsquo;s container only, so an
external APFS drive cannot be mistaken for it:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">   <span class="console__token">Container</span> <span class="console__token">Free</span> <span class="console__token">Space:</span>      <span class="console__token">65.8</span> <span class="console__token">GB</span> <span class="console__token">(65847853056</span> <span class="console__token">Bytes)</span> <span class="console__token">(exactly</span> <span class="console__token">128609088</span> <span class="console__token">512-Byte-Units)</span></span></code></pre>
    </div>
  </div></div><p>This reading was taken after the cleanup, so it does not match the <code>df</code>
output above, which was taken partway through, or the 37.7 GB before any
cleanup. <code>df -h</code> prints GiB and
<code>diskutil</code> prints GB, so the same moment looks different in the two units. Use one of them for every reading, or the difference
between the units looks like progress. A Mac keeps writing while you work, so
treat a change of a gigabyte or two as noise.</p>
<h2 id="find-whats-taking-up-space-with-du">Find what&rsquo;s taking up space with du<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#find-whats-taking-up-space-with-du"
     aria-label="Link to section: Find what’s taking up space with du" data-pagefind-ignore></a>
</h2>
<p><code>du</code> reads sizes and changes nothing. <code>2&gt;/dev/null</code> hides the permission
errors that protected directories print.</p>
<!-- style-check: procedure -->
<ol>
<li>
<p>List the largest directories in your home directory:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>Skip the first line, which is the total for the directory you ran it on.
Run the same command on the largest directory below it, and repeat until
the directory belongs to one tool. On this Mac, that took three levels:
<code>~/.cache</code>, then <code>~/.cache/uv</code>, then <code>~/.cache/uv/archive-v0</code>.</p>
</li>
<li>
<p>Check the two places that hide the most, even if they didn&rsquo;t top the first
list:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~/Library/Caches</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~/Library/Developer</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>For a package manager, ask the tool where its cache is rather than
assuming a path. pip on macOS uses <code>~/Library/Caches/pip</code>, not
<code>~/.cache/pip</code>:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">dir</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">/Users/&lt;user&gt;/.cache/uv</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">-m</span> <span class="console__token">pip</span> <span class="console__token">cache</span> <span class="console__token">dir</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">/Users/&lt;user&gt;/Library/Caches/pip</span></span></code></pre>
    </div>
  </div></div></li>
</ol>
<!-- style-check: end procedure -->
<p>A <code>du</code> total is an upper bound on what deleting frees. APFS clones share
their blocks with the original until one of them changes, and <code>du</code> counts a
cloned file at full size wherever it appears.</p>
<h2 id="match-the-directory-to-its-cause">Match the directory to its cause<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#match-the-directory-to-its-cause"
     aria-label="Link to section: Match the directory to its cause" data-pagefind-ignore></a>
</h2>
<p>These are the directories that were large on this Mac, and the part of this
series that covers each one:</p>
<div class="table-wrap" data-scrollable data-label="Table: Directory and what fills it">
  <table>
    <thead>
      <tr><th scope="col">Directory and what fills it</th><th class="align-right" scope="col">Size here</th><th scope="col">Covered in</th></tr>
    </thead>
    <tbody>
      <tr><td><code>~/.cache/uv</code><br>Environments that <code>uvx</code> builds for MCP servers</td><td class="align-right">132 GiB</td><td>Part 2</td></tr>
      <tr><td><code>~/Library/Developer/CoreSimulator/Devices</code><br>iOS simulators and the apps installed in them</td><td class="align-right">44 GiB</td><td>Part 5</td></tr>
      <tr><td><code>~/Library/Developer/Xcode/iOS DeviceSupport</code><br>Debug symbols for each device and iOS version you&rsquo;ve connected</td><td class="align-right">35 GiB</td><td>Part 5</td></tr>
      <tr><td><code>~/Library/Containers/com.docker.docker</code><br>Docker Desktop&rsquo;s VM disk, <code>Docker.raw</code></td><td class="align-right">32 GiB</td><td>Part 3</td></tr>
      <tr><td><code>~/.npm/_npx</code><br>Packages that <code>npx</code> runs, MCP servers among them</td><td class="align-right">8.9 GiB</td><td>Part 4</td></tr>
      <tr><td><code>.build</code>, <code>node_modules</code>, <code>.venv</code> in your projects<br>Build output and dependencies, including projects you no longer work on</td><td class="align-right">varies</td><td>Part 6</td></tr>
      <tr><td><code>~/Library/Caches/Homebrew</code><br>Cached downloads of formulae and casks</td><td class="align-right">1.1 GiB</td><td><a href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs">Below</a></td></tr>
      <tr><td><code>~/.ollama/logs</code><br>Ollama&rsquo;s server and app logs</td><td class="align-right">947 MB</td><td><a href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs">Below</a></td></tr>
    </tbody>
  </table>
</div>
<p>For the uv cache, read <a href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/">part 2</a>
before you prune: a running coding session holds a lock that makes the prune
wait, and the prune reports far more than the disk gets back. The other parts
are listed on the series page.</p>
<p>Some directories stay whatever their size. A Docker volume holds an app&rsquo;s
data, not cache. An app&rsquo;s <code>Application Support</code> directory holds its state.</p>
<h2 id="clear-homebrew-downloads-and-ollama-logs">Clear Homebrew downloads and Ollama logs<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs"
     aria-label="Link to section: Clear Homebrew downloads and Ollama logs" data-pagefind-ignore></a>
</h2>
<p>Two directories on the list need no part of their own.</p>
<p><code>brew cleanup -s</code> removes old versions and the cached downloads of anything no
longer installed. It keeps the downloads of what is installed now, so on this
Mac it freed about 270 MB of the 1.1 GiB:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">brew</span> <span class="console__token">cleanup</span> <span class="console__token">-s</span></span></code></pre>
    </div>
  </div></div><p>An app can keep its data somewhere else and still write its logs to the
internal disk. My Ollama models live on an external SSD, linked into place:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-ld</span> <span class="console__token">~/.ollama/models</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">lrwxr-xr-x</span>  <span class="console__token">1</span> <span class="console__token">&lt;user&gt;</span>  <span class="console__token">staff</span>  <span class="console__token">...</span> <span class="console__token">/Users/&lt;user&gt;/.ollama/models</span> <span class="console__token">-&gt;</span> <span class="console__token">/Volumes/&lt;drive&gt;/ollama/models</span></span></code></pre>
    </div>
  </div></div><p>The logs aren&rsquo;t linked. List them first:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-lh</span> <span class="console__token">~/.ollama/logs</span></span></code></pre>
    </div>
  </div></div><p>Here they had reached 947 MB in two files, <code>server-1.log</code> and <code>app-1.log</code>.
Yours may have other names. Ollama has no cleanup command for them, so quit
Ollama and delete the files that list showed, by name:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">rm</span> <span class="console__token">~/.ollama/logs/server-1.log</span> <span class="console__token">~/.ollama/logs/app-1.log</span></span></code></pre>
    </div>
  </div></div><p>Deleting a log leaves the models and the settings as they are.</p>
<p>Ollama wasn&rsquo;t in Open at Login, but App Background Activity, under System
Settings &gt; General &gt; Login Items, allowed it to run in the background. I
turned that off. I didn&rsquo;t check whether background runs are what filled the
logs.</p>
<h2 id="record-the-free-space-again">Record the free space again<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#record-the-free-space-again"
     aria-label="Link to section: Record the free space again" data-pagefind-ignore></a>
</h2>
<p>Run the same command you started with:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span></span></code></pre>
    </div>
  </div></div><p>Here it went from 37.7 GB before any cleanup to 65.8 GB after the uv prune.
The cleanup commands had reported more than 140 GB between them. A cleanup
command reports what it removed from its own storage; only the container&rsquo;s
figure says what the Mac got back.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>Paste this prompt into your own local coding agent. It finds and reports, and
it deletes nothing:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Find what is taking up space on this Mac&#39;s internal disk. Measure and report
</span></span><span class="line"><span class="cl">only; delete, prune or clean nothing. Stay in my home directory, and don&#39;t
</span></span><span class="line"><span class="cl">follow links to external drives.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Report free space with diskutil info / (Container Free Space) and
</span></span><span class="line"><span class="cl">   df -h /, and say which units each prints.
</span></span><span class="line"><span class="cl">2. Run du -hd 1 on my home directory, then on the largest subdirectory it
</span></span><span class="line"><span class="cl">   lists (not the total line for the directory itself), and keep going down
</span></span><span class="line"><span class="cl">   until each large directory belongs to one tool. Also check
</span></span><span class="line"><span class="cl">   ~/Library/Caches and ~/Library/Developer. Ask package managers for their
</span></span><span class="line"><span class="cl">   cache paths (for example uv cache dir, python3 -m pip cache dir) instead of
</span></span><span class="line"><span class="cl">   assuming them.
</span></span><span class="line"><span class="cl">3. For each directory over 1 GB, report its size, which tool owns it, what
</span></span><span class="line"><span class="cl">   fills it, and the tool&#39;s own cleanup command if it has one.
</span></span><span class="line"><span class="cl">4. Mark anything that is in use or holds work: running processes using the
</span></span><span class="line"><span class="cl">   directory (lsof), open coding-agent or editor sessions, uncommitted or
</span></span><span class="line"><span class="cl">   unpushed changes in any git checkout, Docker volumes, app data under
</span></span><span class="line"><span class="cl">   Application Support. Say what would be lost if each were removed.
</span></span><span class="line"><span class="cl">5. Note that du counts APFS clones at full size, so a total is an upper bound
</span></span><span class="line"><span class="cl">   on what deleting frees.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then stop and wait. I will choose what to clean.</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="free-space-barely-moved-after-a-cleanup">Free space barely moved after a cleanup<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#free-space-barely-moved-after-a-cleanup"
     aria-label="Link to section: Free space barely moved after a cleanup" data-pagefind-ignore></a>
</h3>
<p>Docker reports space inside its VM disk, and <code>du</code> and <code>uv cache size</code> count
cloned files at full size. Measure the container with <code>diskutil info /</code>
before and after; that is the only figure that says what the Mac got back.</p>
<p>With Time Machine on, a local snapshot can keep a deleted file&rsquo;s space in use.
Time Machine keeps hourly snapshots for 24 hours and deletes them as they age
or as space is needed, per <a href="https://support.apple.com/en-us/102154" rel="external noopener" target="_blank">Apple&rsquo;s Time Machine guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/mac-disk-full-developer-caches/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.
List them; on this Mac the list was empty:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">tmutil</span> <span class="console__token">listlocalsnapshots</span> <span class="console__token">/</span></span></code></pre>
    </div>
  </div></div><h3 id="uv-cache-prune-waits-then-times-out">uv cache prune waits, then times out<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#uv-cache-prune-waits-then-times-out"
     aria-label="Link to section: uv cache prune waits, then times out" data-pagefind-ignore></a>
</h3>
<p><code>uv cache prune</code> waits while any other uv process is running, and every <code>uvx</code>
server counts. After five minutes by default (<code>UV_LOCK_TIMEOUT</code>) it gives up;
the <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/mac-disk-full-developer-caches/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> covers
the lock. Quit the coding sessions that started the servers, then run it again.
To see what holds the lock:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span></span></code></pre>
    </div>
  </div></div><p><a href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/">Part 2</a> shows how to find which session
holds it.</p>
<h3 id="npm-cache-clean-left-npm-large">npm cache clean left ~/.npm large<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#npm-cache-clean-left-npm-large"
     aria-label="Link to section: npm cache clean left ~/.npm large" data-pagefind-ignore></a>
</h3>
<p><code>npm cache clean --force</code> empties npm&rsquo;s package cache, <code>_cacache</code>. The space
is in <code>_npx</code>, where npx
keeps the packages it runs; part 4 covers it.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Make a Hyperlapse from Keyframes with ffmpeg Without Decoding Every Frame</title>
      <link>https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>macOS</category>
      
      <description>With one keyframe per second, -skip_frame nokey decodes 1 frame in 30 and setpts makes each one a frame at 30 fps: a 36-minute drive becomes 72 seconds.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>When a video has one keyframe per second, decode only the keyframes and give
each one a frame at 30 fps. The result plays 30 times faster than the drive:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">-vf</span> <span class="console__token">&#34;setpts=N/(30*TB)&#34;</span> <span class="console__token">-r</span> <span class="console__token">30</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;clip&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p><code>-skip_frame nokey</code> goes before <code>-i</code>, because it tells the decoder to discard
every frame that isn&rsquo;t a keyframe. <code>setpts=N/(30*TB)</code> gives frame <code>N</code> the
timestamp <code>N/30</code> seconds, so one second of driving becomes one frame of output.</p>
<p>To keep several cameras in sync, render each recording segment separately, pad
every camera&rsquo;s segment with black to the front camera&rsquo;s length, and join the
segments with a stream copy. The script later in this article does that.</p>

</div>

<p>A Rivian R1T films five directions for as long as it&rsquo;s driven. I wanted a
hyperlapse of every drive from a summer of footage, one file per camera, so a
drive could be cut as a multi-camera edit at 30× speed. An ordinary hyperlapse
decodes 30 frames to keep one, and this footage didn&rsquo;t need that: the truck
writes one keyframe every second. Reading only those, a 36-minute drive became
72 seconds of video and took 4.4 minutes to render, with one drive rendering at
a time. The hyperlapses for 218 drives, five cameras each, came to 107 GB.</p>
<p>This is part of a series about reading dashcam video without watching it.
<a href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/">How to sort months of dashcam footage with Claude
Code</a> is the hub, with the prompts and
the checks that drove the whole job.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>ffmpeg</dt> <dd>9.0.1</dd></div>
    <div class="tested__row"><dt>macOS</dt> <dd>27.0, Apple silicon</dd></div>
    <div class="tested__row"><dt>Footage</dt> <dd>Rivian dashcam, H.264, 30 fps, one keyframe per second</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
<h2 id="why-keyframes-are-enough">Why keyframes are enough<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#why-keyframes-are-enough"
     aria-label="Link to section: Why keyframes are enough" data-pagefind-ignore></a>
</h2>
<p>The Rivian&rsquo;s clips are H.264 at 30 fps with I- and P-frames only, and one
keyframe (an I-frame) every second. A keyframe decodes on its own. A P-frame
needs the frames before it, so taking every 30th frame of an ordinary decode
still means decoding the other 29.</p>
<p>With <code>-skip_frame nokey</code> the decoder drops the P-frames before decoding them.
What reaches the filter is one picture per second of driving, already spaced
evenly in time. The <code>setpts=N/(30*TB)</code> filter discards their timestamps and numbers them
off at 30 per second. <code>N</code> is the frame&rsquo;s index and <code>TB</code> is the stream&rsquo;s time
base. Without <code>setpts</code>, the output has the same frames, each still held for a
second, and plays at the speed of the drive.</p>
<p>The speed this produces is fixed by the footage: one keyframe per second, shown
at 30 fps, is 30×. Footage with a keyframe every two seconds would come out at
60× from the same command.</p>
<figure><img src="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/hyperlapse-frames-floating-bridge_hu_316ec0d3423d1748.jpg" alt="Eight front-camera frames from a 30× hyperlapse, one per second of output. Each step covers 30 seconds of driving. The frames show the I-90 floating bridge under an overcast sky, the orange light of the Mount Baker tunnel, and the Mercer Island lid." width="1600" height="640" loading="lazy" decoding="async"><figcaption>Eight frames of a hyperlapse, 30 frames apart. Plates blurred.</figcaption>
</figure>
<!-- style-check: procedure -->
<h2 id="check-the-keyframe-rhythm">Check the keyframe rhythm<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#check-the-keyframe-rhythm"
     aria-label="Link to section: Check the keyframe rhythm" data-pagefind-ignore></a>
</h2>
<p>Before relying on it, count the keyframes in one clip. In a terminal, run:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-show_entries</span> <span class="console__token">frame=pts_time</span> <span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-3</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">0.000000</span>
</span><span class="line"><span class="console__token">1.000000</span>
</span><span class="line"><span class="console__token">2.000000</span></span></code></pre>
    </div>
  </div></div><p>Pipe the same command to <code>wc -l</code> instead of <code>head</code> for the count. On a
351-second clip it printed <code>351</code>, and a full 15-minute segment from the truck
has 900. On some drives one camera&rsquo;s keyframes fall at 0.967 s past each
second rather than on it, and which camera varies by drive. The spacing is
still one second, which is what the speed depends on.</p>
<p>If the times are not one second apart, the hyperlapse still renders, but its
speed is the keyframe interval times 30 rather than 30×.</p>
<h2 id="render-one-clip">Render one clip<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#render-one-clip"
     aria-label="Link to section: Render one clip" data-pagefind-ignore></a>
</h2>
<p>In the folder that holds the clip, run:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-vf</span> <span class="console__token">&#34;setpts=N/(30*TB)&#34;</span> <span class="console__token">-r</span> <span class="console__token">30</span> <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;clip&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p>It writes <code>&lt;clip&gt;_30x.mp4</code> beside the source and changes nothing else. On a
351-second clip of a bridge crossing, the output was 351 frames and 11.7
seconds long, 11.2 MB from a 438.8 MB source.</p>
<p>The encoder options are the part to change on another machine:</p>
<ul>
<li><code>hevc_videotoolbox</code> is the Mac&rsquo;s hardware HEVC encoder. The Homebrew build
of ffmpeg used here includes it; <code>ffmpeg -encoders | grep videotoolbox</code>
lists it if yours does.</li>
<li><code>-q:v 65</code> asks it for constant quality rather than a bitrate.</li>
<li><code>-tag:v hvc1</code> labels the HEVC stream the way QuickTime and Apple&rsquo;s players
expect.</li>
<li><code>-color_range pc</code> labels the output full range like the footage, which the
black filler and the join depend on. Apple&rsquo;s encoder ignores
<code>-pix_fmt yuvj420p</code> with a warning, so it isn&rsquo;t set here.</li>
<li><code>-map_metadata -1</code> drops the source&rsquo;s tags, including its GPS fix.</li>
<li><code>-an</code> writes no audio track. The truck records none.</li>
</ul>
<h2 id="pad-each-segment-to-the-front-cameras-length">Pad each segment to the front camera&rsquo;s length<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#pad-each-segment-to-the-front-cameras-length"
     aria-label="Link to section: Pad each segment to the front camera’s length" data-pagefind-ignore></a>
</h2>
<p>The truck splits a drive into 15-minute segments, one file per camera per
segment. All five cameras&rsquo; files for a segment have the same length, unless a
camera stopped recording. If one did, that camera&rsquo;s segment has fewer
keyframes, its hyperlapse has fewer frames, and every later frame of that angle
plays early. A camera that is missing a whole segment is worse.</p>
<p>When I first rendered every camera of one drive, two came out at 1,303 frames
against the front camera&rsquo;s 2,170. The rear camera had stopped 33
seconds into a segment and recorded the rest of the drive <a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">in a sibling
folder</a>.</p>
<p>Pad every camera&rsquo;s segment to the front camera&rsquo;s frame count with black, so
each segment ends at the same frame on every camera. The numbers in these steps
are an example, a 351-keyframe front segment and a camera that stopped 33
seconds in:</p>
<ol>
<li>
<p>Count the front camera&rsquo;s keyframes for the segment, as in the first step.
On a 351-second segment, that&rsquo;s 351.</p>
</li>
<li>
<p>Count the other camera&rsquo;s keyframes for the same segment. A clip that
stopped 33 seconds in has 33.</p>
</li>
<li>
<p>Render that camera&rsquo;s segment with <code>tpad</code> adding the difference as black
frames, and <code>-frames:v</code> capping it at the front camera&rsquo;s count:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-i</span> <span class="console__token">&lt;rear-segment&gt;.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-vf</span> <span class="console__token">&#34;setpts=N/(30*TB),tpad=stop=318:color=black&#34;</span> <span class="console__token">-r</span> <span class="console__token">30</span> <span class="console__token">-frames:v</span> <span class="console__token">351</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;rear-segment&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div></li>
</ol>
<p>The <code>-frames:v</code> cap also trims a camera that recorded slightly longer than the
front one.</p>
<h2 id="fill-a-missing-segment-with-full-range-black">Fill a missing segment with full-range black<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#fill-a-missing-segment-with-full-range-black"
     aria-label="Link to section: Fill a missing segment with full-range black" data-pagefind-ignore></a>
</h2>
<p>When a camera has no file at all for a segment, render a black part of the same
size and frame count. The front, rear and side cameras are 1920 × 1536; the
Gear Guard bed camera is 1280 × 720.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-f</span> <span class="console__token">lavfi</span> <span class="console__token">-i</span> <span class="console__token">&#34;color=black:s=1920x1536:r=30&#34;</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-frames:v</span> <span class="console__token">351</span> <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;missing-segment&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p>The filler must be full range, the same as the footage. <code>-color_range pc</code> is
what makes it so: without it, generated black came out labeled limited range
(<code>tv</code>), and <code>-pix_fmt yuvj420p</code> doesn&rsquo;t help, because Apple&rsquo;s encoder ignores it. The decoded footage is already full
range, so the same options are safe on every part.</p>
<p>A mismatch doesn&rsquo;t make the next step fail. The join is a stream copy, which
converts nothing, and the joined file takes its range label from the first
part. A camera whose file opens on limited-range filler is labeled limited
range throughout, real footage included. That happened to 11 of the more than a thousand
hyperlapse files in my batch, all in folders that held only a fragment of a
drive, and they had to be rendered again. The script checks each part&rsquo;s range before
joining, because the joined file shows only the first part&rsquo;s.</p>
<h2 id="join-the-segments-without-re-encoding">Join the segments without re-encoding<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#join-the-segments-without-re-encoding"
     aria-label="Link to section: Join the segments without re-encoding" data-pagefind-ignore></a>
</h2>
<p>List the parts in order in a text file, one <code>file</code> line each, then join them
with the concat demuxer and a stream copy:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">printf</span> <span class="console__token">&#34;file</span> <span class="console__token">&#39;%s&#39;\n&#34;</span> <span class="console__token">&lt;part-001&gt;.mp4</span> <span class="console__token">&lt;part-002&gt;.mp4</span> <span class="console__token">&gt;</span> <span class="console__token">list.txt</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-f</span> <span class="console__token">concat</span> <span class="console__token">-safe</span> <span class="console__token">0</span> <span class="console__token">-i</span> <span class="console__token">list.txt</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">&lt;camera&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p>Every part was encoded with the same options, so the copy only rewrites the
container. It takes seconds.</p>
<h2 id="script-the-whole-drive">Script the whole drive<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#script-the-whole-drive"
     aria-label="Link to section: Script the whole drive" data-pagefind-ignore></a>
</h2>
<p>The steps combine into one script per camera. It reads the segment list from
the front camera&rsquo;s files, so a camera missing a segment gets black filler for
it. The macOS <code>._</code> files beside the clips on the truck&rsquo;s exFAT drive don&rsquo;t match
its pattern, so they&rsquo;re skipped. The front camera sets every segment&rsquo;s length,
so all five files come out the same length.</p>
<div class="code-block" data-copyable data-copy-label="hyperlapse.sh">
  <div class="code-block__head"><span class="code-block__lang code-block__file">hyperlapse.sh</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">folder</span><span class="o">=</span><span class="nv">$1</span>
</span></span><span class="line"><span class="cl"><span class="nv">camera</span><span class="o">=</span><span class="nv">$2</span>
</span></span><span class="line"><span class="cl"><span class="nv">size</span><span class="o">=</span><span class="si">${</span><span class="nv">3</span><span class="k">:-</span><span class="nv">1920x1536</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">encode</span><span class="o">=(</span>-map_metadata -1 -c:v hevc_videotoolbox -q:v <span class="m">65</span> -tag:v hvc1
</span></span><span class="line"><span class="cl">        -color_range pc -an<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">keyframes<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  ffprobe -v error -skip_frame nokey -select_streams v <span class="se">\
</span></span></span><span class="line"><span class="cl">    -show_entries <span class="nv">frame</span><span class="o">=</span>pts_time -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="p">|</span> wc -l <span class="p">|</span> tr -d <span class="s1">&#39; &#39;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">work</span><span class="o">=</span><span class="k">$(</span>mktemp -d<span class="k">)</span>
</span></span><span class="line"><span class="cl">: &gt; <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/list.txt&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> front in <span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">_frontCenter_&#34;</span>*.mp4<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">segment</span><span class="o">=</span><span class="si">${</span><span class="nv">front</span><span class="p">##*_</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">  <span class="nv">frames</span><span class="o">=</span><span class="k">$(</span>keyframes <span class="s2">&#34;</span><span class="nv">$front</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">source</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">segment</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">part</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/</span><span class="nv">$segment</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> -f <span class="nv">$source</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">shortfall</span><span class="o">=</span><span class="k">$((</span> frames <span class="o">-</span> <span class="k">$(</span>keyframes <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span><span class="k">)</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">((</span> shortfall &lt; <span class="m">0</span> <span class="o">))</span><span class="p">;</span> <span class="k">then</span> <span class="nv">shortfall</span><span class="o">=</span>0<span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    ffmpeg -nostdin -v error -skip_frame nokey -i <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -vf <span class="s2">&#34;setpts=N/(30*TB),tpad=stop=</span><span class="si">${</span><span class="nv">shortfall</span><span class="si">}</span><span class="s2">:color=black&#34;</span> -r <span class="m">30</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -frames:v <span class="s2">&#34;</span><span class="nv">$frames</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">encode</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    ffmpeg -nostdin -v error -f lavfi -i <span class="s2">&#34;color=black:s=</span><span class="si">${</span><span class="nv">size</span><span class="si">}</span><span class="s2">:r=30&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -frames:v <span class="s2">&#34;</span><span class="nv">$frames</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">encode</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nv">range</span><span class="o">=</span><span class="k">$(</span>ffprobe -v error -select_streams v -show_entries <span class="nv">stream</span><span class="o">=</span>color_range <span class="se">\
</span></span></span><span class="line"><span class="cl">    -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="nv">$range</span> !<span class="o">=</span> pc <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">: color_range=</span><span class="nv">$range</span><span class="s2">, expected pc&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;file &#39;</span><span class="nv">$part</span><span class="s2">&#39;&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/list.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">ffmpeg -nostdin -v error -f concat -safe <span class="m">0</span> -i <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/list.txt&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c copy <span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_30x.mp4&#34;</span>
</span></span><span class="line"><span class="cl">rm -r <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>It writes <code>&lt;drive&gt;_&lt;camera&gt;_30x.mp4</code> in the current folder. Run it once per
camera, from the folder where the hyperlapses belong, and pass <code>1280x720</code> as
the third argument for the Gear Guard camera:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">for</span> <span class="console__token">camera</span> <span class="console__token">in</span> <span class="console__token">frontCenter</span> <span class="console__token">rearCenter</span> <span class="console__token">sideLeft</span> <span class="console__token">sideRight;</span> <span class="console__token">do</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">bash</span> <span class="console__token">hyperlapse.sh</span> <span class="console__token">&lt;drive-folder&gt;</span> <span class="console__token">&#34;$camera&#34;;</span> <span class="console__token">done</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">bash</span> <span class="console__token">hyperlapse.sh</span> <span class="console__token">&lt;drive-folder&gt;</span> <span class="console__token">gearGuard</span> <span class="console__token">1280x720</span></span></code></pre>
    </div>
  </div></div><p>The script assumes the front camera recorded every segment. If it didn&rsquo;t,
the segment is missing from every camera&rsquo;s hyperlapse.</p>
<h2 id="confirm-every-camera-has-the-same-frame-count">Confirm every camera has the same frame count<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#confirm-every-camera-has-the-same-frame-count"
     aria-label="Link to section: Confirm every camera has the same frame count" data-pagefind-ignore></a>
</h2>
<p>Same length is the whole point of the padding, so count the frames in each
output, and read the pixel format while you&rsquo;re there:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">for</span> <span class="console__token">f</span> <span class="console__token">in</span> <span class="console__token">*_30x.mp4;</span> <span class="console__token">do</span> <span class="console__token">echo</span> <span class="console__token">&#34;$f</span> <span class="console__token">$(ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-count_packets</span> <span class="console__token">-of</span> <span class="console__token">compact=p=0</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-show_entries</span> <span class="console__token">stream=nb_read_packets,pix_fmt,color_range</span> <span class="console__token">&#34;$f&#34;)&#34;;</span> <span class="console__token">done</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">&lt;drive&gt;_frontCenter_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_gearGuard_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_rearCenter_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_sideLeft_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_sideRight_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span></span></code></pre>
    </div>
  </div></div><p>Every camera of a drive prints the same count, and every file prints
<code>yuvj420p</code> and <code>pc</code>. A camera with fewer frames was not padded. The range here
is only the first part&rsquo;s, because a stream-copy join takes its label from the
first part, so the script checks every part&rsquo;s range before it joins them and
stops on one that isn&rsquo;t <code>pc</code>.</p>
<figure><img src="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/hyperlapse-five-cameras-same-frame_hu_49df17819680e522.jpg" alt="Frame 1,970 of each of the five 30× hyperlapse files of one drive. Every tile shows the same instant on the floating bridge: a box truck and a pickup behind the truck appear in both the rearCenter and gearGuard tiles, and the SUV passing on the left appears in frontCenter." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The same frame index from each camera&rsquo;s hyperlapse. Plates blurred.</figcaption>
</figure>
<h2 id="remove-the-location-before-publishing">Remove the location before publishing<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#remove-the-location-before-publishing"
     aria-label="Link to section: Remove the location before publishing" data-pagefind-ignore></a>
</h2>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>ffmpeg copies the truck&rsquo;s GPS tag to a re-encoded output by default. Every
command here drops it with <code>-map_metadata -1</code>; check the file before you
publish it, as <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a> shows.</p>
</div><p>A hyperlapse runs from the start of a drive to its end. Trim both ends before
you publish one, too.</p>
<!-- style-check: end procedure -->
<h2 id="keyframes-vs-a-setpts-timelapse-of-every-frame">Keyframes vs a setpts timelapse of every frame<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#keyframes-vs-a-setpts-timelapse-of-every-frame"
     aria-label="Link to section: Keyframes vs a setpts timelapse of every frame" data-pagefind-ignore></a>
</h2>
<p>Reading only keyframes is what makes this fast, and it fixes what the
hyperlapse can be.</p>
<p>On the same 351-second clip, I rendered the 30× hyperlapse twice: once from
keyframes, and once by decoding every frame and keeping every 30th with
<code>select='not(mod(n,30))'</code>. Both came out at 351 frames. The keyframe render
took 4.4 seconds and used about one CPU core. The full decode finished in 7.0
seconds only because it spread across the cores: it used 57 seconds of CPU
time, roughly 13 times the work. On one clip that&rsquo;s a few seconds of waiting.
Across a batch of drives, the CPU time is what adds up.</p>
<p>What the full decode buys you is choice:</p>
<div class="table-wrap" data-scrollable data-label="Table: You want">
  <table>
    <thead>
      <tr><th scope="col">You want</th><th scope="col">Keyframes</th><th scope="col">Every frame</th></tr>
    </thead>
    <tbody>
      <tr><td>30× at 30 fps, from one keyframe per second</td><td>Yes</td><td>Yes, decoding 30 frames per output frame</td></tr>
      <tr><td>60×, 90× or another whole multiple</td><td>Yes, by keeping every second or third keyframe with <code>select</code></td><td>Yes</td></tr>
      <tr><td>Any other speed, such as 20× or 45×</td><td>No</td><td>Yes</td></tr>
      <tr><td>Motion blur from blending neighboring frames</td><td>No</td><td>Yes</td></tr>
      <tr><td>Even spacing on footage with irregular keyframes</td><td>No</td><td>Yes</td></tr>
    </tbody>
  </table>
</div>
<p>For dashcam footage with a steady one-second keyframe, 30× was the speed I
wanted, so keyframes were the right choice. If the speed has to be something
else, decode everything and accept the cost.</p>
<p>The same <code>-skip_frame nokey</code> read is the cheap way to analyze footage, not only
to watch it. When you extract keyframes as raw frames rather than encoding a
video, add <code>-fps_mode passthrough</code>. Without it, ffmpeg duplicates each keyframe
back up to 30 fps, and every frame-to-frame difference you compute reads zero.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Sort Months of Dashcam Footage with Claude Code</title>
      <link>https://looselydecoupled.com/triage-dashcam-footage-claude-code/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/triage-dashcam-footage-claude-code/</guid>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      <category>macOS</category>
      
      <description>Five copy-paste Claude Code prompts sort months of Rivian R1T dashcam footage by GPS and keyframes, each with the check that caught a real failure.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Give Claude Code one prompt per stage: survey, move, find, cut, publish. Each
prompt states what the agent can&rsquo;t touch and carries three demands: run on
one drive first, break your own check on purpose and confirm that it fails, and
list what you couldn&rsquo;t verify. The agent reads the GPS tag and the keyframes instead
of the pixels and proposes candidates. You confirm every pick on a contact sheet
before anything is cut, moved, or deleted.</p>

</div>

<p>Driving from July to September filled the truck&rsquo;s 2 TB dashcam drive, a
<a href="https://www.amazon.com/dp/B08HN37XC1?tag=loosely-decoupled-20" rel="external noopener sponsored" target="_blank">SanDisk Extreme Portable SSD<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
(affiliate link): 240 drive folders,
1,958 GB, five cameras recording on every trip. I wanted to keep only what might
make a video, and the backup drive couldn&rsquo;t hold all of it. Watching it wasn&rsquo;t an
option. I directed Claude Code in plain language instead. It sorted the drives
by where they went using GPS, found the I-90 floating bridge by the color of the tunnel
lights, cut synced clips, and rendered a hyperlapse of every drive.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/five-cameras-floating-bridge_hu_c7373bb90be2c6e0.jpg" alt="Five fisheye views from the same second on the I-90 floating bridge: front, rear, both sides and the Gear Guard camera over the truck bed. A red car passing behind appears in three views at once." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The five cameras at one second of a crossing.</figcaption>
</figure>
<p>Almost every failure in that work was caught by a check: a size comparison, a
frame count, a positive control, a contact sheet, or me. One failure was caught by
nothing for hours. The prompts in this guide are general versions of the ones I
typed, rewritten for your own footage, and each one carries the guardrail that
the failure taught.</p>
<h2 id="signals-already-in-the-files">Signals already in the files<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#signals-already-in-the-files"
     aria-label="Link to section: Signals already in the files" data-pagefind-ignore></a>
</h2>
<p>A Rivian writes one folder per drive and one MP4 per camera per 15-minute segment.
Two things in those files do most of the work, and neither needs anyone to watch
the video:</p>
<ul>
<li><strong>GPS.</strong> The container&rsquo;s <code>location</code> tag holds one fix per segment, taken at its
start. That&rsquo;s the only GPS in the file; there&rsquo;s no per-second track.</li>
<li><strong>Keyframes.</strong> Every camera writes one keyframe per second. Decoding only the
keyframes, downscaled to 64 × 48, gives a per-second record of motion and color
without decoding the frames between them.</li>
</ul>
<p>Read the tag from any clip:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-show_entries</span> <span class="console__token">format_tags=location</span> <span class="console__token">-of</span> <span class="console__token">default=nw=1:nk=1</span> <span class="console__token">&lt;clip&gt;.mp4</span></span></code></pre>
    </div>
  </div></div><p>It prints a signed latitude immediately followed by a signed longitude, such as
<code>47.589462-122.2655</code>. Some tags end in <code>/</code> and most don&rsquo;t, and <code>0,0</code> means the truck
had no fix. <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a> covers every spelling. Other
dashcams store GPS differently, which is why the first prompt asks the agent to
find out rather than assume.</p>
<h2 id="tools-and-rules-for-every-stage">Tools and rules for every stage<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#tools-and-rules-for-every-stage"
     aria-label="Link to section: Tools and rules for every stage" data-pagefind-ignore></a>
</h2>
<p>You need:</p>
<ul>
<li>Claude Code, run in a terminal on the machine that has the footage mounted</li>
<li>The <code>ffmpeg</code> and <code>ffprobe</code> commands on the <code>PATH</code></li>
<li>The <code>exiftool</code> command, for the privacy stage</li>
<li>A second drive with room for whatever you keep</li>
</ul>
<p>Every prompt carries three rules, worded for its stage. They&rsquo;re repeated on
purpose, so that a prompt pasted on its own still carries them:</p>
<ul>
<li><strong>One drive first.</strong> A bug found on the first drive costs one drive&rsquo;s time. The
same bug found late in a batch costs every drive before it.</li>
<li><strong>Break the check on purpose.</strong> A check that has never failed proves nothing.
The agent damages its own input or output, confirms the check catches it, and
then restores it.</li>
<li><strong>Say what couldn&rsquo;t be verified.</strong> Matching file sizes aren&rsquo;t matching content,
and a report that doesn&rsquo;t say so reads as if they were.</li>
</ul>
<!-- style-check: procedure -->
<h2 id="survey-every-drive-without-changing-anything">Survey every drive without changing anything<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#survey-every-drive-without-changing-anything"
     aria-label="Link to section: Survey every drive without changing anything" data-pagefind-ignore></a>
</h2>
<p>In Claude Code, from the folder that holds the drive folders, paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The folder &lt;footage-folder&gt; holds video from my truck&#39;s dashcam: one subfolder per drive, and one MP4 per camera per segment. Survey it without changing anything.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">For every drive folder, use ffprobe to read each segment&#39;s duration and GPS location tag. Report each drive&#39;s start time, length, GPS points, the distance between its two farthest points, and its size on disk. Ignore any ._ files macOS has left beside the clips on a FAT32 or exFAT drive.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only. Do not move, rename, re-encode, or delete anything.
</span></span><span class="line"><span class="cl">- Run on one drive first and show me the result before you run the rest.
</span></span><span class="line"><span class="cl">- The location tag is written in more than one format. Parse it permissively, and treat 0,0 as no fix, not as a place.
</span></span><span class="line"><span class="cl">- Before you run the whole batch, feed your parser a malformed tag on purpose and confirm that it reports the drive instead of skipping it silently.
</span></span><span class="line"><span class="cl">- Cache everything you read in one file, so that later steps don&#39;t read the drives again.
</span></span><span class="line"><span class="cl">- Then propose a way to sort the drives into routine and unusual, and wait. I&#39;ll tell you my regular routes.
</span></span><span class="line"><span class="cl">- Finish with a list of the drives you couldn&#39;t read and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p><strong>What went wrong here.</strong> I asked for road trips, and the agent ranked drives by
distance. The ranking itself showed there were none: no drive in the batch went
far. Distance was the wrong question, and only I could supply the right one: the
corridor across the I-90 bridges that most of the drives used. With that, the
classification became a corridor test. A drive whose every GPS point falls
inside a box around that corridor is a corridor drive; anything else is worth a
look. The test found 168 corridor drives, 1,594 GB, and 14 off the corridor
worth keeping,
171 GB. That&rsquo;s why the prompt ends by
waiting for you. Your routes are context the agent can&rsquo;t get from the files.</p>
<p>The parser also failed on its first real run, on a tag that ended in <code>/</code>. The
fix was a pattern that finds two signed numbers wherever they sit.
Expect the same from your own camera&rsquo;s metadata, and test both spellings.</p>
<h2 id="move-the-drives-you-keep">Move the drives you keep<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#move-the-drives-you-keep"
     aria-label="Link to section: Move the drives you keep" data-pagefind-ignore></a>
</h2>
<p>Decide which drives to keep from the survey, then paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Move these drives from &lt;footage-folder&gt; to &lt;backup-drive&gt;/&lt;kept-folder&gt;: &lt;list of drive folders&gt;.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Before you copy anything, compare the total size with the free space on &lt;backup-drive&gt;, and stop if it won&#39;t fit.
</span></span><span class="line"><span class="cl">- Copy first. After each drive, compare every file&#39;s size on the destination with the source.
</span></span><span class="line"><span class="cl">- Do not delete anything from the source until I say so. Show me the drives whose sizes all match, and wait.
</span></span><span class="line"><span class="cl">- When I confirm, remove the source files with a tool that removes only what it has copied, such as rsync --remove-source-files. Do not rely on shell-specific glob features.
</span></span><span class="line"><span class="cl">- Skip any folder that another process, such as Finder, is still writing to or moving.
</span></span><span class="line"><span class="cl">- Report the empty folders that a move leaves behind. Do not delete them, and make every later step skip them.
</span></span><span class="line"><span class="cl">- Move one drive first and show me the result.
</span></span><span class="line"><span class="cl">- Before the batch, truncate a copy in a scratch folder and confirm your size check reports it.
</span></span><span class="line"><span class="cl">- Say plainly that a size match isn&#39;t a content match, and list anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Do not let an agent delete source footage in the same step that copies it. Copy,
verify on the destination, and approve the removal yourself. A move that copies
and then fails to delete costs disk space; a move that deletes and then fails to
copy costs the footage.</p>
</div><p><strong>What went wrong here.</strong> A queued move used a <code>zsh</code> glob qualifier that the
shell Claude Code runs commands in doesn&rsquo;t enable. It would have copied every file
and deleted none. Inspecting the running process caught it, and the move switched
to <code>rsync --remove-source-files</code>, which removes only what it copied. Later, a
cleanup command of the form <code>rm -f $B/*.jpg</code> was blocked by Claude Code&rsquo;s own
guard, because an unset <code>$B</code> would expand the path to <code>/*</code>. The cleanup wasn&rsquo;t
needed, and the guard was right to stop it.</p>
<p>The moves also left empty drive folders behind. Nothing failed because of them
until the hyperlapse run, in a later stage, which is why this prompt asks for
them to be reported now.</p>
<h2 id="find-a-landmark-from-computed-signals">Find a landmark from computed signals<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#find-a-landmark-from-computed-signals"
     aria-label="Link to section: Find a landmark from computed signals" data-pagefind-ignore></a>
</h2>
<p>GPS places a drive to within a 15-minute segment, which is too coarse to find a
bridge crossing. The picture has to do it, and the cheap way to read the picture
is to compute a few numbers per second once and reuse them. Paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">I want the moment in each drive where the truck passes &lt;landmark&gt;. The GPS has one point per segment, which is too coarse, so find it from the picture.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. From keyframes only, downscaled to 64x48, compute per-second signals for each camera: the mean change in green from the previous keyframe, and the mean red, green, and blue. Cache them per drive and per camera, so that no later step decodes video again.
</span></span><span class="line"><span class="cl">2. Propose a signal that marks &lt;landmark&gt;, and explain why it should separate the landmark from things that look like it.
</span></span><span class="line"><span class="cl">3. Propose candidates. Do not pick for me.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only on &lt;footage-folder&gt;. Write only under &lt;work-folder&gt;.
</span></span><span class="line"><span class="cl">- When you extract keyframes as raw frames, make sure ffmpeg doesn&#39;t duplicate them back up to the source frame rate.
</span></span><span class="line"><span class="cl">- Before you scan everything, calibrate the signal on a drive where I know the landmark is present and a drive where I know it isn&#39;t. If both read the same, suspect your extraction before the signal.
</span></span><span class="line"><span class="cl">- Run on one drive first.
</span></span><span class="line"><span class="cl">- Build a contact sheet with one row per drive, labeled with the drive&#39;s name, and one frame per candidate in time order. Write a picks file with your best guess for each drive, which I can edit. Then wait for me.
</span></span><span class="line"><span class="cl">- Ignore the first and last few minutes of each drive, where the truck is parking or leaving.
</span></span><span class="line"><span class="cl">- If a result contradicts something already confirmed, check your tool before you report the contradiction: extract the same frame two ways and compare them.
</span></span><span class="line"><span class="cl">- List the drives with no candidate, and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>For a tunnel, the signal turned out to be color. Auto-exposure keeps a tunnel&rsquo;s
brightness close to daylight&rsquo;s, but sodium lighting tips the front camera&rsquo;s red/blue
ratio above 1.0. A run of 25–150 s above that ratio, away from the first and last
3 minutes of the drive, found a tunnel on 71 of the 79 bridge drives
scanned. Seven of the eight misses were after dark. <a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in
dashcam video by the color of tunnel
lights</a> is the full account,
including what didn&rsquo;t work.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/bridge-daylight-vs-tunnel-sodium_hu_addb249c3b37c46e.jpg" alt="Two front-camera frames side by side. On the floating bridge in daylight the picture is blue-gray, with a red/blue ratio of 0.81. Inside the Mount Baker tunnel it is orange-brown under the sodium lamps, with a ratio of 1.37, and the road and cars are still well exposed." width="1600" height="640" loading="lazy" decoding="async"><figcaption>Mean red over mean blue for each whole frame, from the same drive. Plates blurred.</figcaption>
</figure>
<p><strong>What went wrong here.</strong> The first calibration read zero change between every
pair of keyframes, parked or moving. A signal that&rsquo;s flat on a known positive and
a known negative is a broken instrument, not a finding. The cause was ffmpeg
duplicating keyframes back up to 30 fps, so consecutive frames were identical.
<code>-fps_mode passthrough</code> fixed it. That bug is why the prompt asks for a known
positive and a known negative before the scan.</p>
<p>The automatic pick, one tunnel chosen by its position in the drive, was right
about two times in three. The rest landed on other tunnels. The contact sheet is
what turned an unreliable detector into a usable one. It showed one frame per
candidate, taken from the bridge side of the tunnel. From it, I confirmed 68
crossings, couldn&rsquo;t confirm two night drives, and found none on one. The first sheet was a flat grid
of every candidate frame, and it was easy to match a frame to the wrong drive.
One row per drive makes that mistake visible.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/review-sheet-one-row-per-drive_hu_bceefb337d7a67ac.jpg" alt="Three rows of a review contact sheet, one drive per row, each tile a frame from the bridge side of one tunnel candidate. Each row has a floating-bridge tile among freeway tiles, so the right pick stands out at a glance. The first row’s last three tiles are replaced by gray blocks." width="1600" height="768" loading="lazy" decoding="async"><figcaption>Three rows of a review sheet. Some frames are removed.</figcaption>
</figure>
<p>The worst wrong claim of the whole session came at this stage. Partway through,
the agent reported that the picks for some drives were the wrong tunnel, the Mercer
Island lid instead of the Mount Baker tunnel. The contact sheets had been built
with an output-side <code>-ss</code>, which ffmpeg applied after tiling, so the sheets showed
a different window from the one the picks named. Extracting one frame two ways
exposed it. The picks had been right, and the claim was retracted.</p>
<h2 id="cut-synced-clips-and-render-hyperlapses">Cut synced clips and render hyperlapses<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#cut-synced-clips-and-render-hyperlapses"
     aria-label="Link to section: Cut synced clips and render hyperlapses" data-pagefind-ignore></a>
</h2>
<p>With picks confirmed, paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">For each confirmed pick in &lt;picks-file&gt;, cut a clip from every camera, from &lt;seconds-before&gt; seconds before the landmark to &lt;seconds-after&gt; seconds after it, without re-encoding. Then render a 30x hyperlapse of every drive for every camera, decoding keyframes only.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only on &lt;footage-folder&gt;. Write only under &lt;work-folder&gt;.
</span></span><span class="line"><span class="cl">- A stream copy can start only on a keyframe. Measure each camera&#39;s keyframe timing first; do not assume the cameras share it.
</span></span><span class="line"><span class="cl">- A camera&#39;s first frames in a segment file can depend on frames at the end of the previous file. Cut across segment files in one pass, and check the joins.
</span></span><span class="line"><span class="cl">- After cutting, compare every camera&#39;s frame count, duration, and start_time. Same length isn&#39;t the same as in sync.
</span></span><span class="line"><span class="cl">- Pad a camera that stopped early with black frames, per segment, so that every camera stays the same length as the front camera. Give the black the footage&#39;s pixel format and color range, and check the range of every joined file.
</span></span><span class="line"><span class="cl">- Do not trust folder names or file timestamps for timing. If a camera restarted into another folder, measure its offset from cameras that see the same scene, in more than one window. If that offset doesn&#39;t land on one of the camera&#39;s keyframes, re-encode only that camera, cut at the exact frame, and tell me which files were re-encoded.
</span></span><span class="line"><span class="cl">- Test on one drive first, and include a drive where a camera stopped early.
</span></span><span class="line"><span class="cl">- Before the batch, shift one camera by a second on purpose and confirm that your sync check fails. Then restore it.
</span></span><span class="line"><span class="cl">- In the batch, one bad drive must be logged and skipped, not stop the run. Write each output to a temporary name and rename it when it&#39;s complete, so that a rerun resumes.
</span></span><span class="line"><span class="cl">- Time the test drive and estimate the batch from that.
</span></span><span class="line"><span class="cl">- If the run will outlive this session, start a watcher that tells me when the run ends or stops making progress.
</span></span><span class="line"><span class="cl">- List the drives that were skipped or padded, and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>Each keyframe becomes one frame at 30 fps, so a hyperlapse plays at 30× speed. A
36-minute drive became 72 s and took 4.4 minutes to render. The hyperlapses of
218 drives, five cameras each, came to 107 GB. <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam clips
with ffmpeg</a> and <a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">make a
hyperlapse from keyframes with ffmpeg</a> cover each
step in full.</p>
<p><strong>What went wrong here.</strong> On the first drive I cut, the side-left camera came out
short. On other drives it&rsquo;s a different camera, or none, so the prompt measures
every camera. The frame-count comparison caught it twice:</p>
<ol>
<li>Its GOPs run across segment files. Cutting each segment separately and joining
them dropped the frames that reference the previous file: the camera came out
1.9 s short, with a jump at the join. One concat-demuxer pass over the source
segments keeps them.</li>
<li>It was still 29 frames short, because its keyframes fall at n + 0.967 s, not on
whole seconds like the other cameras&rsquo;. Landing each camera&rsquo;s in-point exactly
on its own keyframe makes every file start at time 0. An offset camera
starts a few frames early, and setting that clip&rsquo;s In point that many frames
in lines it up with the others.</li>
</ol>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/synced-clip-five-cameras_hu_321fa648b5047a9c.jpg" alt="The five cameras of a finished synced clip at the same second on the floating bridge. A container truck passing on the left fills the front and side-left views, and the pickup behind appears in the rear and Gear Guard views." width="1600" height="853" loading="lazy" decoding="async"><figcaption>One frame from each camera&rsquo;s cut file, all at the same timestamp. Plates blurred.</figcaption>
</figure>
<p>A third check, on <code>start_time</code>, caught a version that had the right length but
started 0.967 s late, which an editor might ignore.</p>
<p>The hyperlapse test drive caught two more. Two cameras came out at 1,303 frames
against the front camera&rsquo;s 2,170. The first guess was the black filler&rsquo;s pixel
format; the real cause was the rear camera stopping 33 s into a segment. The fix
is to pad every segment to the front camera&rsquo;s length. The filler has a trap of
its own: it has to be full range like the footage, which takes <code>-color_range pc</code>;
Apple&rsquo;s hardware encoder ignores <code>-pix_fmt yuvj420p</code>. A mismatched part doesn&rsquo;t make the stream-copy join fail.
The joined file takes the first part&rsquo;s label, so a camera whose file opens on
filler was labeled limited range throughout: 11 of more than a thousand hyperlapse files, found
and re-rendered later. Across 233 drive folders, six hyperlapse sets needed
black-filled segments.</p>
<p>On that same drive, the rear and side-left cameras had restarted into a sibling
folder whose name was 34 s later. The name was wrong by more than a second.
Correlating motion between the Gear Guard camera and the restarted rear camera,
which both face backward, measured 32.7 s ± 0.1 s across four independent
windows. Front against rear disagreed by 0.9 s between windows, because those two
cameras don&rsquo;t see the same scene. <a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">Rivian dashcam camera missing from a
drive</a> covers the restart.</p>
<p>Then the batch failed, and no check caught it. Background jobs in the session
stopped at a time limit, so the run was relaunched detached. A detached process
sends no notice when it ends. It crashed at 10:11 on one of the empty folders the
moves had left behind, and it sat dead until 16:48, when I asked what was next:
more than six and a half hours. Two
fixes came out of it, and both are in the prompt: an empty or bad drive is now a
logged skip, and a long run gets a watcher.</p>
<h2 id="keep-location-out-of-anything-you-publish">Keep location out of anything you publish<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#keep-location-out-of-anything-you-publish"
     aria-label="Link to section: Keep location out of anything you publish" data-pagefind-ignore></a>
</h2>
<p>Paste, for each file you mean to share:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Prepare &lt;export-file&gt; for publishing.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Treat GPS in any clip as private. List every metadata field that holds a location, with: exiftool -G1 -a &#34;-gps*&#34; &#34;-location*&#34; &lt;file&gt;
</span></span><span class="line"><span class="cl">- First run that command on an original clip from the truck and confirm that it finds the location. Then run it on the export.
</span></span><span class="line"><span class="cl">- If the export carries a location, write a copy without it, run the same command again, and show me the empty result.
</span></span><span class="line"><span class="cl">- List the first and last frames of every clip, and flag any frame from either end of a drive, any license plate, and any face. Do not decide for me what to blur or cut.
</span></span><span class="line"><span class="cl">- Also list creation dates, and any drive date or time in file names. Strip them with -map_metadata -1 and rename the files, unless I say otherwise.
</span></span><span class="line"><span class="cl">- List what this check can&#39;t see, such as a street sign or a house number in the picture.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>Run the check on every export rather than reasoning about which files carry a
tag. In this work the answer differed by tool. The raw clips carry it. The
hyperlapse tool I used copies the first source file&rsquo;s metadata, GPS included,
so the set stays traceable; the <a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">published hyperlapse
script</a> strips it. Clips cut with the concat demuxer came
out without it, but a plain <code>-c copy</code> of a single file keeps it, and
<code>-map_metadata -1</code> removes it. Show only
public landmarks on screen, and never frames from either end of a drive.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/interchange-curve-sunset_hu_6d94b69d2acd59a0.jpg" alt="A side-camera fisheye frame on the I-5/I-90 interchange curve at sunset: the low sun flares at the left edge, and the downtown Seattle skyline stands above the ramps and trees." width="1600" height="1280" loading="lazy" decoding="async"><figcaption>A public landmark mid-drive, raw fisheye, which keeps the sun in frame.</figcaption>
</figure>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>A clip from the truck&rsquo;s drive carries a GPS fix for the start of every segment, and
the first segment of a drive usually starts at home. Run the <code>exiftool</code> check on
every file before it leaves your machine.</p>
</div><!-- style-check: end procedure -->
<h2 id="detectors-that-didnt-work">Detectors that didn&rsquo;t work<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#detectors-that-didnt-work"
     aria-label="Link to section: Detectors that didn’t work" data-pagefind-ignore></a>
</h2>
<p>Four ideas failed, and each failure says something about the signal rather than
the threshold:</p>
<ul>
<li><strong>Parked footage.</strong> The hypothesis was that recording while parked padded every
drive. The measurement found 23 GB of 1,760 GB: with Gear Guard turned off, the
truck stops recording seconds after parking. The scan still paid for itself, because its per-second
signals fed every detector after it.</li>
<li><strong>Hard stops.</strong> The detector returned 875 candidates, and all 12 at the top
were turns into parking lots or ordinary stops. Turning changes the picture as
much as braking does.</li>
<li><strong>Traffic jams.</strong> Crawling traffic still changes the picture, because the
neighboring cars move.</li>
<li><strong>Garage boundaries by color.</strong> An overcast sky is as gray as a concrete
ceiling. It worked on a clear day and failed on a cloudy one, which is the case
to test first.</li>
</ul>
<p>One detector worked with a person in the loop. Close passes, read as a
one-sided spike on a side camera while the front camera shows steady driving,
were swamped by overpass shadows until the frame&rsquo;s overall brightness change was
subtracted. Of the top 60, 37 were real passes, and a contact sheet sorted them.
A human pass was cheaper than a better detector.</p>
<h2 id="failures-and-the-checks-that-caught-them">Failures and the checks that caught them<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#failures-and-the-checks-that-caught-them"
     aria-label="Link to section: Failures and the checks that caught them" data-pagefind-ignore></a>
</h2>
<div class="table-wrap" data-scrollable data-label="Table: Failure">
  <table>
    <thead>
      <tr><th scope="col">Failure</th><th scope="col">Caught by</th></tr>
    </thead>
    <tbody>
      <tr><td>No road trips in the footage</td><td>The distance ranking itself</td></tr>
      <tr><td>GPS parser failed on a tag ending in <code>/</code></td><td>An exception on the first run</td></tr>
      <tr><td>Move would copy but never delete</td><td>Inspecting the running process</td></tr>
      <tr><td><code>rm</code> with a possibly unset variable</td><td>Claude Code&rsquo;s command guard</td></tr>
      <tr><td>Every frame difference read zero</td><td>A known parked and a known moving segment</td></tr>
      <tr><td>Automatic tunnel pick wrong about a third of the time</td><td>A one-row-per-drive contact sheet</td></tr>
      <tr><td>Claim that the picks were the wrong tunnel</td><td>Extracting one frame two ways</td></tr>
      <tr><td>One camera short in a synced cut, 1.9 s and then 29 frames</td><td>Frame counts across cameras</td></tr>
      <tr><td>The same camera starting 0.967 s late</td><td>Inspecting <code>start_time</code></td></tr>
      <tr><td>Hyperlapse cameras at 1,303 frames, not 2,170</td><td>Frame counts on a test drive with a stopped camera</td></tr>
      <tr><td>Folder name said 34 s; the offset was 32.7 s</td><td>Motion correlation in four windows</td></tr>
      <tr><td>Detached run dead for more than six and a half hours</td><td>Nothing, until I asked; now a watcher</td></tr>
      <tr><td>Hard stops, jams, and garage color</td><td>A contact sheet, a count, and an overcast day</td></tr>
    </tbody>
  </table>
</div>
<h2 id="each-stage-in-depth">Each stage in depth<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#each-stage-in-depth"
     aria-label="Link to section: Each stage in depth" data-pagefind-ignore></a>
</h2>
<p>Each later part of this series takes one stage and stands on its own:</p>
<ol start="2">
<li><a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a>: the tag&rsquo;s spellings, a
parser, and how to strip it.</li>
<li><a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">Rivian dashcam camera missing from a drive: it restarted in another
folder</a>: finding the sibling
folder and measuring its real offset.</li>
<li><a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam clips with ffmpeg without re-encoding, and keep them
in sync</a>: snapping each
camera to its own keyframe.</li>
<li><a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">Make a hyperlapse from keyframes with ffmpeg without decoding every
frame</a>: the 30× render, the padding, and the
full-range filler.</li>
<li><a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in dashcam video by the color of tunnel
lights</a>: the detector, the
review sheet, and what didn&rsquo;t work.</li>
</ol>
]]></content:encoded>
    </item>
    
    <item>
      <title>Create a Bootable Linux USB Drive on macOS with dd</title>
      <link>https://looselydecoupled.com/bootable-linux-usb-macos-dd/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/bootable-linux-usb-macos-dd/</guid>
      <pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Linux</category>
      
      <description>Four Terminal commands write a Linux ISO to a USB drive without Etcher; a 3 GB Mint image took about seven minutes and booted a 2011 iMac.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Find the USB drive with <code>diskutil list</code>, unmount it, write the ISO to its raw
device with <code>dd</code>, and eject it.</p>
<p><code>dd</code> writes to whatever device you name, with no confirmation and no undo.
Replace <code>N</code> in <code>diskN</code> with the number <code>diskutil list</code> shows for your USB
drive, and check it before the third command.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">list</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">unmountDisk</span> <span class="console__token">/dev/diskN</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">sudo</span> <span class="console__token">dd</span> <span class="console__token">if=&#34;$HOME/Downloads/linuxmint-22.3-cinnamon-64bit.iso&#34;</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">of=/dev/rdiskN</span> <span class="console__token">bs=1m</span> <span class="console__token">status=progress</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eject</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div>
</div>

<p>I wanted to repurpose my old Mac, a 2011 iMac, by putting Linux Mint on it.
balenaEtcher stopped at &ldquo;Starting&hellip;&rdquo; on my Apple Silicon MacBook Pro. I
wrote the installer drive with <code>dd</code> instead, and the iMac booted from it.
<code>dd</code> ships with macOS, so nothing needs installing. It copies the image onto
the device block for block, which is what Etcher&rsquo;s helper does in code.</p>
<!-- style-check: procedure -->
<h2 id="1-identify-the-usb-device">1. Identify the USB device<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#1-identify-the-usb-device"
     aria-label="Link to section: 1. Identify the USB device" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">list</span></span></code></pre>
    </div>
  </div></div><p>Find the external disk in the output. Its number replaces <code>N</code> in the commands
that follow:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/dev/diskN (external, physical):</span></span></code></pre></div></div>
  </div>
</div><p>Before going further, confirm both:</p>
<ul>
<li>The header reads <code>external, physical</code>. Internal disks read <code>internal, physical</code>, and APFS containers read <code>synthesized</code>. Neither is your USB
drive.</li>
<li>The size matches your USB drive.</li>
</ul>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p><code>dd</code> writes to whatever device you name, with no confirmation and no undo.
An internal disk named by mistake is overwritten from block zero. Read the
identifier off <code>diskutil list</code> for this drive on this machine; disk numbers
change between boots and between ports.</p>
</div><h2 id="2-unmount-the-disk">2. Unmount the disk<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#2-unmount-the-disk"
     aria-label="Link to section: 2. Unmount the disk" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">unmountDisk</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><p>Unmount, not eject. An ejected disk is removed from <code>/dev</code>. An unmounted disk
stays in <code>/dev</code> with no volume mounted, so <code>dd</code> can open it for writing.</p>
<h2 id="3-write-the-iso">3. Write the ISO<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#3-write-the-iso"
     aria-label="Link to section: 3. Write the ISO" data-pagefind-ignore></a>
</h2>
<p>This command erases everything on the drive and replaces it with the image.
<code>sudo</code> prompts for your administrator password.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">sudo</span> <span class="console__token">dd</span> <span class="console__token">if=&#34;$HOME/Downloads/linuxmint-22.3-cinnamon-64bit.iso&#34;</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">of=/dev/rdiskN</span> <span class="console__token">bs=1m</span> <span class="console__token">status=progress</span></span></code></pre>
    </div>
  </div></div><p>Three details in that command:</p>
<ul>
<li><code>&quot;$HOME/Downloads/...&quot;</code>, not <code>~/Downloads/...</code>. The default macOS shell,
zsh, doesn&rsquo;t expand a tilde after <code>if=</code>, and <code>dd</code> reports the file as
missing. <code>$HOME</code> expands in every shell.</li>
<li><code>rdiskN</code>, not <code>diskN</code>. The <code>r</code> prefix names the raw device, which
bypasses the buffer cache and writes faster on macOS.</li>
<li><code>status=progress</code> prints the bytes written as it goes, so a slow write
shows progress instead of looking stalled.</li>
</ul>
<p>On this drive, the sustained rate was about 7.6 MB/s, and the 3 GB image took
about seven minutes. That rate is the drive&rsquo;s own write speed, not a fault.</p>
<h2 id="4-confirm-it-finished">4. Confirm it finished<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#4-confirm-it-finished"
     aria-label="Link to section: 4. Confirm it finished" data-pagefind-ignore></a>
</h2>
<p><code>dd</code> reports the total when it completes:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">3091660800 bytes transferred in 404.24 secs (7648047 bytes/sec)</span></span></code></pre></div></div>
  </div>
</div><p>Compare the byte count with the ISO&rsquo;s size:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">stat</span> <span class="console__token">-f</span> <span class="console__token">%z</span> <span class="console__token">&#34;$HOME/Downloads/linuxmint-22.3-cinnamon-64bit.iso&#34;</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">3091660800</span></span></code></pre>
    </div>
  </div></div><p>The two numbers match and no I/O error was printed, so the write is
complete. <code>dd</code> doesn&rsquo;t read the drive back to verify it the way Etcher
does. Booting from the drive, later in this article, is the verification.</p>
<p>When <code>dd</code> releases the device, macOS might report that the disk isn&rsquo;t
readable and offer to initialize it. Choose Ignore or Eject. Initialize opens
Disk Utility, where erasing the drive would undo the write.</p>
<h2 id="5-eject">5. Eject<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#5-eject"
     aria-label="Link to section: 5. Eject" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eject</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><!-- style-check: end procedure -->
<h2 id="what-finder-shows-afterward">What Finder shows afterward<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#what-finder-shows-afterward"
     aria-label="Link to section: What Finder shows afterward" data-pagefind-ignore></a>
</h2>
<p>Finder shows unfamiliar partitions, or a fraction of the drive&rsquo;s capacity.
That&rsquo;s expected: the ISO was written as a raw disk image, not as a file system
that macOS can mount. On a 32 GB drive, the image uses about 3 GB, and the
rest is outside any partition in this layout. Don&rsquo;t change anything on the
drive until you&rsquo;ve booted from it.</p>
<p>To return the drive to ordinary use later, erase it as exFAT. This removes the
Linux image:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eraseDisk</span> <span class="console__token">ExFAT</span> <span class="console__token">USB</span> <span class="console__token">GPT</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><!-- style-check: procedure -->
<h2 id="boot-the-usb-drive">Boot the USB drive<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#boot-the-usb-drive"
     aria-label="Link to section: Boot the USB drive" data-pagefind-ignore></a>
</h2>
<p>An Intel Mac, such as the 2011 iMac, can boot from this drive. An Apple
Silicon Mac can&rsquo;t.</p>
<p>On an Intel Mac, hold Option during startup and select the external entry.
The entry is usually labeled &ldquo;EFI Boot&rdquo;, though the label depends on the firmware
and on how the drive is enumerated.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>macOS</dt> <dd>Tahoe 26.3.1, MacBook Pro (Apple Silicon)</dd></div>
    <div class="tested__row"><dt>Boot target</dt> <dd>iMac (2011)</dd></div>
    <div class="tested__row"><dt>USB</dt> <dd>PNY 32 GB USB 3.0</dd></div>
    <div class="tested__row"><dt>ISO</dt> <dd>Linux Mint 22.3 Cinnamon 64-bit</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>March 2026</dd></div>
  </dl>
</div>
<!-- style-check: end procedure -->
]]></content:encoded>
    </item>
    
    <item>
      <title>balenaEtcher Stuck at Starting on macOS: Erase the USB Drive as exFAT</title>
      <link>https://looselydecoupled.com/balena-etcher-stuck-starting-macos/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/balena-etcher-stuck-starting-macos/</guid>
      <pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Linux</category>
      
      <description>An APFS-formatted USB drive stops Etcher&#39;s helper before it writes a byte; erased as exFAT, the same drive flashes normally.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>When balenaEtcher stays at &ldquo;Starting&hellip;&rdquo; with no progress bar and no error,
check the USB drive&rsquo;s format. On a drive formatted as APFS, Etcher&rsquo;s helper
receives the write request and never starts writing. Running Etcher as <code>sudo</code>
doesn&rsquo;t change that. Erase the whole drive as exFAT and flash again.</p>
<p>The erase deletes everything on the drive. Replace <code>N</code> in <code>diskN</code> with the
number <code>diskutil list</code> shows for your USB drive, and check it before the last
command.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">list</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">unmountDisk</span> <span class="console__token">force</span> <span class="console__token">/dev/diskN</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eraseDisk</span> <span class="console__token">ExFAT</span> <span class="console__token">USB</span> <span class="console__token">GPT</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><p>If Etcher still stops, write the ISO with <code>dd</code> instead, as described in
<a href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/">Create a bootable Linux USB drive on macOS with dd</a>.</p>

</div>

<p>I wanted to repurpose my old Mac, a 2011 iMac, by putting Linux Mint on it.
I wrote the installer USB drive on my Apple Silicon MacBook Pro with
balenaEtcher. Etcher stopped at &ldquo;Starting&hellip;&rdquo; and gave no reason, so I wrote
the drive with <code>dd</code>, and the iMac booted from it. Later I reproduced the hang
on a second drive to find the cause.</p>
<h2 id="symptoms">Symptoms<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#symptoms"
     aria-label="Link to section: Symptoms" data-pagefind-ignore></a>
</h2>
<p>balenaEtcher (also searched as Balena Etcher) detects the drive and loads the
ISO. After you click Flash, the window stays at:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Starting...</span></span></code></pre></div></div>
  </div>
</div><p>No progress bar appears and no error is raised. Launching the app from a
terminal with elevated permissions makes no difference:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">sudo /Applications/balenaEtcher.app/Contents/MacOS/balenaEtcher</span></span></code></pre></div></div>
  </div>
</div><p>Etcher&rsquo;s DevTools console shows the flasher starting and then stopping short
of the write. This is Etcher 2.1.6 with an APFS-formatted drive:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Starting priviledged flasher sidecar on port 3435
</span></span><span class="line"><span class="cl">/Applications/balenaEtcher.app/Contents/Resources/etcher-util
</span></span><span class="line"><span class="cl">... with privileges ...
</span></span><span class="line"><span class="cl">elevated pending
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">elevated granted
</span></span><span class="line"><span class="cl">etcher-&lt;id&gt;
</span></span><span class="line"><span class="cl">Connection to sidecar flasher process attempt 1 / 10 failed; retrying in 1000ms...
</span></span><span class="line"><span class="cl">etcher-&lt;id&gt;
</span></span><span class="line"><span class="cl">CHILD READY
</span></span><span class="line"><span class="cl">start heartbeat
</span></span><span class="line"><span class="cl">params Object
</span></span><span class="line"><span class="cl">CHILD LOG: Ready ...
</span></span><span class="line"><span class="cl">CHILD LOG: write requested
</span></span><span class="line"><span class="cl">CHILD LOG: Image: /Users/&lt;user&gt;/Downloads/&lt;image&gt;.iso
</span></span><span class="line"><span class="cl">CHILD LOG: Devices: /dev/diskN
</span></span><span class="line"><span class="cl">CHILD LOG: Auto blockmapping: true
</span></span><span class="line"><span class="cl">CHILD LOG: Decompress first: true</span></span></code></pre></div></div>
  </div>
</div><p>The log reaches <code>write requested</code>, echoes the job&rsquo;s settings, and no bytes
follow. In the log, <code>&lt;user&gt;</code> is your account name and <code>&lt;image&gt;</code> is the ISO
you selected. <code>N</code> is your drive&rsquo;s disk number, and <code>&lt;id&gt;</code> is a random name
Etcher gives each helper run.</p>
<h2 id="apfs-drive-that-stops-the-helper-before-the-write">APFS drive that stops the helper before the write<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#apfs-drive-that-stops-the-helper-before-the-write"
     aria-label="Link to section: APFS drive that stops the helper before the write" data-pagefind-ignore></a>
</h2>
<p>Etcher doesn&rsquo;t write the disk itself. The Electron app launches a separate
privileged helper, the &ldquo;sidecar flasher&rdquo;, and hands it the image and the
device. The <code>CHILD LOG</code> lines after <code>write requested</code> are the helper echoing
that request back. Etcher asks macOS for administrator rights for the helper
itself, which is what the <code>elevated pending</code> and <code>elevated granted</code> lines
record, so <code>sudo</code> adds nothing. Run as <code>sudo</code>, the log is identical.</p>
<p>The log doesn&rsquo;t show why the helper stops. The drive&rsquo;s format does. I
reproduced the hang with a second USB drive, a different ISO and Etcher 2.1.6,
and changed only the format between two runs:</p>
<div class="table-wrap" data-scrollable data-label="Table: Drive format">
  <table>
    <thead>
      <tr><th scope="col">Drive format</th><th scope="col">What Etcher did</th></tr>
    </thead>
    <tbody>
      <tr><td>APFS, GUID partition map</td><td>Stopped at &ldquo;Starting&hellip;&rdquo;; the log ended at <code>Decompress first: true</code></td></tr>
      <tr><td>exFAT, GUID partition map</td><td>Started writing; the next log line was the first progress line</td></tr>
    </tbody>
  </table>
</div>
<p>Erased as exFAT, the same flash continued straight into progress:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">CHILD LOG: Decompress first: true
</span></span><span class="line"><span class="cl">Flashing 1 device, 0% at 1.60 MB/s (total 1.60 MB/s) eta in 235s with 0 failed devices</span></span></code></pre></div></div>
  </div>
</div><p>That matches the reports on balena&rsquo;s forums.
<a href="https://forums.balena.io/t/etcher-stalls-on-0-flashing-never-starts-fix/375032" rel="external noopener" target="_blank">One thread<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
states that Etcher doesn&rsquo;t work on an APFS drive and surfaces no error.
<a href="https://forums.balena.io/t/mac-14-8-23j21-and-balena-etcher/374842" rel="external noopener" target="_blank">Another<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
with this hang was resolved by erasing the drive as exFAT.</p>
<p>A USB drive isn&rsquo;t APFS unless someone made it so. Disk Utility offers APFS
only when you erase the whole device with a GUID partition map, not when you
erase a volume on a drive with the older MBR layout. I don&rsquo;t know what the
first drive was formatted as when it hung. <code>dd</code> wrote the Mint image to it
without an error, so the ISO and the drive weren&rsquo;t at fault.</p>
<!-- style-check: procedure -->
<h2 id="erase-the-usb-drive-as-exfat">Erase the USB drive as exFAT<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#erase-the-usb-drive-as-exfat"
     aria-label="Link to section: Erase the USB drive as exFAT" data-pagefind-ignore></a>
</h2>
<p>The erase replaces the drive&rsquo;s partition map and every volume on it. Copy off
anything you need from the drive first.</p>
<h3 id="1-find-the-usb-drive">1. Find the USB drive<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#1-find-the-usb-drive"
     aria-label="Link to section: 1. Find the USB drive" data-pagefind-ignore></a>
</h3>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">list</span></span></code></pre>
    </div>
  </div></div><p>Find the header that reads <code>external, physical</code> and matches your drive&rsquo;s size.
Its number replaces <code>N</code> in the next step.
An APFS drive also appears a second time, as a <code>synthesized</code> container. Use
the <code>external, physical</code> identifier, not the container&rsquo;s.</p>
<h3 id="2-erase-it">2. Erase it<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#2-erase-it"
     aria-label="Link to section: 2. Erase it" data-pagefind-ignore></a>
</h3>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">unmountDisk</span> <span class="console__token">force</span> <span class="console__token">/dev/diskN</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eraseDisk</span> <span class="console__token">ExFAT</span> <span class="console__token">USB</span> <span class="console__token">GPT</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><p><code>force</code> matters when Spotlight is indexing the drive. Without it, the erase
can fail with <code>Couldn't unmount disk. : (-69888)</code> and name the process <code>mds</code>.
<code>USB</code> is the new volume name; any name works, because the flash overwrites it.</p>
<h3 id="3-flash-again">3. Flash again<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#3-flash-again"
     aria-label="Link to section: 3. Flash again" data-pagefind-ignore></a>
</h3>
<p>Select the ISO and the drive in Etcher, and click Flash. Within a few seconds,
the window shows <code>Flashing...</code> with a percentage, and the DevTools log shows
<code>Flashing 1 device</code> lines.</p>
<!-- style-check: end procedure -->
<h2 id="write-the-iso-with-dd-instead">Write the ISO with dd instead<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#write-the-iso-with-dd-instead"
     aria-label="Link to section: Write the ISO with dd instead" data-pagefind-ignore></a>
</h2>
<p>If Etcher still stops after the erase, <code>dd</code> writes the image without Etcher&rsquo;s
helper. That&rsquo;s how the Mint drive in this article was written. The steps, the
checks that keep <code>dd</code> off the wrong disk, and how to confirm the write are in
<a href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/">Create a bootable Linux USB drive on macOS with dd</a>.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>macOS</dt> <dd>Tahoe 26.3.1, MacBook Pro (Apple Silicon)</dd></div>
    <div class="tested__row"><dt>USB</dt> <dd>PNY 32 GB USB 3.0</dd></div>
    <div class="tested__row"><dt>ISO</dt> <dd>Linux Mint 22.3 Cinnamon 64-bit</dd></div>
    <div class="tested__row"><dt>Reproduction</dt> <dd>balenaEtcher 2.1.6, CHIPSBNK 1 GB USB 2.0, Alpine 3.24.2 x86_64</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>March 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
