clipshelf.

Docs

Clipshelf sits alongside OBS. It doesn't record, doesn't replace anything, and never touches your files — it reads the folder OBS already writes to.

Installing

Windows 10/11, 64-bit. Run the installer. It's per-user, so there's no admin prompt. It brings its own Python and fetches ffmpeg on first run. Nothing is code-signed yet, so SmartScreen will warn you — "More info" then "Run anyway". The checksums on the download page are there so you can check the file is the one we published.

Linux, x86_64. One AppImage. Make it executable and run it:

chmod +x Clipshelf-*.AppImage
./Clipshelf-*.AppImage

It carries its own Python and Node, and opens in your browser. If your distro has dropped FUSE and it won't start, run it with --appimage-extract-and-run.

Linux notes

Needs glibc 2.28 or newer — Debian 10+, Ubuntu 18.10+, RHEL/Alma 8+, Fedora 29+. x86_64 only; there is no ARM build.

Three differences from Windows, all deliberate:

Games launched through Proton or Wine are detected by their Windows executable name, which is exactly what the bundled games database is keyed on — so Steam titles chapter correctly. A native Linux build with an unusual name may not be recognised; you can name it yourself in Settings, or mark chapters with the hotkey.

Setting up OBS

Clipshelf reads whatever OBS already writes, so there is nothing you have to change. This is the setup that gets the most out of it, and the reasoning behind each choice — take the parts you want.

Two things here are ahead of the current download: MKV and MOV recordings, and recordings in subfolders. On 0.1.17 keep your recordings as MP4 in one folder — the app finds nothing otherwise, and won't tell you why. Both land in the next release. Everything else on this page applies today.

Recording format

Clipshelf reads .mp4, .mkv and .mov.

Use Hybrid MP4 if your OBS offers it (30.2 and newer, under Settings → Output → Recording → Recording Format). It survives a crash or a power cut the way MKV does, and it is still an MP4 at the end — which means the recording plays in the app the moment it is written, with nothing to wait for.

MKV is fine, and it is what OBS defaults to for a good reason: plain MP4 writes the index at the very end, so a machine that falls over mid-session loses the entire recording, while MKV loses only the tail. The one cost is that no browser can play Matroska, and the app's player is a browser — so an MKV night plays once its scrub preview has been built (that happens on its own within a few minutes of the recording finishing, or immediately from the build scrub preview button). Everything else — the timeline, markers, cutting, download — works the whole time.

Avoid plain MP4. It is the one format that can lose a whole night.

If you use Settings → Advanced → Automatically remux to MP4 and it leaves both files behind, Clipshelf keeps the MP4 and ignores its twin — one night, one entry.

Filename and folders

Settings → Advanced → Recording → Filename Formatting.

Keep the timestamp at the front. OBS's default, %CCYY-%MM-%DD %hh-%mm-%ss, is how Clipshelf knows when a recording started, and therefore where on it a kill at 21:47 belongs. Adding to the end is fine — 2026-09-01 21-30-00 deadlock ranked reads back correctly. Moving the stamp or dropping it is not: the app falls back to the file's creation date, which on a library you have copied between drives is the day you copied it, and every marker lands at the wrong offset without complaining.

A / makes OBS create folders, and Clipshelf follows them up to three deep. So this is a supported layout, not a workaround:

recording path      D:\Footage
filename            OBS_Raw/%CCYY-%MM-%DD %hh-%mm-%ss
replay prefix       OBS_Replays/Replay_

Point Clipshelf at D:\Footage — the top of the tree, not the subfolder — and it finds both, and knows which are replays rather than nights. If you renamed the replay prefix from Replay, the setup wizard reads that out of your OBS profile so it still knows the difference.

Encoder

Use a hardware encoder if you have one (NVENC on NVIDIA, AMF on AMD, QuickSync on Intel). It costs a few frames rather than the 15–20% that x264 takes off a CPU already running the game.

SettingUseWhy
Rate controlCQP (hardware) or CRF (x264)Constant quality. CBR is for streaming, where the bitrate is the constraint; on your own disk it wastes bits on menus and starves the fights.
CQ / CRF level18–22Lower is better and bigger. 20 is a good place to start; 16 is close to indistinguishable.
Keyframe interval2sWorth setting deliberately: an instant cut copies the video rather than re-encoding it, so it can only start on a keyframe. Two seconds is the difference between a clip that starts where you asked and one that starts a beat early.
PresetNVENC P5/Quality, x264 veryfastSlower presets buy little at these quality levels and cost frames.
Profilehigh

Record at your monitor's resolution with no downscale, and at 60 fps for anything competitive. Leave Settings → Video → Downscale Filter alone unless you are actually downscaling.

Audio tracks — the one worth doing

This is the setting that changes what the app can do for you rather than just how the file looks.

In Settings → Output → Recording, tick audio tracks 1, 2 and 3. Then in the Audio Mixer, open each source's Advanced Audio Properties and assign:

TrackCarriesWhat it buys
1everything, mixedthe ordinary track — what a clip uses when you share it
2your microphone alonespeech search and captions read this one, so they transcribe you instead of gunfire and a Discord call
3desktop/game alonelets you drop your own voice from a clip after the fact, instead of wishing you had

Then tell the app which one is you: Settings → Recording → which track is your mic. It defaults to 1, the mixed track, which works but transcribes everything at once.

Audio bitrate 160–192 kbps, sample rate 48 kHz to match what every game outputs.

Replay buffer

Turn it on: Settings → Output → Replay Buffer, 30–60 seconds. It is what makes the clip hotkey able to save something that already happened — without it, a hotkey can only mark the moment for you to trim later.

Its format follows your recording format, so replays can arrive as MKV too; the app remuxes those to MP4 as they land, which takes about a second and changes nothing about the picture.

The websocket

Tools → WebSocket Server Settings → Enable, port 4455. Clipshelf uses it to ask OBS to save the buffer, and to know exactly when a recording started rather than inferring it from the clock. The app's self-test tells you if it can't reach it.

Hotkeys

On Windows, set them in Settings → Hotkeys and you're done.

On Linux there is no global hotkey backend, and this is not an oversight. Wayland deliberately does not let an ordinary application listen to the keyboard while it isn't focused. The two ways around it are the desktop portal — which GNOME only honours for Flatpak apps — and reading /dev/input directly, which needs your user in the input group. That's a permission this app has no business asking for.

So bind it in your desktop instead. Clipshelf prints the exact command, with your install's token already in it, at Settings → Hotkeys (or GET /api/clipmark/info). It looks like:

curl -X POST 'http://127.0.0.1:8420/api/clipmark?token=YOUR-TOKEN'

Then bind that to a key:

DesktopWhere
GNOMESettings → Keyboard → Custom Shortcuts → new, paste the command
KDE PlasmaSystem Settings → Shortcuts → Add Command
Stream Decka System → Website or Multimedia action pointed at the same URL

For the tiling compositors it's a line of config. sway and i3:

bindsym $mod+F8 exec curl -X POST 'http://127.0.0.1:8420/api/clipmark?token=YOUR-TOKEN'

Hyprland, since 0.55, configures in Lua:

hl.bind("SUPER + F8",
        hl.dsp.exec_cmd("curl -X POST 'http://127.0.0.1:8420/api/clipmark?token=YOUR-TOKEN'"),
        { description = "Clipshelf: save a clip" })

On Hyprland 0.54 and older, the same thing in the old hyprlang syntax:

bind = SUPER, F8, exec, curl -X POST 'http://127.0.0.1:8420/api/clipmark?token=YOUR-TOKEN'

There are two endpoints: /api/clipmark saves a clip, and /api/chaptermark just drops a chapter where you are.

In the viewer

These work while a recording is open (press ? there to see them). They move you through the night; nothing here cuts anything — a clip is still i, o and the save button.

KeyDoes
Space / kplay, pause
← / →5 seconds back, forward (30 with Shift)
j / l10 seconds back, forward
Home / Endstart, end
n / pnext, previous moment from the night's best
> / < (or . / ,)faster, slower — 1× to 4×, voice stays at pitch
i / oset the in and out points of a clip
?show this list

Two toggles sit beside the speed row: moments only plays each of the best moments and jumps to the next when one ends; skip dead air hops over stretches where nothing happened and nobody spoke, and says how much it skipped. Both are navigation, and the speed you pick is remembered. A recording also reopens where you stopped watching it.

Where your files live

Your recordings are never moved or modified — Clipshelf only reads them. Everything it creates lives in one place, so uninstalling is deleting the app and, if you want, one folder.

WhatWindowsLinux
Settings%LOCALAPPDATA%\Clipshelf\config.json~/.config/clipshelf/config.json
Library, clips, thumbnails%LOCALAPPDATA%\Clipshelf~/.local/share/clipshelf
Pluginsthe same folder~/.config/clipshelf/plugins
ffmpeg<library>/tools/ffmpeg
Export browser<library>/browsers
Speech models<library>/models

On Linux, XDG_CONFIG_HOME and XDG_DATA_HOME are honoured. The portable Windows zip keeps everything beside itself instead, so a copy on a USB stick stays self-contained.

Games, packs and chapters

Every game gets two things with no setup at all: chapters, from noticing which game is running, and the clip hotkey.

Some games can do better, because they publish what's happening. Counter-Strike 2 and Dota 2 are built in. League of Legends, Rocket League, Deadlock, War Thunder, Marvel Rivals, World of Warcraft, Path of Exile 2 and LiveSplit are packs the app installs in one click from Settings → Games, which also shows how each game's events arrive:

KindMeans
live eventsthe game posts to the app while you play
asked while runningthe app polls something the game serves locally
screen scanread from the recording afterwards, for games with no data
match recordfetched from a match history after the night

Integrations nobody has proven on a real install yet are labelled not field-tested in that list. They're written to the game's published spec and their tests pass, but tests aren't a night of actually playing. If you run one, we'd like to hear how it went.

ffmpeg and speech search

Neither ships inside the download, and both install with one button.

ffmpeg does the cutting and encoding. The builds that include libx264 are GPL, and downloading the upstream build at your request keeps that question from arising. Settings offers it; the dashboard nags if it's missing.

Speech search and captions need faster-whisper — about 250 MB of native packages, plus the model itself on first use. Most people want the app running before they decide whether they want that, so Settings → Recording → speech search installs it on request, into your library folder. Everything except transcription works without it.

Working with an editor

Your recordings stay on your machine. An editor opens your library in their browser, scrubs it, leaves marks, and cuts sections out — the app on your PC does the work and hands them the result. Settings → Editor access is the whole setup, in two steps.

1 · How they reach you

Pick one. The app can only be reached from outside while it is running.

Way inWhat it needsWho it's for
through clipshelf.gg a sharing account with relay access switched on for it anyone who wants nothing to set up. Every minute an editor watches goes through our server, so this is enabled per account rather than open to all — it's the Studio preview. Ask, and we'll turn it on.
through my own relay a small server with a hostname — the self-host recipe is a two-container compose file anyone who'd rather run it themselves. Free, no account, nothing touches clipshelf.gg.
on my network the editor on the same LAN, or a mesh such as Tailscale that puts them there a household, an office, or people who already run a mesh. Turning it on needs a restart — the app has to listen on the network, not just on your own machine.

The relays are outbound-only from your side: nothing is opened on your router, and the tunnel closes the moment you disconnect or quit. The relay's address is a name under the relay host (yours, or <handle>.relay.clipshelf.gg); the app shows it once the tunnel is live.

2 · Invite an editor

Name the seat, press new seat, copy the link. It opens the viewer directly with that seat — no account on their side, nothing to install, nothing to type. The name goes on every mark and cut they make, so two editors are told apart. With no access method live the app gives you the bare token instead of a link, and it works the same way on the login page once one is.

What a seat can do: open every recording, scrub, search, play the moments, leave marks and chapters, cut sections, and download the cuts they made. What it can't: settings, sharing, publishing, updates, backups, the games setup, or anything that touches your machine — those answer with a 403 to a seat, whichever way they got in.

Revoke next to a seat ends it right away, on every device that used the link. Treat the link like a password: it is one, and it's in the editor's browser history. Revoke and mint a new one if it leaks.

The editor is working on your PC. If it's off or asleep, so is their access. An always-on copy of your library on our servers isn't offered today; if you need one, say so.

Running it yourself

The app is local: it reads OBS's folder, cuts with ffmpeg on your machine, and keeps its database next to your recordings. Four things can reach clipshelf.gg, each optional, three of them replaceable with something you run. This is the whole map.

What talks to clipshelf.ggWhenRun it yourself instead
share links only when you press share on a cut your own bucket and domain — free, no account, no caps
the editor relay only while Editor access is connected through clipshelf.gg your own relay — free, no account gate
update checks once a day, if you left check daily on your own update source, or turn the check off
crash reports never, unless you switched them on in Settings → Crash reports — off by default, no first-run nag, scrubbed of paths and names, and queued in a file you can read first leave them off

Nothing else: no telemetry, no account needed to use any of it. (ffmpeg, speech models and the relay client download from their own projects' releases on first use, not from us.) With the two self-hosted pieces set up and the update check off, the app never contacts us. Self-hosting is free forever and always will be; it's also where we can help least, since the bucket, the box and the domain are yours.

Share links from your own bucket

Any S3-compatible bucket with public reads, served from a name you own: Cloudflare R2, AWS S3, Backblaze B2, a MinIO on your NAS. A share then uploads three files — the clip (.mp4), a poster frame (.jpg) and a self-contained page (.html) with the Open Graph tags Discord and Twitter unfurl — and the link is https://clips.yourdomain.com/<id>.html. Nothing passes through clipshelf.gg, not even the click.

The R2 walk-through, since it's the one with free egress — the others are the same four values under different names:

  1. Bucket. Cloudflare dashboard → R2 → create a bucket. Any name.
  2. Public reads. Bucket → Settings → Custom Domains → connect a name on a zone you have in Cloudflare, say clips.yourdomain.com. (The r2.dev development URL also works, but it's rate-limited and not meant to be shared.)
  3. Keys. R2 → Manage R2 API Tokens → create one with Object Read & Write, scoped to that bucket. Note the access key, the secret, and the S3 endpoint it shows — https://<account>.r2.cloudflarestorage.com.
  4. In Clipshelf: Settings → Sharing → advanced — self-host clips on your own domain:
    fieldvalue
    s3 endpointhttps://<account>.r2.cloudflarestorage.com (S3: https://s3.<region>.amazonaws.com; B2: https://s3.<region>.backblazeb2.com; MinIO: its URL)
    bucketthe bucket name
    access key / secret keyfrom step 3
    public base urlhttps://clips.yourdomain.com — the address a browser reads the bucket at, not the S3 endpoint
    key prefixoptional; clips puts everything under one folder if the bucket holds other things
  5. verify & save. The app uploads a probe object, fetches it back over the public URL, and deletes it. It refuses to save until that round trip works — a link that only opens for you isn't a share. The two ways it fails: bucket upload failed is the endpoint or the keys; uploaded fine, but the public URL didn't serve it back is public access or the base URL.

From then on share publishes to your domain. If you're also signed into a clipshelf.gg sharing account, that account wins while it's connected — disconnect it in Sharing to publish to your bucket. Your shares are listed in the app and deleted from there; deleting removes all three files. There's no cap, no expiry, and no page views counted: the bucket is the whole system.

Cost, for scale: R2 charges about $0.015 per GB stored and nothing for egress through a custom domain. A year of clips for most streamers is single-digit dollars.

Your own editor relay

The relay is a tunnel: the app on your PC connects out to a small server, and your editor's browser connects to that server. Ours is <handle>.relay.clipshelf.gg; yours is any box with a public address and a name — a $5 VPS is plenty, it forwards bytes and does nothing else. The software is frp behind Caddy, two containers, and the recipe is in the repo: docs/relay-selfhost.

  1. A name. cuts.example.com A <the box>. Caddy gets the certificate itself, so the name has to resolve before you start it.
  2. Ports. Open 80, 443 and 7000 (tcp) on the box's firewall. 7000 is what your app connects to; the other two are for the editor.
  3. Config. Copy .env.example to .env; set RELAY_HOST to the name and FRPS_TOKEN to something long and random (openssl rand -hex 24).
  4. docker compose up -d. docker compose logs frps should say start frps success.
  5. In Clipshelf: Settings → Editor access → through my own relay:
    fieldvalue
    relay serverthe box's hostname or IP
    port7000
    tokenthe FRPS_TOKEN from .env
    public URLhttps://cuts.example.com
    Save, connect. The row turns live with the URL, and every invite link from new seat now starts with it.

What goes through it is exactly what a seat may see (above): the library, previews, marks, cuts, and the cut files. The tunnel lands on a listener that never accepts your owner code, only editor tokens, so a relay box that's compromised gets what an editor gets and nothing more. One relay serves one install; for several streamers on one box, give each a name (a.cuts.example.com, b.cuts.example.com) and add the names to the Caddyfile — frps routes by host.

Until the app connects, curl -I https://cuts.example.com answers 502 or 503 with a valid certificate; that's the box saying it's ready and waiting for you.

Updates from somewhere else, or never

The app looks at https://clipshelf.gg/releases/latest.json once a day. Settings → Updates → update source takes any other latest.json — an HTTPS URL, a folder on this machine, a network share — and check daily off means it never asks anyone. The manifest format is what scripts/release.sh writes, so a mirror is a copy of our releases folder, and a fork is its own. Game packs install from the same source.

Updates

The Windows app checks for updates and can install them itself.

The AppImage tells you, and you replace the file. It can't update itself: the whole image is read-only, which is also why it can't be tampered with. Settings → Updates says when a new version exists and links it. Your library and settings live outside the image, so replacing it keeps everything.

When something's wrong

Writing a plugin

A plugin is a folder with a plugin.json in it, dropped into the plugins folder. It's loaded at startup — installing one never runs code until the next boot, and a plugin that throws is marked broken and skipped rather than taking the app down with it.

Plugins are local code running at the app's trust level. There is no sandbox. That's a deliberate, documented limit: curation is the safety model, not isolation. Install ones you trust, the same rule as any program you download.

plugins/my-pack/
  plugin.json
  plugin.py          # omitted for data-only plugins (themes)
  assets/            # optional, served at /plugin-assets/my-pack/

plugin.json

{
  "id": "my-pack",
  "name": "My Game Pack",
  "version": "1.0.0",
  "api_version": 1,
  "min_app_version": "0.1.0",
  "capabilities": ["game_adapter"],
  "entry": "plugin.py",
  "games": [{"slug": "mygame", "label": "My Game"}],
  "settings_schema": { "demo_dir": { "type": "path", "default": "" } },
  "author": "you",
  "homepage": "https://…"
}

capabilities is any of game_adapter, media_provider, publisher, theme. id should match the folder name. min_app_version is checked before the entry module is imported.

games names what the pack covers. It's declarative on purpose — the release manifest and the Games roster read it without running your code, which is how a pack that isn't installed yet can still be offered with the right name on it.

Anything in settings_schema becomes a preset domain, plugin-<id>, readable from your module:

from clipshelf import presets
cfg = presets.domain("plugin-my-pack").get("default")

Read them behind a try so your module still imports outside the app — its own tests, mostly.

game_adapter

The common kind: turn a game's events into marks on the recording's timeline. This is timestamping, not editing. The product never cuts automatically, so an adapter's only job is to say what happened and when.

GAME = "mygame"                 # slug; must be unique
LABEL = "My Game"               # shown in the UI
PROCESS_NAMES = ["mygame.exe"]  # optional: adds to game detection

def parse(payload, state):
    """Called for each POST to /api/gsi/mygame?token=…

    Return a list of events. `state` is a dict that persists between
    calls for this game, so you can diff against the previous payload.
    """
    events = []
    if payload.get("round", {}).get("phase") == "over":
        events.append({
            "type": "round_end",   # any string; unknown types get a
            "title": "round over", # neutral marker and a tooltip
            "detail": {"map": payload.get("map", {}).get("name", "")},
        })
    return events

Optional extras, all discovered by name:

NameWhat it does
CONFIG_FILENAME / config_text()a config file the user downloads and drops into the game, so it starts posting
CONFIG_HINTone line telling them where to put it
MARKER_STYLEhow marks are drawn: {event_type: {"color": "#f80", "icon": "kill", "label": "kill"}}
scan(path, t0, t1)a vision scanner: read frames from a recording between two timestamps, return the same event shape. For games with no telemetry.
ASSETS_DIRfolder served at /plugin-assets/<id>/
POLL_URL / poll()for a game that must be asked rather than one that posts — see below
INGEST / INGEST_HINThow events arrive: push, poll, scan or record, plus one human line for the roster. Usually unnecessary — the kind is inferred from which of parse/poll/scan you define. Declare it only when the inference would lie, e.g. a record pack whose parse is a stub.
record_cycle(conn)rebuild the night from a match history after the fact — see below

An adapter that raises is logged and counted, never fatal: ingest keeps working and the other adapters are unaffected.

Games that don't push

Some games never post anywhere; they serve a local endpoint and expect to be asked. League of Legends is the standard example — Riot's Live Client Data API answers on https://127.0.0.1:2999 for as long as a match runs. Declare where to look and the app runs the loop for you:

POLL_URL = "https://127.0.0.1:2999/liveclientdata/allgamedata"
POLL_INTERVAL_S = 1.0     # while it is answering
POLL_IDLE_S = 5.0         # while it is not (nobody is playing)
POLL_INSECURE = True      # its certificate is self-signed
POLL_RESET_AFTER_S = 60   # a longer gap means a different match

Or define poll() yourself and return a payload — or None for "nothing there right now" — if the fetch is stranger than an HTTP GET. POLL_URL must be a loopback address; the network is poll()'s business, not the app's.

Whatever comes back goes through the same parse() and the same writer as a pushed payload, with two differences worth knowing:

Marking something that already happened

events.append({"type": "kill", "title": "kill", "offset_ms": 3200,
               "detail": {}})

offset_ms is how long ago it happened. Without it a marker lands where the poll was rather than where the play was — for a once-a-second poll of a log that's already seconds old, that's the difference between a clip that opens on the kill and one that opens after it. Pushed adapters leave it out; their payload arrives at the moment of the play.

Games with no live data at all

Some games expose nothing while running but keep a match history behind an API. Declare record_cycle(conn, verbose=False) and the app calls it on a slow cadence (RECORD_POLL_S, default 1800s; RECORD_IDLE_S when you return {"skipped": reason} — do that whenever the user hasn't configured an account or key, because a community API is not somewhere to go fishing). Inside a cycle: fetch the history, find the recording whose window contains each match, and write chapters and events idempotently — tag your rows with detail["src"] and delete-and-reinsert only your own.

Be honest about granularity. An API with no per-event timeline gets match-level markers by wall clock, not invented kill times.

No integration at all?

Anything can post to /api/gsi/<anything> with the install token and a {"events": [...]} body. That path needs no plugin — it's the zero-code integration, and it's registered last so a real adapter always wins.

media_provider

ID = "my-sounds"
KIND = "sound"                  # sound | gif | image | music

def search(q):
    return [{"name": "boom", "url": "https://…/boom.mp3", "thumb": None}]

def import_item(item):
    """Download it, return a local path."""
    return "/path/to/boom.mp3"

Providers needing an API key should return an empty list when the key is missing rather than raising — the pane then shows its empty state.

publisher

ID = "my-host"
LABEL = "My Host"

def publish(clip_path, meta):
    return {"url": "https://…/abc", "id": "abc"}

theme

Data only — no entry, no Python. A theme.css that redefines the token block, and a theme.json giving it a label. Renders are deliberately theme-independent: the render engine forces a fixed theme, and a test asserts the same project renders identically under every one. A theme cannot change what an export looks like.

Testing yours

Drop the folder in the plugins directory and restart. Settings → Plugins lists what loaded, what it registered, and the traceback if it failed.