Mobile-first web clips camera: hold anywhere on the preview to record, arrange clips on a filmstrip timeline, then tap Go to export/share — all on-device.
Kody Video is inspired by the OK Video interaction model: camera-first capture, quick clip cleanup, and one big OK/share moment. It is an independent project with its own name, mark, and implementation; it is not affiliated with OK Video and does not use OK Video trademarks or assets. The koala mascot is credited to the KCD community / kentcdodds.com/kody. App artwork in public/art/ was generated from that Kody reference (camera, timeline, share, app icon).
npm install
npm run devOpen the printed localhost URL in Chrome (desktop or Android). Camera/microphone require a secure context (http://localhost or HTTPS).
npm run build # production build + service worker
npm run preview # serve dist (PWA cache active)
npm test # storage/export-planner unit tests (Vitest browser mode, real Chromium)
npm run test:e2e # Playwright e2e suite (fake camera/mic; recording, editor, playback, export, plans)
npm run test:smoke # Playwright UX smoke (fake camera, records + exports)Live app: https://kody.video (Cloudflare Pages, builds from main)
For a phone on the same network, use your machine’s LAN URL over HTTPS, or tunnel (npm run dev -- --host plus a trusted tunnel). getUserMedia will fail on plain http://<lan-ip> in most browsers.
- Full-bleed live camera (rear preferred; flip, torch, and zoom when the device exposes them)
- Hold-to-record anywhere on the preview; drag up/down while holding to zoom
- Self-timer for hands-free takes (tap to stop)
- Recording feedback (REC pill + elapsed) with a page that does not re-render per frame — capture stays smooth
- Editor: filmstrip timeline (thumbnails, width ∝ duration), drag to reorder, duplicate, delete w/ undo, in-timeline trim with drag handles, add clips and photos from your device/gallery
- Photos on the timeline: a picked image becomes a still clip (3s by default) that plays in previews and exports like any other clip — silent, with a small camera badge on its tile. Instead of the trim strip, photos open a Duration strip: because a still has no media length to trim within, its on-screen time is a free choice that can grow as well as shrink — drag the handle across a 0–30s scale, tap a preset (1/2/3/5/10s), or nudge with ±0.5s steppers to land the exact length, with the precise readout always visible. Photos travel in backups and duplicate/reorder like clips
- Per-clip audio levels: every clip's audio is peak-normalized as a post-recording step (measured once, persisted on the clip, backfilled automatically when an older project loads), so takes recorded at different distances play back at consistent loudness — in the previews and in the export. Selecting a clip in the timeline shows a Clip sound slider: the clip's own volume, 0–100% (default 100%), applied with or without music
- Background music (Plus): build a playlist of audio tracks that play one after the other under the film (nothing loops — a hint suggests adding another track when the music ends before the film does). Each track carries its own volume (default 25% — normalized music sits under speech), set in the track's detail view (the audio counterpart of the clip trim view: trim the track to a kept window over its waveform, set its volume, and toggle its fade in/out — on by default; each track eases in where it starts and out where it ends, including the film's edges). The selected clip additionally gets a Music slider that ducks the playing track's volume during that clip (0–100%, default 100%) — the clip's own sound and the music are independent dials, and the blend hard-clamps rather than coupling them. Volume changes glide across clip boundaries. All of it — volumes, normalization, fades, trims, track hand-offs — plays the same in the previews as in the exported file: the project preview runs the whole film's bed, and the editor's clip stage plays the music under the selected clip from its exact spot on the film's timeline (normalization boosts cap at the browser's volume ceiling in previews)
- Landscape projects (Plus): no setting — rotate the phone. While a project has no clips its interface follows how the device is held, and the first take locks the orientation (emptying the project of clips lets the next first take decide again). Landscape shifts the entire interface: the phone-shaped shell widens to the viewport, the record controls become a right-hand rail, the editor puts the player beside the timeline, and hints ask for a turn whenever the device mismatches the locked project. Exports are forced into a landscape project's orientation (mismatched clips center-crop), and the lock travels in backups. On the free plan, rotating an empty project previews the landscape interface but the take that would lock it opens the Plus upsell. Desktop is exempt (webcams and screen shares are landscape media without that being a choice): its projects stay unlocked and exports follow the clips, as always
- Project preview playback: tap edges to skip clips, tap middle to stop
- Up to 6 stable project slots (create / open / rename / delete, poster art from your clips)
- Big Go CTA: on-device export to one video file, then Share (system sheet) or Save
- Fallback: save clips as separate files
- Project backup/import: one
.kodyvideofile per project (clips, trims, location data, background music + volumes) — a safety net, and the way to move a project between devices or browser origins (storage is per-origin); ⋯ → Save backup on a slot, import from the About page or by dropping a.kodyvideofile anywhere in the app - Send to another device (Plus): a pairing code + QR introduces two
browsers; the
.kodyvideotravels over a WebRTC DataChannel (STUN only — same Wi‑Fi usually). Clips never land on our servers. Receiving is free at/receive. If the devices cannot connect, Save backup still works - Installable PWA (manifest + Workbox service worker for the app shell)
- No accounts, no uploads, no analytics
Most QA is automated: npm run test:e2e runs the Playwright suite in
tests/e2e/ (~40s). See manual-test-checklist.md
for what it covers and the remaining real-device-only checks.
Built with Remix 3 (remix@3.0.0-beta.10,
pinned — v3 is prerelease) as a pure client-side app: remix/ui components
rendered with createRoot, no server rendering.
src/
lib/storage.ts IndexedDB (idb) — projects, clip blobs + thumbnails, undo
lib/project-actions.ts Loader/mutation helpers for pages
lib/camera.ts Camera controller (open/flip/zoom/lens/mic lifecycle)
lib/recorder.ts Hold-to-record MediaRecorder wrapper (hardware-codec aware)
lib/media.ts getUserMedia/permissions/share/download helpers
lib/thumbs.ts Filmstrip thumbnail generation (stored per clip)
lib/sync-*.ts Plus send-to-device (WebRTC + /api/sync matchmaker)
lib/sheet-modal.ts Bottom-sheet modality (focus trap, Esc, sheet stack)
lib/export/ Export engines (see below)
components/record-screen Camera surface (capture, zoom, timer, dock)
components/editor-screen Timeline, trim, clip actions
pages/ Home (project slots) + Project (record/editor shell)
router.tsx Tiny client router (route-pattern matching + history)
MP4 exports carry chapter markers at every clip boundary (Nero chpl,
injected post-mux by lib/export/mp4-metadata.ts), titled with each clip's
time of day (no calendar date), plus QuickTime text tags: the project title
(©nam), a short composition note (©cmt / ©des — clip and photo counts,
duration, music), and an encoder credit (©too) pointing at
kody.video. Kody Video Plus users can opt into
location tagging on the record screen; clips then store device coordinates
in IndexedDB alongside the clip, never inside the MediaRecorder blob. Exports
omit those coordinates — and filming dates — by default, so a public share
does not disclose where or when it was made. A separate Plus-only export
toggle adds calendar dates and coordinates to chapter titles, a ©xyz geotag
at the median of the majority cluster of clip locations (within 5 km), falling
back to the last located clip, and a capture timestamp (©day plus
mvhd/tkhd creation_time) from the last timeline video that has one — so a
Photos library can place the film on the right day and map pin. Share and Save
also stamp File.lastModified from that same last-clip time (Web Share copies
it onto the filesystem created/modified dates). Synology Photos sorts by those
file dates rather than the MP4 atoms. WebM exports skip this injection (the
WebM subset of Matroska excludes chapters). Clips recorded before this feature
simply lack the data and degrade gracefully.
lib/export/ stitches clips into one file with two engines:
- Mediabunny + WebCodecs (preferred, all Chromium + recent Firefox/Safari): each clip's own samples are demuxed and decoded directly by Mediabunny (no playback pacing — the frame supply runs at hardware speed, keeps working in background tabs, and honors file rotation metadata), composited onto one canvas with the watermark, and encoded/muxed by Mediabunny, which owns the codec-config/packet-ordering/container details. Audio is decoded per clip and appended sample-accurately, so audio can never drift across clips; backpressure comes from awaiting the encoder. Container/codec negotiation strongly prefers MP4 (H.264/HEVC + AAC) — the most shareable output everywhere — and only falls back to WebM (VP9/VP8 + Opus) on non-iOS platforms without MP4 encoders. Clips WebCodecs can't decode take a realtime element-pump fallback per clip.
- Realtime fallback: the old
canvas.captureStream+MediaRecorderstitcher, hardened with timeouts and degenerate-segment skipping, for browsers without WebCodecs.
plan.ts is a pure, unit-tested planner that clamps trims and drops unplayable segments up front. The share flow completes the export first, then Share/Save run on fresh taps so the Web Share API always has the user activation it requires.
- Phones prefer hardware H.264 (
video/mp4/h264MediaRecorder types) over software VP9 — software encoding is what makes previews and clips drop frames on Android. - Capture asks for 1080p at 30fps; bitrate scales with the actual track size (about 10 Mbps at 1080×1920) instead of a flat 3.5 Mbps.
- A live MediaRecorder is armed on the record screen so the hardware encoder is already past its ~170ms startup hole when the user presses; the take adopts that session and trims the pre-roll.
- Clip duration is measured from the encoded media after stop (wall-clock time includes encoder startup latency and corrupts trim/export math).
- The elapsed timer is a leaf component mutating its text node directly from a 10Hz boundary-aligned timer (a per-frame rAF loop would force 60 main-thread frames/s), so the ticking readout never re-renders the page during capture.
- A screen wake lock is held while recording.
- Exports keep 1080p (long edge 1920), hold the last frame across source gaps so timestamp holes become a freeze instead of a hitch, and only drop frames closer than half a 30fps tick (jittery 30fps stays; 60fps extras go).
- Components are Remix 3 setup + render functions: state lives in plain
setup-scope variables, re-renders happen only on explicit
handle.update(). - Pages own their data: each page loads IndexedDB state in setup and
exposes
refresh(); mutations write storage then callrefresh(). - Camera attaches via the
ref()mixin (start on insert, stop when the element's abort signal fires). - Blob URLs bind/revoke in
ref()mixins (BlobVideo,BlobImage,TimelineThumbImage), re-synced from render when the blob changes. - Sheets reset with
key={id}; modality comes fromlib/sheet-modal.ts. - Timers / toasts use
requestAnimationFrame/setTimeoutstarted from event handlers.
| Store | Contents |
|---|---|
| projects | JSON metadata + ordered clipIds |
| clips | Clip metadata, Blob media, filmstrip thumbnails |
| undo | Last deleted clip per project (for Undo) |
| meta | Settings (maxProjects, last opened id, onboarding) |
| audio | Background-music playlist per project (blobs, per-track trims/volumes/fades) |
Database name: kody-video. Blobs never leave the device unless the user explicitly shares/downloads.
vite-plugin-pwa generates a service worker that precaches the app shell (html/js/css/icons). After the first successful visit:
- Airplane mode still loads the SPA from Cache Storage.
- Project/clip data continues to come from IndexedDB.
Verified approach: npm run build && npm run preview, load once online, then DevTools → Network → Offline (or OS airplane mode) and reload.
- iOS Safari: WebCodecs audio support is incomplete; the realtime fallback engine covers it, but Chromium (especially Android) is the primary target.
- iOS microphone: WebKit can deliver muted audio tracks when mic and camera come from separate
getUserMediacalls, so on iOS the mic is acquired together with the camera and held while the preview is open (everywhere else the mic is grabbed per-take). A live level monitor warns "Mic isn't picking up sound" during silent takes on every platform. - External mics on iOS (DJI transmitters, AirPods, wired headsets): iOS pins web capture to the built-in mic by
default. After the camera opens, the app kicks WebKit's
navigator.audioSessionintoplay-and-record— the documented nudge that re-routes capture to a connected external mic — and restores the session when the camera closes. Best-effort: routing remains OS-controlled. - Permissions: denied camera/mic must be re-enabled in site settings; the UI surfaces this.
- Storage quotas: large projects can hit IndexedDB quotas; the soft 6-project cap helps.
- Background tabs: recording should stay in the foreground; browsers may throttle capture.
- Ultra-wide (0.5×): Android usually exposes the ultra-wide/telephoto as separate rear cameras, not as zoom below 1× — the lens chip next to the zoom chips switches between them. Some devices don't expose the extra lenses to browsers at all; the chip is hidden there. Phones that expose Android's logical multi-camera to the browser (zoom range below 1×, common on Samsung) get native-style seamless lens hand-off through the zoom gesture instead — the app detects such a lens the first time it's opened (cycle the chip once) and locks onto it for future sessions. Switching lenses with the chip afterward replaces that memory (your explicit choice always wins); opening the seamless lens again re-locks it. iPhones always work this way via the OS multi-lens camera.
Kody Video is designed as a mobile camera app, but desktop gets first-class keyboard support (hints appear automatically on fine-pointer devices):
- Camera: hold
Spaceto record (release to stop),Fflip,Tself-timer,Sscreen recording,Eeditor,Pplay preview,Deleteremove last clip. - Editor:
←/→select clip,Alt+arrows reorder,Ttrim,Dduplicate,Deletedelete,Pplay,Escback to camera (or exit trim). - Playback:
←/→skip clips,Spacepause/resume,Escclose.
The monitor button on the camera view (or S) records a screen, window, or
tab as a regular clip — pick the surface, narrate over your mic (mixed with
shared tab/system audio when you opt in), then tap the preview or the button
to stop and the take lands on the filmstrip like any camera clip. Desktop
browsers only: getDisplayMedia does not exist on iOS or Android, so on
phones use the OS screen recorder instead. The button hides itself where the
API is missing.
The free plan includes one project, and exports carry a small Kody Video mark
in the corner. Kody Video Plus — a one-time $0.99 Stripe Payment Link —
removes the watermark, unlocks six project slots, background music,
landscape projects, and sending a project to another device: the export
sheet (or a locked home slot, the locked "Add music" button in the editor,
or rotating an empty project sideways on the free plan) links to checkout,
Stripe redirects back to /unlocked?session_id=…, and a single Cloudflare
Pages Function (functions/api/verify-purchase.ts) verifies the session
server-side before the entitlement is stored in IndexedDB. 100%-off
promotion codes (friends / the developer) flow through the exact same
verification. Restore on another device: on the phone or computer that
already has Plus, open About and tap "Use Plus on another
device" — type the short code or scan the QR. "Already paid?"
on the export sheet still accepts that code (or a checkout
session id).
Projects are also created lazily — "New project" opens the camera at
/project/new and nothing is persisted until the first clip is recorded, so
backing out of an untouched project leaves no empty slot behind. The same
holds after creation: a project exited while still in its default state (no
clips, default name, no music) is silently deleted when the home screen
loads, since keeping it would change nothing the user can see.
Deployment requirements on the Cloudflare Pages project:
STRIPE_SECRET_KEY(a restricted key with Checkout Sessions read access is enough)- KV namespace binding
SYNC_ROOMSfor Plus send-to-device matchmaking (room codes + WebRTC descriptions only; never media). Without it, Send shows a configuration error and Save backup still works.
The app has been implemented four times as an experiment series, and every edition stays online as a living showcase. Each deployment carries a dismissible banner pointing back to the real app and to the PR where the agent analyzed that edition:
| Origin | Edition | Analysis |
|---|---|---|
| kody.video | Remix 3 (this branch — production) | — |
| remix.kody.video | Remix 3 rewrite preview (proxies production) | PR #87 |
| react.kody.video | React 19 + React Router 7 (pre-rewrite, branch cursor/react-legacy-b52b) |
PR #87 |
| vanilla.kody.video | Web components, no build, no deps (branch vanilla) |
PR #88 |
| typescript.kody.video | The vanilla app in strict TypeScript, tsc-only build (branch typescript) |
PR #89 |
Storage is per-origin, so projects recorded on a showcase stay there — use backup/import to move them to kody.video.
Email team@kody.video or open a GitHub issue (the in-app About page has a link that pre-fills device details).
If the app opens to just the Kody hero with no project slots (a stale or
poisoned cached shell), two always-fresh pages ship with the app — served
under /api/ so no service worker or cached shell can interfere:
- kody.video/api/diag — on-device, read-only diagnostics: confirms your projects/clips are present in IndexedDB, checks the cached vs network shell, and probes whether the app can mount.
- kody.video/api/recover — one-tap repair: drops the service worker and cached app files and re-primes them fresh. Never touches IndexedDB, so projects and clips are safe.
- No accounts, no cookies, no cross-site tracking.
- Page views are counted with Fathom Analytics (cookieless, anonymous, aggregate-only), loaded only on production hostnames.
- No clip upload endpoints exist in this app.
- Share/export uses user-gesture download or the Web Share API only.
- Network calls besides Stripe checkout (opened in the browser): the
purchase-verification function above (never sees media); anonymous
Sentry crash reports (error + stack trace only — breadcrumbs and request
metadata are stripped in the SDK config; no PII, no media; only from
production hostnames — dev and tests never report); and, only when you
tap Send to device, a short-lived
/api/syncmatchmaking room (code + WebRTC descriptions, never clips). Export and import failures the UI surfaces as friendly messages are also captured, tagged with the failing step, so real-device bugs surface. - The home-screen tour video (shown to first-time users) streams from
media.kody.video(an R2 bucket behind the app's own zone — no third-party player, no tracking) and only when the user taps play on it.
| Command | Purpose |
|---|---|
npm run dev |
Vite dev server |
npm run build |
Typecheck + production bundle |
npm run preview |
Preview production build |
npm test |
Vitest storage/export-planner tests (browser mode, Chromium) |
npm run test:e2e |
Playwright e2e suite (tests/e2e/): recording, editor, playback, export, plans, keyboard |
npm run test:smoke |
Playwright smoke: record → edit → export |
node scripts/probe-export-chrome.mjs |
Export validation in Chrome stable (real codecs) |
node scripts/probe-keyboard.mjs |
Desktop keyboard flows (record/edit/playback) |
node scripts/probe-rear-lens.mjs |
Rear lens switching (ultra-wide) with fake cameras |
node scripts/probe-fast-export.mjs |
Decode-driven export beats realtime (MP4 clips) |
node scripts/probe-mic-monitor.mjs |
Silent-mic warning fires (and clears) correctly |
node scripts/probe-install-hint.mjs |
iOS install hint shows/dismisses per user agent (needs npm run build) |
node scripts/probe-deployed-remix.mjs |
Live smoke against a deployed origin (defaults to remix.kody.video) |
node scripts/probe-screen-record.mjs |
Desktop screen recording lands as a clip |
node scripts/probe-touch-timeline.mjs |
Touch timeline gestures (scroll, long-press lift) |
node scripts/probe-webkit.mjs |
WebKit engine sanity + feature matrix (iOS proxy, not a substitute for a real device) |
Outside pull requests to this repository need a signed inbound
Contributor License Agreement.
See CONTRIBUTING.md.
Kody Video is licensed under the Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2). You can use, copy, modify, create derivative works from, publicly perform, publicly display, and redistribute the software for any purpose other than Competing Use. Competing Use means making the software available to others in a commercial product or service that substitutes for Kody Video, substitutes for another product or service the licensor offers using Kody Video that existed when the version was made available, or offers the same or substantially similar functionality. Each version becomes available under the Apache License 2.0 on the second anniversary of the date that version was made available.