- TypeScript 69.3%
- Svelte 22%
- JavaScript 5.2%
- Nix 1.6%
- CSS 1.6%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Three claims had drifted ahead of the implementation, all of them in the section an operator reads before deciding what a backup is safe to keep. Sessions are described as they are now stored; the password-less sign-in is described as needing both variables in production rather than "refused outright", which was true of the default and false of the flag; and the scripted suite's recipe against a `pnpm start` build says which two. One claim in that section is left standing and still untrue: media responses do not carry `default-src 'none'; sandbox`. `hooks.server.ts` excludes the media paths from its CSP branches and `respondWithFile` sets no policy of its own. That is a change to what the server sends rather than to what this document says, so it is left for a decision rather than quietly reconciled to the bug. |
||
| .agents/skills/clips-repo | ||
| docs/plans | ||
| nix | ||
| scripts | ||
| src | ||
| static | ||
| tests | ||
| .env.example | ||
| .envrc | ||
| .gitignore | ||
| .npmrc | ||
| .oxfmtrc.json | ||
| flake.lock | ||
| flake.nix | ||
| package-lock.json | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| svelte.config.js | ||
| tsconfig.json | ||
| vite.config.js | ||
clips
A small, self-hosted clip host in the spirit of Streamable and Twitch Clips: drag a video onto the page, get a link that plays inline in Discord.
- Drag-and-drop upload anywhere in the app, with resumable chunked transfers. The queue row stays editable once the clip exists, so publicity is changed where the link is handed to you, not a page away
- A clips view you can actually work in: right-click, long-press or the ⋯ on any thumbnail you own for Publish / Unpublish / Make private / Rename / Crop & cut… / Copy link / Delete, tick a few boxes to do it to a whole page at once, and rename in place — Enter saves
- Crop & cut in your tab: drag a crop rect and trim the timeline before uploading and the browser encodes the kept section with WebCodecs — a 100 MB recording becomes a 7 MB clip without leaving the machine, and without ffmpeg.wasm. Where WebCodecs cannot be trusted, the queue says so and uploads as-is. The same editor on an already-uploaded clip derives a new clip server-side
- Automatic, dark, or light theme — a three-way switch in the header, remembered per device and never sent to the server
- Sign in with Discord — OAuth 2.0 with PKCE, no passwords, no signup form, no bot; the rest of the app is built on the account that creates itself on first sign-in
- Straightforward transcode pipeline: one ffmpeg invocation per rendition, driven by a SQLite-backed queue in the same process
- Discord-ready embeds:
og:videopoints at a real MP4, so a pasted link expands into an inline player
Built with SvelteKit, node:sqlite, and ffmpeg. No Redis, no S3, no background-service
supervisor, no native npm modules.
Quick start
nix develop # node 24, pnpm, ffmpeg (with libx264), git
pnpm install
pnpm dev # http://localhost:5173
Sign in via POST /auth/dev with any name (scripts/lib.mjs's devLogin does exactly that),
then drag an .mp4 onto the page. That password-less identity is the local stopgap: it only
answers while no provider is configured, and production needs the door named twice —
AUTH_DEV_LOGIN=1 plus AUTH_DEV_LOGIN_ALLOW_IN_PRODUCTION=1 — because a stale AUTH_DEV_LOGIN
copied in from a dev box should not open a login that requires no credentials. Configure Discord
— see Signing in with Discord — and the header button goes straight to
Discord's OAuth consent screen.
To see the whole pipeline working, including the Discord tags:
pnpm seed # generate three clips with ffmpeg and upload them over HTTP
pnpm smoke # 73 end-to-end assertions against a running server
Both scripts talk to the app over real HTTP rather than importing server code, so they
exercise the same surface a browser or Discordbot hits. BASE_URL points them somewhere
else; pnpm smoke exits non-zero on failure, so it drops into CI. Against pnpm dev the
password-less sign-in they use is open by default; against a pnpm start build it needs both
AUTH_DEV_LOGIN=1 and AUTH_DEV_LOGIN_ALLOW_IN_PRODUCTION=1, because a login that asks for no
credentials should not be openable by one stale line in an environment file.
Themes
The header's Auto · Dark · Light switch writes a clips-theme key in localStorage and
sets data-theme on <html>; src/app.css defines the light scale under
:root[data-theme='light'] and everything else reads tokens. Nothing is uploaded and no
route is involved — the price of that is that the choice is per browser, not per account,
which is what a device preference should be. auto stores the preference rather than the
result, so switching your OS to dark mode repaints the page without a reload.
Because CSS keys off that attribute, src/app.html carries a small inline script that
resolves the theme before the first paint — otherwise every load would flash the wrong
palette while the bundle downloaded. It duplicates three lines of src/lib/theme.svelte.ts,
and tests/theme.test.ts evaluates both copies against the same table so they cannot drift.
A second palette is the easy thing to get wrong, so pnpm contrast parses the token blocks out
of src/app.css itself, converts oklch to relative luminance, and fails on any readable pair
under WCAG AA — body text at 4.5:1, the 11px mono micro-labels at 3:1. Nothing about the light
scale is trusted to the eye.
Production
pnpm build
PUBLIC_SITE_URL=https://clips.example.com PORT=3000 pnpm start
Everything lives under one directory (CLIPS_DATA_DIR, default ./data): the SQLite
database, in-progress uploads, and all media. Back it up with cp -a data … plus a
sqlite3 data/clips.db '.backup …' if you want a consistent snapshot while traffic is
flowing.
Which build is this?
Every page except /embed/* ends with a stamp: the commit, the Jujutsu change id when the
source was a jj working copy, the moment the version was built, and how long the page took.
The same provenance is machine-readable at /healthz (commit, change, builtAt,
dirty) and is journald's first line at boot.
The four stamps are baked in by vite.config.js, and each one may come back unknown rather
than wrong: a build with no git has no commit, a build with no jj has no change id, and a
build handed its commit by Nix has no clock it may repeat later. Precedence is always an
explicit variable → the VCS beside package.json → unknown.
| Build-time variable | Read by |
|---|---|
CLIPS_COMMIT |
the commit; the flake passes its locked rev, or dirty |
CLIPS_CHANGE_ID |
the jj change id, for a deploy driven from a jj checkout (a fileset source has no .jj) |
CLIPS_BUILD_TIME |
the instant the version was made; the flake passes the revision's time |
CLIPS_DIRTY |
1 when the tree did not match the commit — which git can tell and jj never needs to |
The flake passes a time rather than letting the build read one because nix build must give
the same input the same output path: a timestamp from the sandbox clock would hand an
unchanged revision a new store path, and a service restart, on every rebuild. SOURCE_DATE_EPOCH
is deliberately ignored for the same reason — Nix pins it to a constant, so honouring it would
stamp every deployment as built in 1980.
The two durations are measured where each is true, and neither claims the other's half:
server is the request until that page's data was ready (the last point at which a number can
still be printed into HTML that has not been written yet), and page is the browser's, from
the start of the navigation to the stamp drawing itself — wire, parse, paint and hydration
included. Both describe the document that was loaded, not a client-side click afterwards.
Requirements
| Node | ≥ 22.5 for node:sqlite; 24 recommended (the dev shell provides it) |
| ffmpeg | any build with libx264, aac, mpeg4/h264 in -formats |
| Database | none — SQLite ships with Node |
nix develop exports FFMPEG_PATH and FFPROBE_PATH, so nothing needs configuring inside
the shell. Outside it, put both on PATH or set the variables explicitly. /healthz tells
you what the running process can actually see, and the home page warns in the UI when ffmpeg
is missing.
There is one page to browse: the home page holds the uploader and the feed, and a filter
switches that feed between everyone's public clips (/?by=all), your own shelf (/?by=me),
and one person's public clips (/?by=<handle>). With no by the feed opens on whichever of
the first two is yours — your own shelf when you are signed in, the public feed when you are
not — so a link that means a particular shelf has to say which one, and both header buttons
do: Browse has to, once there is a My clips next to it, and My clips has to because the
bare / is where an uploader is already standing, and a link to the address you are standing
on is a button that does nothing. The bare / is left to the address bar, the one place
nobody has said who they are. /u/<handle> is the lighter profile page an uploader's name on
a watch page leads to.
How it works
Upload
POST /api/uploadsopens an upload and returns a chunk size plus two URLs.- The browser
PATCHes fixed-size chunks with anX-Offsetheader. The server keeps the authoritative received byte count, so a retry resumes from its number rather than the client's — a dropped connection costs one chunk, not the whole file. POST …/completeverifies the byte count, hashes the file, moves it intodata/clips/<id>/source.<ext>, creates the clip row, and enqueues the transcode. The tab's own timeline — how long its cut took, what the machine and uplink were like — rides along with this call and is stored for the admin's waterfall; see What the whole upload cost.
Uploads are opened against a lease (expires_at); abandoned ones are swept by a periodic
reaper. The client-side queue lives in src/lib/upload.svelte.ts as module-level $state,
which is what lets a transfer started on the home page keep running while you browse the
feed, and lets a drop on any other page park the files and land back on the home page with
the upload already running.
The studio is a stage, and it strikes when you leave. That same module state is why the
editor cannot simply be "visible when there is a queue": the queue outlives the page, so an
editor keyed on nothing but that re-appears on arrival from /settings wearing whichever clip
it picks first, covering the shelf you came to look at. So the stage has one flag of its own
(studio, same module) and three things move it — a drop brings it back, beforeNavigate in
UploadQueue.svelte puts it away, and so does the ✕ on the stage. beforeNavigate rather than
an effect on the URL, because an effect gets no chance on either of the navigations that matter
here. On the way to another route this component is destroyed, and a destroyed component has no
effect left to record the news. And much of what counts as leaving is one route with a
different by (Browse, My clips), which unmounts nothing at all — including the click the
stage itself provokes: the header's My clips, reached from in front of the editor, by someone
already standing on their own shelf. That last one is why the dismissal is unconditional rather
than keyed on a URL that changed. A stage that stepped aside only for a different address sat
exactly where it was — over the page you had just asked to see — and left reloading the tab as
the only way out. The collapsed stage is one line — how many clips it is standing in for, how
many of them have failed (the stage is where an error used to be visible), and a Show
editor button — while the queue head above it keeps counting what is in flight and keeps
starting and clearing rows.
Hiding is not cancelling, which is the difference between that ✕ and Remove.
Several clips can be merged into one, in the tab. Right-click (long-press, or Shift+F10)
a clip in the studio's picker strip and the menu offers to fold it into the clip on stage; the
⋯ at the end of that strip is the same menu for the clip you are editing, where it can undo the
merge or fold the whole queue into one clip. The result is a single ordinary upload: one row, one
file, one clip, one link. src/lib/cut/merge.ts decides what the join takes and
cut.worker.ts carries it out — packets are copied onto one shifted timeline when every part
is already the same encoded shape (two exports from one editor, the common case, and no generation
loss at all), and otherwise each part is re-encoded once to a single common shape and the
results are copy-joined, which is one encode per part and no more than one pass over the
concatenated timeline would have cost, and the clip that comes out wears nothing to say so.
The seam in the trim ruler is the only mark of a merge. Once folded, a merged clip is one clip: the chip reads like any other chip, the row reads like any other row, and the only indication that there was ever more than one file is a blue hairline on the ruler where each part begins — the one place the question is ever asked, since where the next clip starts is something you only want to know while deciding where to cut. Blue because the bar is already spoken for: white is the playhead, green is the cut, and a marker that shares a colour with a control is a marker that gets grabbed. It carries a dark hairline around it because it crosses both the lit keep and the dark scrim, and over footage somebody else recorded a single-colour line is invisible on one of those two whichever colour it is. The mark is read-only in the strictest sense available: no pointer events, no name in the accessibility tree, since a boundary you can grab would be a second undocumented way to move a cut.
Their positions come from the durations the merge itself recorded in its recipe, never from a guess at
equal lengths — and a seam is drawn only where the merge recorded one, which is why mergeMarks returns
nothing at all rather than a partial set when the recipe and the queue disagree. What survives elsewhere
is only what costs nothing to look at: the file count on the chip's hover text, and the split and
merge-all commands in the chip's own menu.
Everything about a merge that cannot happen is a refusal with a named file in it, asked before any
encoder runs: file 3 is a different shape (1440×1080) than the rest, file 2 has no audio and another does, merged it would run 1200s. The tab does not scale one part to sit beside a
differently-shaped one, does not synthesize silence for a silent part, and does not stitch
different aspect ratios — ffmpeg can do all three and a server-side merge job one day will; the
browser's answer is to leave them as the separate clips they were rather than ship a file that
plays until it suddenly does not.
What the merge is allowed to touch is deliberately narrow, and the two rules worth knowing:
- Only clips whose bytes are still in the tab. A row releases its
Filethe moment the bytes land on the server, so merging is a pre-upload operation — which is also why the fold parks the member row and its file rather than deleting them. A merge is undoable (Split) until its bytes stop being the tab's; after that the option explains itself rather than resurrecting files that are no longer what the clip is. - One merge per clip. Folding an already-merged clip into another would leave its parked parts orphaned under a row that is no longer on the stage, so the undo would have to be a tree. Split it first — the chip's menu says so, and says it with the count.
Merging never invents a status: the clip stays queued, still titled, still not started, and only
job.merge says its bytes are being made — which is why the Upload button dims instead of lying,
and why startJob refuses a row mid-merge so that Upload all cannot ship the file the merge is
about to replace. A merge that declines rolls the fold back and prints its reason on the row in
--warn, not --bad: nothing failed, the files are simply still two files. The join's own spans
travel with the upload like a cut's do, so the waterfall
can answer what merging cost this tab. Provenance goes with it: edit_recipe carries an optional
parts array — one entry per file, in timeline order — which is the only thing that later says the
source was assembled here rather than uploaded as one long file.
Transcode
src/lib/server/ffmpeg.ts holds the whole media pipeline; jobs.ts holds the queue.
- Probe first.
ffprobedecides everything downstream — resolution after the display matrix rotation (phone video stays portrait), framerate, codecs, pixel format, bitrate. - Ladder, never upsample. Rungs are 1080/720/480/360, filtered to those at or below the source, with the source's own height included so a 720p upload really is 720p. Heights come out even, because x264 rejects odd dimensions.
- CRF with a ceiling, not average bitrate:
-crf 23 -maxrate <ceiling> -bufsize— quality-capped-at rather than bitrate-pinned-to. - Stream-copy when it would be a waste. If the upload is already H.264 4:2:0 + AAC at a
sane bitrate, the matching rung is copied (
480p*in the UI means "this is your file, byte for byte"). Re-uploading does not lose a generation. - The master rung: the same trick, for the source the ladder would have burned. A 1080p
screen recording at 12 Mbps is over the 1080p rung's
4500 × 1.35copy threshold, so the ladder re-encodes it at 4500 kbps and a viewer on fibre watches a worse picture than the file on the disk three metres away.planMasterRenditionadds one rung that is the upload's own video stream —-c:v copy, faststarted, in a browser-safe MP4. Audio may be re-encoded (-c:a aacis what turns PCM/AC3 recordings into copyable masters); video may not. Three rules keep it honest, and all three are decided before a byte is written: copy or nothing — never encoded, because a fifth full pass for the bytes of a file only fast viewers ask for is the wrong trade, so an upload that is HEVC, 10-bit, rotated, or above H.264 level 5.1 gets no master and keeps the ladder's ceiling. (-c copycannot lower a level the wayencodeArgsderives one per rung, which is why the ceiling exists: a 5.2 stream is a coin flip on a phone decoder, and "the probe did not say" refuses too.) Disk-gated with numbers the probe already gave (MASTER_MAX_KBPS,MASTER_MAX_MB— a 40 Mbps screen recording is 286 MB a minute, and no viewer's link owes it a copy). And skipped when it would be a duplicate: if the ladder's tallest rung is itself going to be copied, that rung already is the upload's pixels (covered, in the job log). It runs first in the queue — 94 ms of wall time against a 15 s ladder on a 60-second 1080p file — and it isnativeon the wire, which is what lets it ship before there is a rule that uses it: the<source>list,/embed,og:videoand the download menu all speak the ladder, and only the adaptive player may watch the master. No AV1 twin, because an AV1 master would be an encode. +faststartso a browser can seek into a file it has barely downloaded — the single most important flag for progressive MP4.- The frame rate belongs to the upload. No
fps=filter runs unlessTRANSCODE_MAX_FPSsets a ceiling; the ladder used to writefps=30over anything above 30.5, which is what made a 60 fps recording look like a slideshow. Preserving it drags two things along: the H.264 level is derived per rung (-level 4.1cannot describe 1080p60, and x264 only warns before shipping a stream that violates its own declaration — which a phone decoder allocates against), and a rung's bitrate ceiling rises up to 1.5× at 60 fps so the extra frames are not fed from the same pocket. The keyframe interval is now stated in seconds (8) rather than frames, so 30 fps and 60 fps clips scrub alike. - Poster comes from the same probe: it is grabbed near the top rather than at frame zero (which is usually black).
- Hover preview reel. One more best-effort pass after the poster cuts a silent reel —
three 1.5 s snippets, ≤320px, roughly 300 KB — for the card to play under the cursor. It
reads the smallest H.264 rung rather than the source, so it inherits the rotation, the
crop and the trim for free and never decodes more than a few seconds; and each snippet is
its own
-ss … -t … -iinput, because seeking by keyframe instead of decoding to the sample point is the difference between a second of CPU and a full decode per clip. It is deliberately not a rendition — that list is the player's quality menu, the embed pick and "download original" — so it lives in the clip's ownpreviewcolumn and/m/allowlists it there, asking the samecanViewquestion. A reel that fails is a note in the job log, never a failed clip. - Timeouts kill the process group, not just the direct child, so a wedged ffmpeg cannot leak grandchildren.
The queue is a table, not a broker: claimJob uses UPDATE … SET status='running' WHERE id =(SELECT …) RETURNING so two workers cannot take the same row, and startup requeues
anything left running by the previous process. Retries are capped, and each attempt keeps
its own stderr tail (ffmpeg -loglevel error), which is why failures say
x264 version check failed instead of exit code 1.
Playback
Progressive MP4 renditions served with byte-range support, not HLS. Clips are short;
<video src> plus +faststart plus a correct Accept-Ranges beats adaptive streaming at
this size, and it removes an entire class of player code. parseRange in
src/lib/server/http.ts is the load-bearing piece: a wrong parse there means either a full
download before playback or a silently missing Discord embed, neither of which is visible in
the UI — so it is unit-tested instead of trusted.
The watch page measures its own link. chooseRung in src/lib/player.ts is the whole
rule, and it is arithmetic on numbers the server already sent: a rung's real bitrate is
size × 8 ÷ durationMs, and it may be watched if that fits 65 % of the measured link.
The measurement is the throughput of the media bytes the player already needed — same-origin
requests land in Resource Timing with encodedBodySize and duration, which is a real link
measured on the real file at no cost. It is divided by the time bytes were in flight (the
union of the requests' intervals, so parallel range requests do not halve the reading and an idle
buffer does not drag it down), and it ignores anything older than half a minute — a player that
has gone quiet has a full buffer, which is news about the buffer and not about the link. The
browser's downlink is a ceiling only (never a floor: Chromium pins the gauge at 10 Mbps, so
"10" means "at least 10") and Save-Data is a hard 480p cap. The estimate is kept in
sessionStorage so a reload does not re-probe and re-hitch. Three properties the tests nail,
because they are the whole reason the rule is safe to have: no signal is not a downgrade — a
browser that volunteers nothing gets the tallest ladder MP4, which is precisely what it picks on
its own today; never an AV1 rung shorter than the tallest MP4 you could have played, since a
twin is an offer of the same picture for fewer bytes and not a licence to watch a smaller one; and
and a rung has to miss its budget by 1.25× before anything moves, because the swap is sticky
until there is an upgrade path and a misread sample should cost nothing — while staying narrow
enough that the 65 % haircut still means something (a rung may fill 81 % of the link before
anything moves, not 97 %).
Changing what is playing is a video.src swap plus load(), with the position, rate and
paused-ness carried across by hand: mutating the <source> children of an element that has
already selected from them is browser-inconsistent, and +faststart is what makes the hitch
survivable rather than catastrophic. It downgrades on evidence (three stalls in a window, or a
tenth of the frames dropped — or the rung clearing its budget by the 1.25× margin above), never
upgrades on a hint, not while the viewer is scrubbing or in the last five seconds, and not more
than once a quarter-minute. A mono caption under the player says what is playing and why, and opens a
stats for nerds panel — the ladder's real bytes, the measured link, the budget it
produced, the swaps with their reasons — with the owner's rows turning "the quality is bad"
into a sentence with a number in it. It is not a quality menu: there is no control in it.
PLAYER_AUTO=0 renders the old best-first list instead, everywhere, including the SSR'd
markup — the rule is progressive enhancement and its absence is a bug-free state. The
README's standing call against HLS still holds: seamless switching needs fragmented MP4, and
for clips this short a 300 ms hitch is cheaper than a subsystem.
Cards in a shelf also play that hover preview: the reel runs over the thumbnail while the
pointer rests on it — muted, looping, pointer-events: none so the tile stays the one link it
was — and stops when the pointer leaves. Nothing is fetched until the rest happens, because the
src does not exist until then: that is what keeps a 24-tile shelf from becoming 24 downloads,
and why a second hover on the same card is instant rather than waiting again. src/lib/preview.ts
owns the refusals — no hover to preview on (touch), a reader who asked for reduced motion,
Save-Data or a 2g link — and the rule that only one reel runs at a time.
Discord and other link unfurlers
This part has sharp edges, so they are documented where the code is:
og:videomust be a direct file URL. No iframe, no redirect, no relative URL — and it must be fetched without cookies.src/lib/server/opengraph.tsexplains the three properties that make an inline player appear and whytwitter:playeris kept as a separate card rather than being preferred.- Private clips get no playable URL at all, and
/m/,/t/re-check visibility before opening a file descriptor. A leakedog:videoURL is a permanent leak, so the URL alone never authorises anything. - Bots cannot clear a visibility gate, even if a request arrives carrying an owner cookie — a bot cannot prove it is the owner.
- Discord caches previews by exact URL and offers no purge API. Poster URLs carry a version
stamp derived from
updated_at, so a card fetched after a re-transcode shows the current frame. Share links carry nothing beyond that: the watch page's and the clips view's Copy link both hand out the canonical/clip/{id}— the same string as the page's ownrel=canonicalandog:url— so one clip is one card rather than one card per copy-and-paste. A card already sent out keeps its old picture until the previewer re-fetches on its own schedule; that is the accepted price, and it is a small one because a clip's media is never rewritten in place (a cut is a new clip, hence a new id, hence a new URL and a new card). PUBLIC_SITE_URLis the single biggest cause of broken embeds. Left unset, the origin is derived per-request fromHostandX-Forwarded-*; behind a TLS proxy that can come out ashttp://and Discord quietly shows nothing. Pin it.- A clip pasted before its ladder finishes embeds its original.
og:videoused to stay empty untilready, so every clip shared while ffmpeg was still working unfurled as a still picture — and a clip is linkable the second its upload lands.pickEmbedSourcenow falls through to the original: the highest-quality file that exists for the clip, and the same bytes the watch page plays while it waits (/clip/{id}/source?play). Three things have to be true — the container is one a bot's player opens (mp4, m4v, mov, webm), the file is really on disk (a cut on an install that does not keep sources has no original, and neither has one whose chunks are still arriving), and it fitsEMBED_TARGET_BYTES, because a bot that gives up halfway caches the giving-up. Once any rung has finished, the rung wins: that file was cut to be handed to a bot. - A pasted URL is decoration around an id, not an id. Discord's link matcher does not know
that
||is markup, so a spoiler-wrapped link is fetched as/clip/<id>||(and%7C%7Conce a client has percent-encoded it), and a route that demanded its segment be an id answered a spoilered clip with a 404 — which a previewer renders as no embed at all.clipid.tsreads the id out of the segment the way fixupx does; because clip ids are base64url, every character it treats as noise is one no id contains, so it can turn a guaranteed miss into the hit the bars were hiding and can never move a working link onto a different clip. - A conversation's previewer is never handed a status code as an answer. A previewer discards
any response of 400 or worse, so an honest 404 is indistinguishable from a broken site at the
far end — and Discord caches the absence.
previewerFallbackanswers Discord, Slack, Telegram, WhatsApp and friends with a small document and a 200 that says "that clip is not here", and the same sentence for every failure, so a private clip cannot be told apart from a deleted one by comparing responses. Search crawlers are excluded from that set on purpose: they need real 404s, or the site ends up indexed as a hall of soft 404s. The document isno-storebecause a 200 that means 404 is exactly what a shared cache gets wrong, and it carries a body as well as tags because Discord's second user agent is a plain Firefox string —ua.tsexplains why that one lives in the previewer list and not in the privacy list. - The box
og:videodeclares is a presentation hint, not a measurement. Discord sizes its player fromog:video:width/:heightand dislikes both ends of the range: over 1920 on a side it declines to render inline, and a small box it draws thumbnail-sized.embedDimensionshalves the pair above 1920 and doubles it below 400, always both sides together so the ratio stays the clip's. The file's real pixels stay in the JSON-LD, the download menu and the player.
Layout
src/lib/server/
config.ts every knob, parsed once, frozen
db.ts schema + migrations + all queries (node:sqlite)
ffmpeg.ts probe → plan → encode ladder → poster
jobs.ts SQLite-backed queue and the in-process worker pool
uploads.ts chunk accounting, hash, finalize
cliptrace.ts three stores → one upload waterfall (pure build, thin read)
visibility.ts the one place that decides who may see a clip
opengraph.ts the tags Discord/X read, with the reasoning inline
clipid.ts the id inside a path segment (a spoilered link arrives wearing bars)
embedfallback.ts what a chat previewer is shown when a page fails
oauth.ts OAuth2 + PKCE for Discord, the only sign-in
http.ts range requests, conditional requests, JSON error mapping, data-vs-page paths
ua.ts link-unfurler detection, and the narrower "drawing a card in a conversation"
src/lib/
trace.ts the span vocabulary, its bounds, and the clock-skew maths
flame.ts the waterfall's layout: spans in, fractionally-placed bands out
device.ts the cut-relevant browser facts, collected without ever blocking
clips.svelte.ts the "a clip changed" signal the shelves watch
preview.ts whether this browser may fetch a hover reel, and how many at once
upload.svelte.ts the queue, the stage, and the fold that makes several dropped files one clip
visibility.ts is deliberately the only module that answers "may this viewer see this
clip": the feed, the watch page, /m/, /t/, oEmbed, and the Discord handler all ask it, so
the list and the bytes cannot disagree.
The shelves that list clips — the home feed and /u/{handle} — render through
src/lib/components/ClipGrid.svelte, which owns selection, the context menu and every bulk
request; ClipCard.svelte draws one tile and talks to nobody, and ClipContextMenu.svelte
is the menu itself. Managing means owning, so the controls appear on clips the requester
owns and nowhere else, and the watch page's related-clips row gets the same card with none
of them.
Select all means every clip on this page that the requester owns — the button says how
many that is (Select all (4)), and disappears once everything here is picked, because
asking again would only undo it. Paging never carries a selection: a half-selected page is
how a bulk delete ends up half-done.
PATCH and DELETE /api/clips are the bulk endpoints behind it: one request per action,
with ownership written into the statement (… WHERE user_id = ? AND id IN (…)) rather than
checked beforehand. A mixed selection is set, not toggled — Publish on a mix of public,
unlisted and private clips makes all of them public, so the result never depends on what each
clip happened to be beforehand.
The studio and the shelf underneath it are not allowed to disagree. The home feed is
server-rendered, so nothing in Svelte notices that the PATCH /api/clips/{id} the dropzone
just sent has moved a row: the clip you uploaded stays missing from the list below until
something reloads, and the one you unpublished stays on screen. So every write the upload
manager lands on the server bumps a revision in src/lib/clips.svelte.ts, and the home page
re-runs its own load when that number moves — finalize (the clip exists now), a rename, a
visibility change, the transcode reaching ready or failed. A queue-only action (cancel,
retry, take the row off the queue) changes no clip, and so re-reads nothing. The signal is a
counter rather than an event: nobody has to be listening for it to be kept, and three clips
finishing inside one tick is one flush and therefore one load. What that re-read invalidates is
CLIPS_SHELF (src/lib/view.ts), not a URL — Kit re-runs a load only when an invalidation
matches a dependency the load declared (event.depends), and a shelf load that reads nothing
but url.searchParams declares no URL, so a pathname predicate would be handed nothing to
match: no re-run, no error, just a list that quietly keeps being stale. It runs the other way too —
the toolbar's Publish / Rename / Delete act on clips the studio may still be wearing, so a
row adopts the server's answer (never over an unsaved draft or a save in flight), and a row
whose clip was deleted leaves instead of offering a dead link.
Configuration
Copy .env.example to .env. Every value has a working default except provider
credentials, so the app boots with no configuration at all. The ones that matter:
| Variable | Default | Notes |
|---|---|---|
PUBLIC_SITE_URL |
derived per request | Set this in production. Canonical origin for OG tags. |
CLIPS_DATA_DIR |
./data |
DB, uploads, and media |
FFMPEG_PATH / FFPROBE_PATH |
ffmpeg / ffprobe |
absolute paths recommended |
MAX_UPLOAD_BYTES |
2 GiB | |
UPLOAD_CHUNK_BYTES |
8 MiB | also the server's expected chunk size |
TRANSCODE_CONCURRENCY |
2 | parallel ffmpeg runs |
FFMPEG_THREADS |
0 (auto) | -threads per ffmpeg; pin ≈ cores ÷ concurrency at high concurrency |
MAX_OPEN_UPLOADS_PER_USER |
8 | in-flight uploads per account (upload-phase guard) |
TRANSCODE_ENCODER |
libx264 |
nvenc / vaapi encode on the GPU; degrades to libx264 if unsupported |
VAAPI_DEVICE |
unset | render node for TRANSCODE_ENCODER=vaapi (avoid Jellyfin's) |
FFMPEG_TIMEOUT_MINUTES |
30 | wall-clock ceiling per invocation |
TRANSCODE_MAX_FPS |
0 (keep source) | frame-rate ceiling for the ladder; 0 preserves a 60 fps upload at 60 |
KEEP_SOURCE |
1 | keep the original for the download button |
ALLOW_BROWSER_CUT |
1 | offer the in-tab crop & trim editor (WebCodecs); server derives always work |
WEBM_AV1 |
1 | encode AV1/Opus WebM twins of every rung; players use them only with hardware AV1 |
MASTER_RUNG |
1 | also cut the upload's own pixels as a -c:v copy rung, for viewers fast enough |
MASTER_MAX_KBPS / MASTER_MAX_MB |
12 000 / 512 | the two gates on that rung, both read off the probe before any work |
PLAYER_AUTO |
1 | the watch page measures its link and picks the rung; 0 is the old best-first list |
LOG_LEVEL |
info |
verbosity, optionally per category: info,clips.ffmpeg=debug |
LOG_DIR |
<dataDir>/logs |
where the rotated clips.log lives |
LOG_MAX_BYTES × LOG_MAX_FILES |
8 MiB × 5 | the log's own disk ceiling |
CLIENT_CUT_MAX_INPUT_MS |
900000 (15 min) | longest input a browser tab may cut down from; longer uploads go to the server |
COOKIE_SECURE |
1 in production | set 0 only for plain-http testing |
DISCORD_CLIENT_ID / _SECRET |
unset | the only way in; the header's Discord button works once both are set |
HEALTH_CHECK_DISCORD |
0 | have /healthz ask Discord whether those credentials work |
ADMIN_DISCORD_USER_ID |
unset | Discord snowflake of the admin; switches on upload gating + /admin |
Signing in with Discord
One application, no bot, and five minutes:
- https://discord.com/developers/applications → New Application.
- OAuth2 → copy the Client ID, then Reset Secret and copy that — the secret is shown once and cannot be read back later.
- Add a redirect of exactly
https://your-host/auth/discord/callback. It is compared character for character against what the browser sends, sohttpvshttps, awwwprefix, a port, or a trailing slash all fail — with Discord refusing the token exchange, which looks like a dead button rather than a misconfiguration. SetPUBLIC_SITE_URLand derive this page's origin from it, and the two cannot drift. DISCORD_CLIENT_IDandDISCORD_CLIENT_SECRETin.env, then restart.
Nothing else needs ticking in the portal: the app asks for the identify and email scopes
itself, and no bot token is involved — sign-in and the bot that unfurls your clip links are
separate things. An account is keyed on the Discord id, which is the one field nobody can
rename, so editing a display name never orphans anyone's clips — and the handle in
/u/<handle> is that same id rather than a name somebody can change under you, so a profile
URL stays valid once it is shared. The @ line on that page is decoration rather than identity:
displayHandle (in src/lib/format.js) puts the display name into handle shape, because
@88498981231234567 is the correct thing to route on and nobody recognizes themselves in it.
Every link still points at the id, and the id itself stays where it is wanted — Settings, and
/admin, which is where an admin reads it back when unlocking an account.
To check it without signing in, pnpm status prints what is configured and the exact redirect
URI to register; with HEALTH_CHECK_DISCORD=1 it also reports Discord's own verdict on the
credentials, by trading a code that was never issued — a rejection about the code means the
credentials passed, invalid_oauth2_client_id_or_secret means they did not.
Cookies use the __Host- prefix when served over HTTPS, which pins the session to the origin,
to the root path, and to Secure.
Admin panel and upload gating
Set ADMIN_DISCORD_USER_ID to a Discord user id (in Discord: Settings → Advanced →
Developer mode on, then right-click a user → Copy User ID) and the site gains a
one-account admin:
- That account gets an Admin link in the header →
/admin, listing every account with its clip counts and a per-account toggle for upload access. Only Discord accounts are listed — dev-login and other scripted identities stay out of the roster. - Everyone else can no longer open an upload until unlocked there. The server enforces
this at every upload endpoint (
POST /api/uploads, chunkPATCH, andcomplete), so a lock set mid-transfer stops the next chunk; cancelling and browsing stay open. - Accounts that already existed when the migration ran are grandfathered in — enabling gating does not lock out the people who were here first. New sign-ins start locked.
- The same account is the only viewer of the upload waterfall on the clip page — what a
clip's upload cost in the uploader's browser, on their hardware, over their uplink
(details). It is admin-only by construction:
the clip page's load reads it when
locals.user.is_adminand nobody else, so it is absent from every other payload, including the owner's. - Below the roster sit Storage and Pipeline. Storage is the data directory read back
off the filesystem rather than out of the rows — media bytes split by what each file is
(
source.*, renditions,poster.*, the preview reel, and anotherbucket for anything the layout does not name), the database with its WAL siblings, the temp files unfinished uploads are still holding, free space on the filesystem under it, and the largest clip folders. Plus the one check that needs both sides of the ledger at once: rows with no folder (a watch page whose own video 404s) and folders with no row (bytes no page links to, which deleting the clip will not free).src/lib/server/storage-stats.tsowns the walk, andpnpm status diskwalks the same tree for the shell. - The whole
/admintree is refused inhooks.server.ts— before any route module is loaded — unless the request comes from the admin (adminGateinlib/server/auth.ts). A page added under/adminlater therefore cannot be shipped open by forgetting one import; the per-routerequireAdmincalls remain as the second lock, since they are what covers each form action. Every refusal is a line in the log:GET /admin → 403 (admin gate), with the account id behind the cookie. - Unset (the default) means no gating at all: any signed-in user can upload and
/adminis 403 for everyone. The admin identity is matched on the Discord provider id reported by Discord at sign-in — never an editable column — so nobody can grant themselves the role.
Deploying with NixOS
The flake exports packages.clips (the built app) and nixosModules.clips (the service).
Every environment knob in src/lib/server/config.ts is a module option; anything the
options do not cover can go through extraEnvironment.
# /etc/nixos/configuration.nix (or your host's module)
{
imports = [ inputs.clips.nixosModules.clips ];
services.clips = {
enable = true;
siteUrl = "https://clips.example.com"; # set this; Discord previews depend on it
maxUploadBytes = 8 * 1024 * 1024 * 1024;
environmentFile = "/etc/clips.env"; # secrets, mode 0600:
# DISCORD_CLIENT_ID=…
# DISCORD_CLIENT_SECRET=…
# ADMIN_DISCORD_USER_ID=…
extraEnvironment.ADMIN_DISCORD_USER_ID = "…"; # or live here; it is not a secret
};
# TLS termination is yours: the service binds http on 127.0.0.1:3000.
services.nginx.virtualHosts."clips.example.com" = {
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:3000";
extraConfig = ''
client_max_body_size 8g;
proxy_request_buffering off; # big resumable chunks stream straight to disk
'';
};
};
}
What the module wires up: systemd.services.clips as a DynamicUser with
StateDirectory = clips (the database, in-flight uploads and media all live in
/var/lib/clips — back that one directory up), the ffmpeg paths from
services.clips.ffmpegPackage (defaults to pkgs.ffmpeg, deliberately not
ffmpeg-headless, whose build drops libx264), GPU encoding via
services.clips.encoder = "nvenc" | "vaapi" (vaapiDevice picks the render
node and grants the render group automatically; /healthz reports which
encoder the worker will actually run), hardened unit settings, an optional
openFirewall, and an assertion that refuses to configure the password-less
AUTH_DEV_LOGIN door on a server. Secrets belong in environmentFile, never in
extraEnvironment — the latter lands in the world-readable unit file.
To try the package by itself: nix build .#packages.x86_64-linux.clips && result/bin/clips.
If npmDepsHash in nix/package.nix ever mismatches after a dependency change, the build
error prints the new hash to copy in.
Tests
pnpm check # svelte-check: 0 errors, 0 warnings
pnpm test # vitest: 446 unit + real-ffmpeg + client-side tests
pnpm smoke # end-to-end against a running server
pnpm contrast # both colour themes clear WCAG AA on the pairs people read
The unit tests cover the parts that fail silently: range parsing, the ladder plan, the
stream-copy decision, visibility rules, redirect sanitising, upload validation, and the OpenGraph
output. tests/transcode.test.ts drives the real ffmpeg binary and asserts the output is a
playable file with a poster — it skips itself when ffmpeg is absent so a
machine without it still goes green. tests/upload-client.test.ts runs the browser upload
manager against a stub server that keeps its own byte count, so chunk placement and the
resume-after-failure path are asserted rather than assumed — including the spans the tab
reports on complete, which otherwise stop arriving in silence.
tests/trace.test.ts and tests/cliptrace.test.ts pin the span vocabulary's bounds, the
chunk-arrival derivations and the waterfall assembly; the trace a browser sends is untrusted
input, so the bounds are tested the way every other untrusted input's are.
tests/flame.test.ts and tests/waterfall.test.ts do the same for the chart: that a row's
widths sum to the lane it claims to divide, that a folded block keeps every span it swallowed,
that the stall survives the fold, and that what reaches the browser is one focusable band per
fact — with the same facts still printed in the table underneath.
The unit tests cover the parts that fail silently: range parsing, the ladder plan, the
stream-copy decision, visibility rules, redirect sanitising, upload validation, and the OpenGraph
output. tests/transcode.test.ts drives the real ffmpeg binary and asserts the output is a
playable file with a poster — it skips itself when ffmpeg is absent so a
machine without it still goes green. tests/upload-client.test.ts runs the browser upload
manager against a stub server that keeps its own byte count, so chunk placement and the
resume-after-failure path are asserted rather than assumed.
Debugging
Running it
nix develop # node 24, pnpm, ffmpeg, sqlite3, jq, curl
pnpm install
pnpm dev # vite dev, http://localhost:5173, HMR
pnpm build && pnpm start # production build on :3000 — needs PUBLIC_SITE_URL
pnpm check # types (svelte-check)
pnpm test # vitest once
pnpm test:watch # vitest in watch mode
pnpm test tests/ffmpeg.test.ts # one file
pnpm test -t "ladder" # one test by name
pnpm seed # three working clips through the real API
pnpm smoke # end-to-end assertions; non-zero exit on failure
pnpm contrast # oklch contrast audit of src/app.css, both themes
Logging is LogTape — zero dependencies, MIT, TypeScript-native — over
two sinks: stdout, which is what systemd captures as the unit's journal, and a rotated
JSON-Lines file under the data directory. The file is there because journalctl -u clips
needs root, journals are often volatile, and pnpm start outside systemd has no journal at
all. Records are tagged by category (clips.jobs, clips.ffmpeg, clips.http, …) instead
of by a hand-typed [jobs] prefix, and everything logged while a job runs carries that
job's clip id:
pnpm status log # the newest 40 records, rendered
pnpm status log --level=warning # only what went wrong
pnpm status log --clip=PeqABMbsuqE # one clip's story, start to finish
pnpm status log --category=ffmpeg --json # what ffmpeg said, for jq
pnpm status log --files # what is on disk and how full it is
Verbosity is LOG_LEVEL, either bare (debug) or per category — LOG_LEVEL=info,clips.ffmpeg=debug
is how you follow one subsystem without drowning in the rest, with no redeploy. LOG_DIR,
LOG_MAX_BYTES and LOG_MAX_FILES move the file and set its disk ceiling; LOG_CONSOLE=0
and LOG_FILE=0 turn a sink off. If the log directory cannot be opened the app says so once
and logs to stdout regardless: an app that will not boot over its log file is worse than one
with no log file.
Run experiments against a scratch data dir so you can throw it away:
CLIPS_DATA_DIR=/tmp/clips-scratch pnpm dev
When the schema and the build disagree
Migrations are keyed by name in schema_migrations, not by position in a counter.
PRAGMA user_version is still written at boot, but as a mirror for whoever is reading
the file with sqlite3 — nothing decides anything with it. Each migration also carries
a probe, a catalog query that is true only when that migration's effect is really in
the file, and every probe runs at boot.
That buys three diagnosable messages instead of one mysterious one:
| Message | What happened |
|---|---|
migration "x" was stamped applied but its schema is absent (WARN) |
An old counter claimed a migration this file never got — the shape a branch that added its own migration and was then dropped leaves behind. It is applied now, once. |
migration "x" is recorded as applied but its schema is absent |
Someone edited the schema by hand, or restored a stale file over a newer database. Boot stops; the named migration's DDL is what is missing. |
migration "x" has changed since it ran here |
The SQL of an already-applied migration was edited. Add a new migration instead. |
The first is self-healing and the other two are deliberate dead ends: a database and a build that disagree about the schema should say so at boot rather than 500 ninety seconds into someone's upload. To see where a database actually stands:
sqlite3 data/clips.db "select name, origin from schema_migrations order by rowid"
origin is applied (this build ran it), reapplied (it had to be run again after a
bad stamp), or legacy (a pre-registry database adopted on the strength of its probe).
When a clip will not render
pnpm status reads the database file directly, so it works with the server down:
pnpm status # config, healthz, queue, failures, disk
pnpm status jobs # every job with attempts and the ffmpeg stderr tail
pnpm status runs # what recent transcodes cost: per-rung wall time, encoder, speed
pnpm status trace <id> # one clip's whole timeline: the tab, the transfer, the ladder
pnpm status clip <id> --probe # row + renditions + files on disk + ffprobe each
pnpm status watch <id> # tail the SSE progress stream, as the browser sees it
pnpm status sql "select id,status,error from clips where status='failed'"
pnpm status retry <id> --yes # requeue a clip and watch ffmpeg run again
The usual answers show up quickly: status jobs prints the captured stderr tail (a clip that
failed on x264 says so here), status clip --probe catches a rendition whose declared size
disagrees with the bytes on disk — the signature of an encode killed by a timeout — and
status flags jobs that have been running for more than ten minutes.
For anything the script does not cover, the sqlite3 CLI is in the dev shell:
sqlite3 data/clips.db '.tables'
sqlite3 -header -column data/clips.db "select id, status, stage, error from clips order by created_at desc limit 5"
sqlite3 data/clips.db "select * from jobs order by id desc limit 3"
What the transcode cost
pnpm status jobs says a job ran. pnpm status runs says what it cost:
pnpm status runs
# 12 PEqABMbsuqE transcode ok libx264+libsvt-av1 wall=41.20s queued=1200ms out=21MB
1920x1080@59.94 62.0s 11500kbps 85MB
360p/h264 encode libx264 1.80s 34.4x | 1080p/h264 encode libx264 19.00s 3.26x | …
stages: probing 0.04s → encoding 360p 1.80s → encoding 1080p 19.00s → poster 0.30s
notes: [libx264] MB rate (489600) > level limit (245760)
Every claimed job writes one job_runs row — queue wait, wall time, which encoder
actually ran (after the fallback, not what was configured), what the probe saw,
what the cut asked for (trim-only or cropped, and whether a stream copy was
allowed), then one entry per rung with its bytes, wall time and speedX — media
seconds per wall-clock second, which is the number that separates "the queue was
busy" from "this encode is slow". notes carries what ffmpeg complained about on
a rung that exited 0, which is why the encoder runs at -loglevel warning: the
failures that hide inside a successful exit all speak in warnings.
The same record is also logged, as a clips.job entry in the log file, so one line of
pnpm status log carries it and journalctl -u clips still shows it for whoever has root:
pnpm status log --category=job --json | jq '.[] | {clip, wallMs, rungs}'
and on /admin, under Pipeline, for the last 20 runs.
The table has no foreign key on purpose: a log that vanishes when its clip is deleted
says nothing about the clip that broke last week. Housekeeping keeps the newest 500
rows (JOB_RUNS_KEPT in src/lib/server/joblog.ts), by row count rather than age, so
a quiet host keeps its only evidence instead of emptying the table on an idle tick.
What the whole upload cost — the waterfall
job_runs answers "what did the ladder cost". It cannot answer the other three questions
anyone asks about a slow clip — what the browser's cut cost, what the uploader's machine and
uplink were like, and where the bytes actually stalled — because those facts exist for about
ninety seconds in the uploader's tab and then vanish. So the tab reports them on
POST …/complete, beside the recipe, and they are drawn as a waterfall.
Three stores, assembled by src/lib/server/cliptrace.ts into one timeline:
| Where | What it holds |
|---|---|
clip_traces |
the tab's spans + its cut-relevant specs + the clock gap, and the chunk arrivals and finalize the server measured |
job_runs |
queue wait, the stage timeline, one entry per rung (unchanged) |
uploads |
open / first byte / completed stamps — scratch, copied into the trace before the reaper sweeps it |
The tab's report is {version, spans, specs, clock} with a fixed phase vocabulary
(src/lib/trace.ts): tab-boot (spawn the worker, import mediabunny), tab-probe (demux and
read the tracks), tab-gate (the decode check, the copy assessment, the encoder probes),
tab-cut (the encode, or the packet-copy remux), then open, the transfer's own window, and
one span per retry backoff. The server adds what it could see: the arrival time of every chunk
(a curve, not an average — a chunk 5× the median is drawn as a stall, which is the whole
reason to sample), the open→first-byte gap, and the finalize itself.
Alongside the spans, the tab sends the few machine facts a cut actually depends on —
hardwareConcurrency, deviceMemory, the GPU WebGPU picked (or the WebGL debug renderer
behind it), UA-CH platform/arch, navigator.connection, and the gate's own codec verdicts
(av1, vp9, aac, opus, copy, canDecode) — which is what turns "the server cut it"
into "no AV1 encoder at that size". Firefox and Safari decline most of it; those fields stay
null and print as missing rather than as a guess.
Two network numbers appear, and they are not the same kind of thing: measured up is
Δbytes ÷ Δtime between this host's arrival stamps — the upload path, in the upload's direction,
quoted as a median with the peak beside it (a mean is what has always hidden the one stall in
three hundred chunks) — and link (browser est.) is navigator.connection, a lazy guess at
the download that Chromium stops counting at 10 Mbps and clumps into 4g above a certain
speed. So a gigabit line prints ≥10 Mbps (browser cap), and the strip is worded so that reads
as a pinned gauge instead of a slow visitor. An upload under one chunk has no arrival curve at
all; its rate comes from the tab's own first-byte→last-ack span and says single chunk, tab clock beside it. Both are decimal bits, so they are comparable with a speedtest — which is the
other way round, to someone else's server, and the tooltip says that too. The maths is one
source of truth (summarizeTransfer / formatRate), and pnpm status trace imports it rather
than restating it.
Two lanes, never one axis. The tab's clock and the host's clock are not reconciled by anything
in the system, so each lane is drawn from its own durations and the only claim made across them
is the skew — estimated from the complete request's round trip and printed with its
uncertainty (clock ±4.2s), rather than folded in silently. See the reasoning at the top of
src/lib/trace.ts.
The section under the player is a flame graph of that timeline (src/lib/flame.ts for the
layout, UploadWaterfall.svelte for the drawing): three rows per lane — the lane, its
sections, the spans — as SVG bands placed and sized in percentages of the lane's measured
milliseconds, which is the only total anybody actually measured. Widths are the picture; the
numbers stay in the table beneath, which is also what a keyboard and a screen reader read.
Two things make the picture survive contact with a real upload:
- Folding. 800 chunk spans are 800 sub-pixel hairlines and 800 DOM nodes, so a run of
same-phase neighbours is gathered into one block —
chunk ×800— as soon as it is wide enough to see and to click, and no further. A different phase breaks the run, so a stall is never folded into the chunks around it, and the row ceiling merges the thinnest neighbours least-first with stalls last. Every folded span stays in the frame, so clicking the block is how you get the 800 back. - Selection. Hover a band and its path to the lane lights up while the caption line prints what it is; click (or Enter on a focused band) and the table narrows to the spans that band stands for, with the band's own total as the last row. Arrow keys walk the chart — sideways along a row, up to what it stands in, down into what it holds — and Escape lets go.
- A thumb, not a cursor. The fold's floor is one percent of the lane, and a phone's lane is
about 300 CSS pixels wide, so the thinnest band the chart is allowed to leave is about three of
them — and three pixels is not a button. So on a coarse pointer the row takes the press and
frameAtdecides which band the finger meant: a point inside a band is that band (wide frames keep exactly the ground they always had), a shared edge goes to the band that starts there because a hairline cannot afford to lose a side, and a point in no band at all — which rounding can leave — falls to the nearest. It is a click rather than a pointerdown so that a finger which travels is a scroll past the chart and not a selection. The same three pixels also make a band too thin to read its colour or its label in, so the chart takes a minimum width and scrolls sideways inside its own lane, the rows grow from 19px to 30, and the redundant bar column comes out of the table under it. The honesty tooltips on the specs strip — the ones that stop a pinned≥10 Mbpsgauge reading as a slow visitor — get printed rather than hidden, since a touchscreen renders no tooltip at all;tests/flame.test.tspins the hit test against the layout's own floor, so the two cannot drift apart.
The band labels are checked rather than eyeballed: pnpm contrast measures label ink against
every band colour the chart can draw, in both palettes.
Where it shows: on the clip page, for the admin account only —
ADMIN_DISCORD_USER_ID sees a "Where the time went" section under the player; nobody else,
including the clip's owner, gets a byte of it. And from the shell, which is the half that works
when the web is down:
pnpm status trace C6vPZQ3YkM8e
timeline for C6vPZQ3YkM8e
in the tab 4.38s
█ 0.18s spawn worker + load mediabunny
███████ 4.20s av1 encode 3MB
on the server 44.24s
█ 0.90s chunk 1/2 1MB
██████████████████████████████████ 21.00s chunk 2/2 1MB ← the stall
██ 1.20s waiting for a worker
█ 0.04s probing
███ 1.80s 360p libx264 encode 34.4x
███████████████████████████████ 19.00s 720p libx264 encode 3.3x
█ 0.30s poster
tab clock ±5200ms (offset 420ms) — never merged across lanes
up (measured): 96 Mbps median · peak 118 Mbps · 256 chunks (1 stalled) (2147483648 B in 178000ms of arrivals)
machine: cpuCores=8 memoryGiB=8 gpu=NVIDIA RTX 3060
link (browser est.): ≥10 Mbps (browser cap) · 28ms rtt — navigator.connection's download guess, not this upload
gate: engine=webcodecs av1=true
Like the job log, clip_traces has no foreign key: the trace of the clip that broke last week
is the reason to keep traces at all, and housekeeping bounds it by row count (TRACES_KEPT).
Turn it off with CLIP_TRACE=0 (clipTrace = false on NixOS) — the report is then ignored and
nothing is stored; uploads are byte-identical either way, because a trace that would fail an
upload is telemetry nobody would ship.
Probing the HTTP surface directly
curl -s localhost:5173/healthz | jq . # ffmpeg path, db, worker, sign-in
curl -s localhost:5173/healthz | jq '{commit,change,builtAt,dirty}' # which build this is
# What Discord sees, without Discord:
CLIP=<id>
curl -s -A 'Discordbot/2.0' "localhost:5173/clip/$CLIP" | grep -oE 'property="og:[^"]*" content="[^"]*"'
curl -sI "localhost:5173/m/$CLIP/720p.mp4" # want: 200, video/mp4, accept-ranges: bytes
curl -sI -H 'range: bytes=0-1023' "localhost:5173/m/$CLIP/720p.mp4" # want: 206 + content-range
# Whether the access rules are being obeyed:
curl -so /dev/null -w '%{http_code}\n' "localhost:5173/clip/$CLIP" # anon
If a pasted link does not preview, PUBLIC_SITE_URL is the cause more often than anything
else: the tags must be absolute, and they are built from it.
Re-running an encode by hand
Grab the retained source (KEEP_SOURCE is on by default) and run the same binary the server
uses:
SRC=$(find "$CLIPS_DATA_DIR/clips" -name 'source.mp4' | head -1)
"$FFPROBE_PATH" -v error -print_format json -show_format -show_streams "$SRC" | jq '.streams[] | {codec_type, codec_name, width, height}'
"$FFMPEG_PATH" -i "$SRC" -c:v libx264 -preset veryfast -crf 23 -vf scale=-2:480 -c:a copy -movflags +faststart /tmp/manual.mp4
src/lib/server/ffmpeg.ts is the authority on the real argument sets; the plan is pure and
unit-tested, so if the ladder itself looks wrong, add a case to tests/ffmpeg.test.ts rather
than eyeballing.
Useful environment knobs
| Variable | Why you would set it |
|---|---|
TRANSCODE_CONCURRENCY=1 |
interleaved ffmpeg output is unreadable; one worker is not |
FFMPEG_TIMEOUT_MINUTES=2 |
fail long encodes fast while iterating |
AUTH_DEV_LOGIN=1 |
the password-less sign-in, outside dev (in production it also needs AUTH_DEV_LOGIN_ALLOW_IN_PRODUCTION=1) |
COOKIE_SECURE=0 |
http testing; over http the browser otherwise drops the session |
PUBLIC_SITE_URL |
absolute OG URLs; also decides the cookie Secure default |
CLIPS_DATA_DIR |
scratch data dir per experiment |
FFMPEG_PATH / FFPROBE_PATH |
point at a specific build (nix develop sets these) |
Browser side: the queue talks to /api/clips/<id>/events over SSE, so DevTools → Network → the
events request → EventStream shows the same frames pnpm status watch prints.
Security notes
- Sessions are 32-byte random tokens; the row keeps only their SHA-256 (
sessionTokenHash), so a readable database, or a backup, yields nothing that can be pasted into a cookie. The cookie isHttpOnly,SameSite=Lax, and__Host-prefixed under HTTPS. Thesessions-hashed-at-restmigration deletes the rows the old scheme wrote — which means upgrading signs everyone out once, the honest price for the tokens having been readable until now. - CSRF: Kit's built-in origin check is replaced by one in
hooks.server.tsthat compares theOriginheader to theHostthe client actually addressed — Kit's version compares against the origin adapter-node guesses, which defaults tohttpsand would 403 every form post on a plain-http deploy. The reasoning is spelled out insvelte.config.js. - CSP is set by hand (
hooks.server.ts) rather than viakit.csp, because/embed/*must be frameable while the rest of the app must not be. Media responses getdefault-src 'none'; sandbox. - OAuth uses PKCE with a single-use, server-side stored state. The avatar column is written
only during sign-in, with a URL the provider itself constructed, and settings accepts no
picture URL at all — so nothing user-typed can reach an
<img src>and no validator is needed to make that true. Accounts with no picture render the provider's own placeholder. - Filenames are never interpolated into a path; clip ids are validated and paths are checked to stay inside the clip directory.
- The upload timeline describes a person's machine (cores, memory, GPU, UA, link quality) and
is therefore server-rendered for the one admin account, in one place: the clip page's load
asks for it only when
locals.user.is_admin, and it appears in no other payload — not a visitor's, not an unfurler's, not the owner's. The report itself is untrusted input validated against a fixed vocabulary with hard bounds (cleanTabTrace), and dropped whole rather than trusted in part; a browser may send nothing at all, and the upload is identical either way.
Tradeoffs worth knowing
- In-process workers. The transcode pool shares the web process, which is the simple
thing to operate, but it means a restart can interrupt an encode. Interrupted jobs are
requeued at startup rather than resumed mid-file, so plan for encodes to restart on deploy.
The queue itself is already multi-worker-safe (
claimJobis an atomic claim against SQLite), so if you do need zero-interruption upgrades, the missing piece is only a second process that callsstartTranscodeWorker()against the sameCLIPS_DATA_DIR— not a change to the job model. - Single writer, SQLite. WAL mode with
busy_timeouthandles a small team comfortably; it is the wrong tool the moment you want several web processes on different machines. - Progressive MP4 means no ABR. The "Auto" quality option approximates it by capping against viewport width, which is fine for clips and wrong for long-form video.
- 15-minute / 2 GiB ceilings are enforced at probe time. This is a clip host, not a media library.