diff --git a/README.md b/README.md
index f052647..f040e3a 100644
--- a/README.md
+++ b/README.md
@@ -38,19 +38,21 @@ frames it's editing. Nothing is re-encoded until you export.
Plenty of tools bolt a chatbot onto a video editor. Kerf inverts that: the editor **is**
an API, exposed twice over one shared project — as the GUI you click, and as
[Model Context Protocol](https://modelcontextprotocol.io) tools an agent calls. So the
-agent isn't scripting a black box; it drives the identical engine, and every edit it
-makes shows up **live** in your timeline for you to review.
+agent isn't scripting a black box; it drives the identical engine — and its edits are
+**staged for review**, so they never move the cut you are looking at.
- 🎬 **The agent has eyes.** `get_frame`, `skim_asset` (a contact-sheet montage for
finding the good parts), and `preview_timeline` (the composited cut at a given time)
return real images the model *sees* — so it can find the right moment and confirm the
cut, not guess from metadata.
- 🧠 **Same engine, same project.** The MCP server and the webview hold the *same*
- `Project`. When the agent trims a clip, your GUI re-renders it instantly.
-- 🤝 **You stay in control.** Work is handed back and forth through a **task queue**: you
- enqueue plain-language tasks, the agent claims one, does the edits, and marks it *ready*
- for you to apply or dismiss. Kerf never edits on its own, and every change is a
- revertible entry in the edit history.
+ `Project` behind one lock — no import/export round-trip, no second copy of the truth,
+ and the agent previews its own work through the exact graph that renders your export.
+- 🤝 **You stay in control.** Agent edits **stage** — they build a *proposal*, not your
+ cut. The review card shows a real diff (added / cut / moved / retrimmed), you can
+ *watch* the proposed timeline play, and Apply lands it as one revertible revision.
+ Work is handed back through a persisted **task queue**, so an agent is safe to leave
+ running on someone's edit.
- ✂️ **Truly non-destructive.** Everything is an EDL over source ranges. Cuts, effects,
and keyframes are just data until you export a single `filter_complex`.
- 📦 **Runs without FFmpeg dev libraries.** The engine drives the `ffmpeg`/`ffprobe`
@@ -63,16 +65,20 @@ makes shows up **live** in your timeline for you to review.
| | |
| --- | --- |
-| **Multi-track NLE** | Bespoke timeline: video/audio/text tracks, free clip positioning with gaps, drag-to-move across tracks, edge-drag trim, razor split, ripple delete, snapping to edges / playhead / **beats**. |
-| **Audible playback** | Real Web-Audio playback with J/K/L shuttle + scrub; volume, fades, speed and reverse are auralized and the playhead follows the audio clock. |
-| **Analysis** | FFmpeg `silencedetect`, scene detection, audio energy/**beat** grid, and waveforms — all CLI-driven. Optional local **Whisper** transcription. |
-| **Effects & color** | Per-clip video (`blur`/`sharpen`/`hue`/`negate`/`vignette`/`chromakey`) and audio (`highpass`/`lowpass`/`EQ`/`compressor`/`gate`) chains, plus transform + color grade. |
+| **Multi-track NLE** | Bespoke timeline: video/audio/text tracks, free clip positioning with gaps, drag-to-move across tracks, edge-drag trim, razor split, ripple delete, markers, mute/solo/lock, snapping to edges / playhead / **beats**. |
+| **Real playback** | Not a slideshow — one long-lived FFmpeg streams the **composited** cut (every track, effect, keyframe and overlay) paced against the audio clock, with J/K/L shuttle and audible Web-Audio scrub. |
+| **Delivery frames** | 16:9 / 9:16 / 1:1 / 4:5 is a property of the **project**, so preview, scrub and export all render the same frame — with platform **safe-area guides** and cover-crop instead of letterboxing. |
+| **Analysis & speech** | Silence and scene detection, an onset/**tempo** grid, EBU R128 loudness and speech-vs-music from one decode — plus **transcription in every build**, via FFmpeg's `whisper` filter or in-process whisper.cpp (models fetched on first use). |
+| **Cut to the beat** | The tempo grid is drawn on the ruler and snapped to while dragging, and `snap_to_beats` ripples a whole track's cuts onto it, retrimming each clip at its **outgoing** edge. |
+| **Effects, color & looks** | Per-clip video (`blur`/`sharpen`/`hue`/`negate`/`vignette`/`chromakey`) and audio (`highpass`/`lowpass`/`EQ`/`compressor`/`gate`) chains, transform + color grade, and one-click **Punchy / Warm / Cool / Faded / B&W** looks. |
| **Keyframe animation** | Animated zoom, position, rotation and opacity via piecewise-linear keyframes — the Transform panel auto-keyframes at the playhead. |
| **360 reframing** | Cut a normal, flat shot out of **equirect or Insta360 dual-fisheye** footage: aim a virtual camera (yaw / pitch / roll / FOV) and **keyframe the pan**. An **Insta360 lens pair imports as one 360 asset** — Kerf stitches the two capture files into an equirect sphere once and caches it. Spherical sources are detected on import; anything unflagged can be marked by hand. |
-| **Titles & captions** | Text overlays / lower-thirds with their own keyframes, and one-click **captions from a transcript** (SRT export too). |
+| **Titles & captions** | Text overlays / lower-thirds with their own keyframes, Title / Lower-third / Caption style presets, the system font list, one-click **captions from a transcript**, and SRT export. |
+| **Edit by transcript** | The transcript is an editing surface: lines resolve to the clip carrying them, a click seeks, and `×` cuts that sentence out of the timeline and ripples the gap closed. |
| **Smart mixing** | Per-track **ducking** (music dips under dialogue via sidechain) and single-pass **loudnorm** to −14 LUFS on export. |
-| **Export** | Positional, multi-track `filter_complex` with progress + cancel; **range export** renders just the region between your in/out marks. |
-| **Agent workflow** | 60 MCP tools, a persisted task queue, and a fully revertible edit history attributed to user / agent / system. |
+| **GPU-accelerated export** | NVENC / QSV / VideoToolbox / AMF are **verified with a real test encode** before being offered, hardware decode falls back to software on its own, and **range export** renders just the region between your in/out marks. |
+| **Agent workflow** | 78 MCP tools, **staged edits with a reviewable diff**, a persisted task queue, and a fully revertible edit history attributed to user / agent / system. |
+| **Auto-update** | The app updates itself from its own GitHub releases; a bundle only installs if its **minisign signature** verifies against the embedded public key. |
@@ -88,10 +94,13 @@ makes shows up **live** in your timeline for you to review.
### Download a build
-Grab the installer for your platform from the
-[**latest release**](https://github.com/OrellBuehler/kerf/releases/latest) — `.dmg`/`.app`
-(macOS), `.AppImage`/`.deb` (Linux), or `.msi`/`.exe` (Windows). macOS and Linux builds
-expect a system FFmpeg on `PATH`; the Windows build bundles it.
+The [**Kerf site**](https://orellbuehler.github.io/kerf/#get) has a direct download
+button for your platform, or grab the installer from the
+[**latest release**](https://github.com/OrellBuehler/kerf/releases/latest) — `.dmg`
+(macOS, Apple silicon + Intel), `.AppImage`/`.deb`/`.rpm` (Linux), or `.exe`/`.msi`
+(Windows). macOS and Linux builds expect a system FFmpeg on `PATH`; the Windows build
+bundles it. The macOS build is unsigned, so open it once with right-click → **Open**.
+Once installed, Kerf updates itself from its own releases.
### Or build from source
@@ -131,7 +140,7 @@ Now ask the agent to work on the project you have open — _"skim the interview
the dead air, and drop in captions."_ It will claim a task, use the tools below, and hand
back a reviewable result.
-### The tools (59)
+### The tools (78)
See / analyze
@@ -146,8 +155,10 @@ the last three return images the model can see.
Cut & arrange
`cut_clip` · `add_clip_to_timeline` · `split_at` · `trim` · `reorder` · `move_clip` ·
-`remove` · `ripple_delete` · `cut_clip_range` · `add_track` · `remove_track` ·
-`set_track_duck` · `remove_silence` · `extract_audio` · `concatenate`
+`remove` · `ripple_delete` · `cut_clip_range` · `duplicate_clips` · `set_clip_enabled` ·
+`add_track` · `remove_track` · `set_track_duck` · `set_track_muted` · `set_track_solo` ·
+`set_track_locked` · `add_marker` · `update_marker` · `remove_marker` · `remove_silence` ·
+`snap_to_beats` · `extract_audio` · `concatenate`
@@ -158,18 +169,20 @@ the last three return images the model can see.
`add_keyframe` · `clear_keyframes` · `set_reframe` · `clear_reframe` ·
`set_reframe_keyframes` · `add_reframe_keyframe` · `add_overlay` · `update_overlay` ·
`remove_overlay` · `set_overlay_keyframes` · `captions_from_transcript` · `export_srt` ·
-`list_fonts`
+`list_fonts` · `set_delivery_format` · `set_asset_projection`
Render & hand-off
-`export` · `list_tasks` · `add_task` · `claim_next_task` · `complete_task` · `fail_task` ·
-`resolve_task` · `remove_task` · `history` · `undo` · `redo` · `revert_to`
+`export` · `export_capabilities` · `stage_edits` · `staged_diff` · `apply_staged_edits` ·
+`discard_staged_edits` · `revision_diff` · `list_tasks` · `add_task` · `claim_next_task` ·
+`complete_task` · `fail_task` · `resolve_task` · `remove_task` · `history` · `undo` ·
+`redo` · `revert_to` · `transcription_status` · `download_speech_model`
-Every mutating tool emits a `project-changed` event, so edits appear in the GUI as the
-agent makes them.
+Every mutating tool emits a `project-changed` event, so the agent's proposal appears in
+the GUI for review as it is built — the live cut only moves when *you* apply it.
---
@@ -283,8 +296,7 @@ preview, transcript, analysis, effects, keyframes, captions, playback and export
wired to real backend state, and the MCP surface is exercised end-to-end.
**Roadmap:** a live activity stream pushed from the MCP server (the queue is polled
-today), richer staged-edit diffs in the review step, and auralized effect chains in
-preview playback.
+today), transitions beyond crossfade / dip-to-black, and audible reverse shuttle.
Contributions welcome — see [`CONTRIBUTING.md`](./CONTRIBUTING.md).
diff --git a/docs/img/README.md b/docs/img/README.md
index 48ddc18..f00eff5 100644
--- a/docs/img/README.md
+++ b/docs/img/README.md
@@ -17,7 +17,7 @@ timeline — a few clips across two or three tracks, a waveform, a scene marker
| Filename | What to capture | Used by |
| ---------------------------- | ------------------------------------------------------------------------------- | ------------------------ |
-| `screenshot-editor.png` | The **full editor** window — title bar, media bin, preview, multi-track timeline. Hero shot. | README + site `og:image` |
+| `screenshot-editor.png` | The **full editor** window — title bar, media bin, preview, multi-track timeline. Hero shot. | README |
| `screenshot-agent.png` | The **agent panel** — task queue (a `ready` task with Apply/Dismiss) + history. | README |
| `screenshot-inspector.png` | The **inspector** — an effects chain, the Transform/keyframe panel, or overlays. | README |
@@ -31,6 +31,20 @@ Tips:
- Keep the amber playhead visible — it's the brand's signature.
Until these exist, the `README` shows broken-image placeholders where they go. The
-landing site no longer embeds them — it renders animated CSS/SVG mockups instead — but
-its `og:image` still points at `screenshot-editor.png`, so social-link previews stay
-broken until the hero shot lands.
+landing site no longer embeds them — it renders animated CSS/SVG mockups instead.
+
+## `og.png` — the social-card image
+
+`og.png` (1200×630) is the site's `og:image`, and it is **generated, not captured**:
+[`../og-source.html`](../og-source.html) is the layout, rendered headless at exactly
+that size. It lives one level up so the Pages build, which mounts *this* folder at
+`static/img/`, does not publish it as a page.
+
+```bash
+google-chrome --headless=new --disable-gpu --hide-scrollbars \
+ --screenshot=docs/img/og.png --window-size=1200,630 \
+ --virtual-time-budget=6000 "file://$PWD/docs/og-source.html"
+```
+
+The budget matters: the page pulls Space Grotesk / Inter / JetBrains Mono from Google
+Fonts, and a shorter one screenshots the fallback faces.
diff --git a/docs/img/og.png b/docs/img/og.png
new file mode 100644
index 0000000..31637fa
Binary files /dev/null and b/docs/img/og.png differ
diff --git a/docs/og-source.html b/docs/og-source.html
new file mode 100644
index 0000000..9a72982
--- /dev/null
+++ b/docs/og-source.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+ Kerf
+
+
The video editor your AI can drive.
+
A non-destructive desktop NLE with an embedded MCP server — the agent
+ drives the same engine as the GUI, and can see the frames it edits.
+
NON-DESTRUCTIVE · MCP-NATIVE · FFMPEG-BACKED · macOS · WINDOWS · LINUX
+
+
+
+
+
+
+
diff --git a/site/assets/css/kerf.css b/site/assets/css/kerf.css
index 4d74a4f..72188ba 100644
--- a/site/assets/css/kerf.css
+++ b/site/assets/css/kerf.css
@@ -187,7 +187,9 @@ pre {
background: var(--surface-hover);
}
@media (max-width: 640px) {
- .nav-links a:not(.ghost-link) {
+ /* Only the download chip survives — it is the primary action, and the
+ section links are all reachable by scrolling anyway. */
+ .nav-links a:not(.nav-dl) {
display: none;
}
}
@@ -221,8 +223,8 @@ pre {
h1 {
font-family: var(--font-display);
font-weight: 700;
- font-size: clamp(38px, 6.4vw, 68px);
- line-height: 1.02;
+ font-size: clamp(36px, 5.4vw, 60px);
+ line-height: 1.04;
letter-spacing: -0.025em;
margin: 18px 0 0;
}
@@ -689,6 +691,12 @@ code {
border-bottom-color: rgba(226, 157, 46, 0.4);
}
+/* The open download menu has to clear the band below it: every `.wrap` sits at
+ z-index 1, so DOM order alone would paint the stat strip over the hero. */
+.hero .wrap {
+ z-index: 20;
+}
+
/* Wider hero on large screens: copy + shot side by side */
@media (min-width: 1000px) {
.hero {
@@ -1461,6 +1469,41 @@ html.js .stagger.in > *:nth-child(n + 9) { transition-delay: 540ms; }
89% { box-shadow: 0 0 0 1px rgba(234, 177, 78, 0.55), 0 0 34px rgba(226, 157, 46, 0.25); }
94%, 100% { box-shadow: var(--shadow-lg); }
}
+.ms-diff {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px 10px;
+ padding: 8px 14px;
+ border-top: 1px solid var(--border-subtle);
+ font-size: 10px;
+}
+.ms-diff .lead {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+.ms-diff i {
+ font-style: normal;
+ padding: 1px 6px;
+ border-radius: 3px;
+ border: 1px solid transparent;
+}
+.ms-diff .add {
+ color: #6fd39b;
+ background: rgba(40, 197, 133, 0.1);
+ border-color: rgba(40, 197, 133, 0.28);
+}
+.ms-diff .cut {
+ color: #f08a78;
+ background: rgba(241, 84, 63, 0.1);
+ border-color: rgba(241, 84, 63, 0.28);
+}
+.ms-diff .shift {
+ color: var(--kerf-300);
+ background: rgba(226, 157, 46, 0.1);
+ border-color: rgba(226, 157, 46, 0.3);
+}
+
.ms-foot {
padding: 9px 14px;
border-top: 1px solid var(--border-subtle);
@@ -1610,6 +1653,402 @@ html.js .stagger.in > *:nth-child(n + 9) { transition-delay: 540ms; }
border-color: var(--border-strong);
}
+/* =============================================================
+ Downloads.
+ The links ship pointing at the releases page and are rewritten
+ in place by assets/js/download.js once it has resolved the real
+ asset URLs — so every state below has to read correctly with
+ the script blocked, too.
+ ============================================================= */
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ---- The split button ---- */
+.dl {
+ position: relative;
+ display: inline-flex;
+ align-items: stretch;
+}
+.dl-main {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ padding-right: 18px;
+}
+.dl-ic {
+ width: 15px;
+ height: 15px;
+ flex: none;
+ opacity: 0.9;
+}
+.dl-toggle {
+ position: relative;
+ padding: 0 12px;
+ margin-left: 1px;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ box-shadow: 0 8px 26px rgba(226, 157, 46, 0.28);
+ cursor: pointer;
+ font: inherit;
+}
+/* One seam between the two halves, drawn on the dark button face. */
+.dl-toggle::before {
+ content: "";
+ position: absolute;
+ left: -1px;
+ top: 9px;
+ bottom: 9px;
+ width: 1px;
+ background: rgba(26, 18, 5, 0.25);
+}
+.dl-chev {
+ width: 14px;
+ height: 14px;
+ transition: transform 160ms var(--ease-out);
+}
+.dl-toggle[aria-expanded="true"] .dl-chev {
+ transform: rotate(180deg);
+}
+
+/* ---- The platform menu ---- */
+.dl-menu {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 10px);
+ left: 0;
+ min-width: 290px;
+ padding: 8px;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-pop);
+}
+.dl-menu[hidden] {
+ display: none;
+}
+html.js .dl-menu:not([hidden]) {
+ animation: dl-pop 150ms var(--ease-out);
+}
+@keyframes dl-pop {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+}
+.dl-menu-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 4px 10px 8px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+.dl-ver {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--kerf-400);
+}
+.dl-group + .dl-group {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border-subtle);
+}
+.dl-group-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 10px 4px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+.os-ic {
+ flex: none;
+ opacity: 0.75;
+}
+.dl-menu-foot {
+ display: block;
+ margin-top: 6px;
+ padding: 9px 10px 5px;
+ border-top: 1px solid var(--border-subtle);
+ font-size: 12.5px;
+ color: var(--text-muted);
+}
+.dl-menu-foot:hover {
+ color: var(--kerf-400);
+}
+
+/* ---- One downloadable file ---- */
+.dl-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 8px 10px;
+ border-radius: var(--radius-lg);
+ font-size: 14px;
+ color: var(--text-primary);
+ transition:
+ background 120ms,
+ color 120ms;
+}
+.dl-row[hidden] {
+ display: none;
+}
+.dl-row:hover {
+ background: var(--surface-hover);
+}
+.dl-row-name {
+ font-weight: 500;
+}
+.dl-row-meta {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 9px;
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+.dl-ext {
+ color: var(--text-secondary);
+}
+.dl-size:empty {
+ display: none;
+}
+/* The row the hero button points at. */
+.dl-row.is-pick .dl-row-name::after {
+ content: "Detected";
+ margin-left: 9px;
+ padding: 1px 7px;
+ border-radius: var(--radius-full);
+ background: rgba(226, 157, 46, 0.13);
+ border: 1px solid rgba(226, 157, 46, 0.3);
+ color: var(--kerf-300);
+ font-family: var(--font-sans);
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ vertical-align: 1px;
+}
+
+/* ---- Hero meta lines ---- */
+.dl-meta {
+ margin: 14px 0 0;
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ color: var(--kerf-300);
+}
+.dl-meta[hidden] {
+ display: none;
+}
+.dl-note {
+ margin: 10px 0 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+.dl-note a {
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border-strong);
+}
+.dl-note a:hover {
+ color: var(--kerf-400);
+}
+.get-cta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 26px;
+}
+.get-cta .dl-meta {
+ margin: 0;
+}
+
+/* ---- The Get-it platform cards ---- */
+.dl-cards {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 18px;
+ margin-bottom: 22px;
+}
+@media (max-width: 860px) {
+ .dl-cards {
+ grid-template-columns: 1fr;
+ }
+}
+.dl-card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ padding: 20px 18px 18px;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-2xl);
+ transition:
+ border-color 200ms,
+ transform 200ms var(--ease-out);
+}
+.dl-card:hover {
+ transform: translateY(-2px);
+ border-color: var(--border-strong);
+}
+.dl-card.is-mine {
+ border-color: rgba(226, 157, 46, 0.4);
+ box-shadow: 0 0 0 1px rgba(226, 157, 46, 0.12);
+}
+.dl-card header {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+.dl-card h3 {
+ flex: 1;
+ margin: 0;
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 17px;
+}
+.dl-card .os-ic {
+ opacity: 0.85;
+}
+.dl-mine {
+ display: none;
+ padding: 2px 9px;
+ border-radius: var(--radius-full);
+ background: rgba(226, 157, 46, 0.13);
+ border: 1px solid rgba(226, 157, 46, 0.3);
+ color: var(--kerf-300);
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+}
+.dl-card.is-mine .dl-mine {
+ display: inline-block;
+}
+.dl-card .dl-row {
+ margin: 0 -8px;
+ padding: 8px;
+}
+.dl-card .dl-row.is-pick .dl-row-name::after {
+ content: none;
+}
+.dl-card .fine {
+ /* auto so the fine print sits on one baseline across cards of unequal
+ row counts; the padding keeps a gap when there is no slack to take. */
+ margin: auto 0 0;
+ padding-top: 16px;
+ border-top: 1px solid var(--border-subtle);
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: var(--text-muted);
+}
+.dl-card .fine code {
+ font-size: 0.92em;
+}
+
+/* =============================================================
+ Stat strip — the numbers that are actually true.
+ ============================================================= */
+.stats-band {
+ border-top: 1px solid var(--border-subtle);
+ border-bottom: 1px solid var(--border-subtle);
+ background: rgba(10, 12, 15, 0.4);
+}
+.stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ padding-top: 26px;
+ padding-bottom: 26px;
+}
+@media (max-width: 780px) {
+ .stats {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 20px 8px;
+ }
+}
+.stat {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-left: 16px;
+ border-left: 1px solid var(--border-subtle);
+}
+.stat b {
+ font-family: var(--font-display);
+ font-size: clamp(19px, 2.4vw, 26px);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--kerf-400);
+ line-height: 1.1;
+}
+.stat span {
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--text-muted);
+}
+.stat:first-child {
+ padding-left: 0;
+ border-left: 0;
+}
+@media (max-width: 780px) {
+ .stat:nth-child(3) {
+ padding-left: 0;
+ border-left: 0;
+ }
+}
+
+/* Nav: a download link that reads as the primary action. */
+.nav-dl {
+ padding: 7px 15px;
+ border-radius: var(--radius-full);
+ background: var(--kerf-500);
+ color: #1a1205 !important;
+ font-weight: 600;
+}
+.nav-dl:hover {
+ background: var(--kerf-400);
+}
+@media (max-width: 640px) {
+ .nav-links {
+ gap: 12px;
+ }
+}
+
+/* The staged-review tool reads as neither "sees" nor plain. */
+.chip.stage {
+ color: var(--kerf-300);
+ border-color: rgba(226, 157, 46, 0.28);
+ background: rgba(226, 157, 46, 0.07);
+}
+
+/* Keyboard focus was invisible on the dark surfaces. */
+:focus-visible {
+ outline: 2px solid var(--kerf-400);
+ outline-offset: 2px;
+ border-radius: var(--radius-md);
+}
+
/* ---- Reduced motion: freeze everything on its legible still ---- */
@media (prefers-reduced-motion: reduce) {
html.js .reveal,
@@ -1634,4 +2073,7 @@ html.js .stagger.in > *:nth-child(n + 9) { transition-delay: 540ms; }
.flow-svg .pulse {
display: none;
}
+ html.js .dl-menu:not([hidden]) {
+ animation: none;
+ }
}
diff --git a/site/assets/js/download.js b/site/assets/js/download.js
new file mode 100644
index 0000000..a5d33cb
--- /dev/null
+++ b/site/assets/js/download.js
@@ -0,0 +1,281 @@
+/* =============================================================
+ Direct downloads.
+
+ Release assets carry their version in the file name
+ (`Kerf_0.19.1_x64-setup.exe`), so GitHub's stable
+ `releases/latest/download/` URL cannot address them. This
+ resolves the real asset URLs from the API instead, points the
+ hero button at the visitor's own platform, and fills in the
+ version and size on every platform row.
+
+ Progressive enhancement: every link ships pointing at the
+ releases page, so the page stays usable with this file blocked
+ or the API unreachable.
+ ============================================================= */
+(function () {
+ "use strict";
+
+ var root = document.querySelector("[data-dl]");
+ if (!root || !window.fetch) return;
+
+ var REPO = root.getAttribute("data-repo");
+ if (!REPO) return;
+
+ function all(sel) {
+ return Array.prototype.slice.call(root.querySelectorAll(sel));
+ }
+
+ /* --- The asset table -------------------------------------------------
+ One entry per downloadable file we publish. `match` recognizes the
+ asset by name, so `.sig` files and latest.json never match. */
+ var ASSETS = [
+ { key: "windows-exe", os: "windows", sub: "Installer (.exe)", match: /_x64-setup\.exe$/i },
+ { key: "windows-msi", os: "windows", sub: "MSI package", match: /_x64_en-US\.msi$/i },
+ { key: "macos-arm", os: "macos", sub: "Apple silicon (.dmg)", match: /_aarch64\.dmg$/i },
+ { key: "macos-intel", os: "macos", sub: "Intel (.dmg)", match: /_x64\.dmg$/i },
+ { key: "linux-appimage", os: "linux", sub: "AppImage", match: /_amd64\.AppImage$/i },
+ { key: "linux-deb", os: "linux", sub: "Debian / Ubuntu (.deb)", match: /_amd64\.deb$/i },
+ { key: "linux-rpm", os: "linux", sub: "Fedora / RHEL (.rpm)", match: /\.x86_64\.rpm$/i }
+ ];
+
+ /* Which file a visitor on this platform actually wants. */
+ var PREFERRED = {
+ windows: "windows-exe",
+ "macos:arm64": "macos-arm",
+ "macos:x64": "macos-intel",
+ linux: "linux-appimage"
+ };
+
+ var OS_NAME = { windows: "Windows", macos: "macOS", linux: "Linux" };
+
+ /* --- Platform detection ---------------------------------------------- */
+
+ function detectOs() {
+ var ua = navigator.userAgent || "";
+ // Phones and tablets get no desktop build: leave them undetected so the
+ // button stays a neutral "Download" pointing at the release page.
+ if (/Android|iPhone|iPad|iPod/i.test(ua)) return "";
+ var hint = (navigator.userAgentData && navigator.userAgentData.platform) || "";
+ var p = hint || navigator.platform || "";
+ if (/Win/i.test(p) || /Windows/i.test(ua)) return "windows";
+ if (/Mac/i.test(p) || /Mac OS X/i.test(ua)) return "macos";
+ if (/Linux|X11|CrOS/i.test(p) || /Linux|X11|CrOS/i.test(ua)) return "linux";
+ return "";
+ }
+
+ // Safari exposes no architecture hint and every Mac still reports
+ // "MacIntel" in navigator.platform — so ask the GPU. Apple silicon renders
+ // through "Apple GPU" / "Apple M…"; an Intel Mac names Intel, AMD or NVIDIA.
+ function macIsAppleSilicon() {
+ try {
+ var gl =
+ document.createElement("canvas").getContext("webgl") ||
+ document.createElement("canvas").getContext("experimental-webgl");
+ if (!gl) return null;
+ var dbg = gl.getExtension("WEBGL_debug_renderer_info");
+ if (!dbg) return null;
+ var r = String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || "");
+ if (/Intel|AMD|Radeon|NVIDIA|GeForce/i.test(r)) return false;
+ if (/Apple/i.test(r)) return true;
+ } catch (e) {
+ /* blocked by a privacy setting — fall through to "unknown" */
+ }
+ return null;
+ }
+
+ /* --- Release lookup ---------------------------------------------------
+ Not `/releases/latest`: publishing a tag makes it "latest" some 25
+ minutes before the slowest bundle finishes uploading, so the newest
+ release can legitimately carry no assets yet. Walk back to the newest
+ one that actually has files. */
+
+ var CACHE_KEY = "kerf.dl.v1";
+ var CACHE_MS = 30 * 60 * 1000;
+
+ function cached() {
+ try {
+ var v = JSON.parse(sessionStorage.getItem(CACHE_KEY) || "null");
+ return v && Date.now() - v.at < CACHE_MS ? v.data : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function store(data) {
+ try {
+ sessionStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), data: data }));
+ } catch (e) {
+ /* private mode — just skip the cache */
+ }
+ }
+
+ function resolve(rel) {
+ var files = {};
+ var oses = {};
+ (rel.assets || []).forEach(function (a) {
+ ASSETS.forEach(function (spec) {
+ if (!files[spec.key] && spec.match.test(a.name)) {
+ files[spec.key] = { url: a.browser_download_url, size: a.size };
+ oses[spec.os] = true;
+ }
+ });
+ });
+ var n = Object.keys(files).length;
+ if (!n) return null;
+ return {
+ tag: rel.tag_name,
+ url: rel.html_url,
+ files: files,
+ complete: oses.windows && oses.macos && oses.linux
+ };
+ }
+
+ /* The newest release carrying all three platforms. A release whose slowest
+ bundle is still uploading has some but not all, and offering it would
+ leave whoever is on the missing platform with no direct link at all — so
+ fall back to the newest complete one, and only settle for a partial
+ release if the whole fetched window is partial. */
+ function pickRelease(list) {
+ var partial = null;
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].draft || list[i].prerelease) continue;
+ var got = resolve(list[i]);
+ if (!got) continue;
+ if (got.complete) return got;
+ if (!partial) partial = got;
+ }
+ return partial;
+ }
+
+ function fetchRelease() {
+ var hit = cached();
+ if (hit) return Promise.resolve(hit);
+ return fetch("https://api.github.com/repos/" + REPO + "/releases?per_page=10", {
+ headers: { Accept: "application/vnd.github+json" }
+ })
+ .then(function (r) {
+ if (!r.ok) throw new Error("github " + r.status);
+ return r.json();
+ })
+ .then(function (list) {
+ var data = Array.isArray(list) && pickRelease(list);
+ if (!data) throw new Error("no release with assets");
+ store(data);
+ return data;
+ });
+ }
+
+ /* --- Rendering -------------------------------------------------------- */
+
+ function mb(bytes) {
+ if (!bytes) return "";
+ var m = bytes / 1048576;
+ return (m >= 100 ? Math.round(m) : m.toFixed(1)) + " MB";
+ }
+
+ function specFor(key) {
+ for (var i = 0; i < ASSETS.length; i++) if (ASSETS[i].key === key) return ASSETS[i];
+ return null;
+ }
+
+ function apply(data) {
+ root.classList.add("dl-live");
+
+ // Every platform row on the page — hero menu and the Get-it grid alike.
+ ASSETS.forEach(function (spec) {
+ var file = data.files[spec.key];
+ all('[data-dl-asset="' + spec.key + '"]').forEach(function (row) {
+ if (!file) {
+ row.setAttribute("hidden", "");
+ return;
+ }
+ row.removeAttribute("hidden");
+ row.href = file.url;
+ row.removeAttribute("target");
+ row.removeAttribute("rel");
+ var size = row.querySelector("[data-dl-size]");
+ if (size) size.textContent = mb(file.size);
+ });
+ });
+
+ all("[data-dl-version]").forEach(function (el) {
+ el.textContent = data.tag;
+ el.removeAttribute("hidden");
+ });
+
+ var os = detectOs();
+ if (!os) return;
+
+ var wantKey = PREFERRED[os];
+ if (os === "macos") {
+ // Unknown architecture keeps Apple silicon — the overwhelming majority
+ // of Macs in use — and the Intel build stays one click away in the menu.
+ wantKey = PREFERRED[macIsAppleSilicon() === false ? "macos:x64" : "macos:arm64"];
+ }
+
+ var spec = specFor(wantKey);
+ var file = spec && data.files[spec.key];
+ if (!file) return;
+
+ all("[data-dl-primary]").forEach(function (main) {
+ main.href = file.url;
+ main.removeAttribute("target");
+ main.removeAttribute("rel");
+ var label = main.querySelector("[data-dl-label]");
+ if (label) label.textContent = "Download for " + OS_NAME[spec.os];
+ });
+
+ all("[data-dl-meta]").forEach(function (meta) {
+ meta.textContent = [data.tag, spec.sub, mb(file.size)].filter(Boolean).join(" · ");
+ meta.removeAttribute("hidden");
+ });
+
+ // Show the visitor where the button points.
+ all('[data-dl-asset^="' + spec.os + '"]').forEach(function (row) {
+ row.classList.add("is-mine");
+ });
+ all('[data-dl-asset="' + spec.key + '"]').forEach(function (row) {
+ row.classList.add("is-pick");
+ });
+ all('[data-dl-card="' + spec.os + '"]').forEach(function (card) {
+ card.classList.add("is-mine");
+ });
+ }
+
+ /* --- The split button's menu ------------------------------------------ */
+
+ function wireMenu(menu) {
+ var wrap = menu.closest(".dl");
+ var toggle = wrap && wrap.querySelector("[data-dl-toggle]");
+ if (!toggle) return;
+
+ function open(on) {
+ toggle.setAttribute("aria-expanded", on ? "true" : "false");
+ if (on) menu.removeAttribute("hidden");
+ else menu.setAttribute("hidden", "");
+ }
+
+ toggle.addEventListener("click", function (e) {
+ e.preventDefault();
+ open(toggle.getAttribute("aria-expanded") !== "true");
+ });
+ document.addEventListener("click", function (e) {
+ if (!wrap.contains(e.target)) open(false);
+ });
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape") open(false);
+ });
+ menu.addEventListener("click", function (e) {
+ if (e.target && e.target.closest && e.target.closest("a")) open(false);
+ });
+ open(false);
+ }
+
+ all("[data-dl-menu]").forEach(wireMenu);
+
+ fetchRelease().then(apply, function () {
+ /* Offline, rate-limited, or no published assets: the links already point
+ at the releases page, which is the right destination anyway. */
+ root.classList.add("dl-fallback");
+ });
+})();
diff --git a/site/hugo.toml b/site/hugo.toml
index a1d4e86..b433250 100644
--- a/site/hugo.toml
+++ b/site/hugo.toml
@@ -7,11 +7,16 @@ enableRobotsTXT = true
disableKinds = ["taxonomy", "term", "RSS", "sitemap"]
[params]
- description = "Kerf is a non-destructive, FFmpeg-backed desktop video editor with an embedded MCP server, so an LLM can analyze your footage and assemble the cut through the same engine the GUI uses — and can see the frames it edits."
+ description = "Kerf is a non-destructive, FFmpeg-backed desktop video editor with an embedded MCP server, so an LLM can analyze your footage and assemble the cut through the same engine the GUI uses — and can see the frames it edits. Free download for macOS, Windows and Linux."
tagline = "The non-destructive video editor your AI can drive."
github = "https://github.com/OrellBuehler/kerf"
+ # owner/name — download.js resolves release assets through the GitHub API,
+ # because the asset file names carry the version and so cannot be addressed
+ # by the stable releases/latest/download/ URL.
+ repo = "OrellBuehler/kerf"
releases = "https://github.com/OrellBuehler/kerf/releases/latest"
mcpUrl = "http://127.0.0.1:7777/mcp"
+ toolCount = 78
[minify]
minifyOutput = true
diff --git a/site/layouts/_default/baseof.html b/site/layouts/_default/baseof.html
index 4c4a6e4..8f41df8 100644
--- a/site/layouts/_default/baseof.html
+++ b/site/layouts/_default/baseof.html
@@ -6,10 +6,18 @@
{{ .Title }}
+
+
+
-
+ {{/* docs/img is mounted at static/img, so this is the same file the repo
+ README can use — see docs/img/README.md for how it is regenerated. */}}
+
+
+
+
@@ -46,5 +54,7 @@
io.observe(el);
});
+ {{ $dl := resources.Get "js/download.js" | minify | fingerprint }}
+