Clip 90 seconds of any video, a passage of any article, or a slice of any podcast — then put your own commentary on it, on a public page that always links back to the source.
Built to the annotated.com bounty spec: a Chrome sidebar extension as the primary surface, a "File a claim" button on every annotation, and a link back to the source on every clip.
Try it in your browser → · Project page
That demo is static — GitHub Pages cannot run Express, SQLite, ffmpeg or yt-dlp, so it keeps the 90s ceiling, source deep-links, the claim button, the feed, follows and comments, and plays clips range-limited from the original instead of re-encoding them to 240p. The full pipeline needs the server below.
extension/ Chrome MV3 side panel — the primary surface
server/ Express + SQLite: auth, clip pipeline, feed, claims
public/ the web app's CSS/JS and generated media
test/ contract tests for the spec's hard requirements
npm install && npm run doctor && npm run seed && npm startThen open http://localhost:4000. doctor tells you if anything the clip pipeline needs is missing;
seed fills the feed by actually clipping two real sources.
The pipeline shells out to ffmpeg, ffprobe, and yt-dlp:
brew install ffmpeg yt-dlpChrome 137 removed the --load-extension command-line switch, so the sidebar has to be loaded
through the UI — there is no scripted install any more.
- Open
chrome://extensionsand turn on Developer mode. - Load unpacked → pick the
extension/folder. - Pin annotated and click it on any page to open the side panel.
- Hit Connect account — it opens the site to sign in, then pairs itself.
The panel reads whatever tab you are on: it detects YouTube / article / podcast, grabs the video's playhead for the in and out points, or pulls the text you highlighted. Commentary can be typed or recorded. Publishing hands the source URL and range to the server, which does the actual clipping.
| Requirement | Where it lives |
|---|---|
| Sidebar Chrome extension is the primary surface | extension/manifest.json, sidepanel.js |
| "File a claim" on every annotation | views/annotation.js, routes/pages.js |
| Every clip links back to its source | views/partials.js sourceLink() |
| Max clip 90s | config.js MAX_CLIP_SECONDS, clamped in youtube.js and podcast.js |
| Video downgraded to 240p (< 480p) | media/youtube.js ffmpeg scale filter |
| Accounts via X or Google only | auth.js |
| Public feed with follow + comment | routes/pages.js, store.js |
| Commentary as text and recorded audio | sidepanel.js, routes/api.js /api/publish |
The limits are enforced on the server, not in the UI — no client can talk it past 90 seconds or above 240p.
YouTube — yt-dlp --download-sections pulls only the requested range, then ffmpeg re-encodes it
to 240p H.264. The stored source URL carries a ?t= so "Source" lands the viewer on the exact
moment.
Articles — Readability parses the page. If the side panel sent the user's highlight, that is kept verbatim; otherwise annotated takes a lead-sized excerpt (~900 chars) with the byline and timestamp stubs stripped.
Podcasts — yt-dlp resolves the episode's audio stream (falling back to og:audio scraping), then
ffmpeg cuts a ≤90s mono AAC segment.
X and Google OAuth2 are both implemented (X with PKCE). Fill in .env from .env.example:
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET redirect: ${PUBLIC_URL}/auth/google/callback
X_CLIENT_ID / X_CLIENT_SECRET callback: ${PUBLIC_URL}/auth/x/callback
With neither configured the server falls back to a local dev sign-in so you can run it immediately. Configuring either provider disables that fallback automatically.
The side panel can't read the site's cookie (different origin), so it pairs instead: it opens the sign-in page carrying a one-time state, and polls until the site hands back a bearer token.
YouTube answers some requests with "Sign in to confirm you're not a bot". That one message covers two different problems, and they have different fixes.
Bot-check throttling — the host looks automated. Fixed by generating PO tokens locally, no account involved:
npm run setup:youtubeThis installs the bgutil plugin into
yt-dlp's own environment and builds its token generator into vendor/ (gitignored). The server
detects it automatically.
Video-level gating — YouTube refuses a specific video to anonymous callers, whatever the client or token. The only fix is a real session:
npm run youtube:login # reads Chrome; pass firefox/safari/brave/edge to overrideRun that in a real terminal — macOS pops a keychain prompt the first time, because Chrome encrypts
its cookie store, and that prompt needs a human. It writes data/youtube-cookies.txt (chmod 600,
gitignored), which the server picks up with no configuration.
npm run doctor probes both an open and a gated video and tells you which of the two you are
hitting. Articles and podcasts are never affected.
The clip pipeline shells out to ffmpeg and yt-dlp, so it needs a host that runs containers — not
static hosting. The Dockerfile carries both binaries.
docker build -t annotated .
docker run -p 4000:4000 \
-e PUBLIC_URL=https://your-host \
-e SESSION_SECRET=$(openssl rand -hex 32) \
-v annotated-data:/app/data -v annotated-media:/app/public/media \
annotatedfly.toml and render.yaml are ready to go. Whatever you use, set
PUBLIC_URL to the public hostname — annotation links, OAuth callbacks and extension pairing are all
built from it — and mount a volume at /app/data and /app/public/media so the database and the
generated clips survive a redeploy.
To expose a local instance without deploying anything:
cloudflared tunnel --url http://localhost:4000then restart the server with PUBLIC_URL set to the URL it prints. That URL is ephemeral and the
clipping runs on your own machine, so treat it as a demo link rather than hosting.
Clipping runs inline, so the expensive endpoints are capped per IP: 12 clips per 10 minutes,
30 URL probes and 40 writes per minute. Responses carry RateLimit-* headers and a Retry-After on
429. Raise them in server/routes/api.js once clipping moves to a queue.
npm test13 contract tests over the parts the spec is strict about: the 90s and 240p ceilings, the source link
and claim button being present on every annotation page, claim validation, takedown removing an
annotation from both the feed and its page, auth gating, the extension pairing handshake being
single-use, follow/comment behaviour, and source-type routing. They use their own database
(DB_PATH) and never touch the network.
POST /api/publish |
clip + annotate in one call (multipart, optional audio) |
POST /api/capture |
clip only, returns the clip |
GET /api/context?url= |
what is this URL, and how long is it |
GET /api/feed?scope=&before= |
the public feed, cursor-paginated |
GET /api/annotations/:slug |
one annotation plus its comments |
POST /api/annotations/:slug/comments |
comment |
POST /api/users/:handle/follow |
toggle follow |
POST /api/claims |
file a fair-use claim |
POST /api/ext/pair/start · GET /api/ext/pair/status |
extension pairing |
Auth is a session cookie on the web, or Authorization: Bearer <token> from the extension.
- Claims are recorded and shown, and
annotations.takedownhides an upheld one everywhere — but there is no moderator UI yet; flipping that flag is a SQL update today. - Clipping happens inline on the request. A real deployment wants a job queue for anything longer than a few seconds.
- Media is served off local disk. Point
public/mediaat object storage before this sees traffic.