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 - 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.5,
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/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
recording time. 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 by
default. A separate Plus-only export toggle adds them to chapter titles and a
©xyz geotag derived by averaging the majority cluster of clip locations
(within 5 km), falling back to the first located clip. WebM exports skip both
(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. - 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.
- 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, and
landscape projects: 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: "Already paid?" on the export sheet or a locked slot's upsell.
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 requirement: set STRIPE_SECRET_KEY (a restricted key with
Checkout Sessions read access is enough) on the Cloudflare Pages project.
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), and 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). 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) |
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.