Kody Video (kody.video) — hold-to-record clips camera for the web. Privacy-first, on-device.
by kentcdoddsTypeScript
Last 12 weeks · 207 commits
3 of 6 standards met
Root cause (verified in real Chromium, not guessed) Every write to a clip's metadata stored a fresh copy of its whole video, and the browser kept charging the old copy against quota. Clip records held the video inline. Trim, volume, fit, audio-peak, display-size, and thumbnail writes all re- the whole record. IndexedDB stores each put's Blob as a new file, even when the Blob was just read back from IndexedDB. Chromium releases the superseded file only after every JS reference to the old Blob has been garbage-collected. On a phone that can take a long time, because the wrappers are tiny and V8 has no reason to collect them. The project page also keeps the first-read Blob alive on purpose (, so playing previews don't restart), which pins the old copy for the whole time a project is open. Every take gets at least one such write right after it's saved (captured thumbnails, then the audio-peak measurement), so a recording session held about 2× the real footage. Measurements (persistent Chromium profile, the app's own storage API, real fake-camera takes through the UI): So the copies are reclaimable in principle, but a user can't force that. Page reload alone didn't release them in testing; a full browser restart did. What Kent's 1.9 GB is Legitimate footage: High = 1920×1080×30×0.16 ≈ 9.95 Mbps video + 192 kbps audio ≈ 1.27 MB/s. 16:58.5 of kept footage is ≈ 1.3 GB at the target bitrate, plus small untrimmed tails (200 ms stop grace plus warm pre-roll per take). Hardware encoders under- or overshoot, so treat this as approximate. The rest is most likely superseded copies from the mechanism above: two or more per take, never released during a long-lived PWA session. I can't read his device, so this is the verified mechanism plus consistent numbers, not a direct measurement. After this ships, About → Storage shows his actual split. Ruled out: Cached exports: 0 MB in his screenshot, and the OPFS directory is the only OPFS use. Service worker cache: it precaches only the app shell (files under 3 MB, no media). Reporting: take reports live in a separate DB capped at 300 × ~1 KB. Send attaches the JSON in memory to one Sentry event, with no offline transport and nothing persisted. (Also, no "Recording health report" event reached Sentry in the last 30 days, so his Send may not have completed.) Changes Storage (DB v4) New object store keyed by clip id. Clip records no longer carry the video, so metadata writes rewrite only a small record. One contract: is the single write path, and reads join the media back in, so is unchanged for every caller. No eager migration. Copying every clip inside the upgrade transaction needs the whole library's size free again, which a 95%-full device doesn't have. A legacy inline blob moves on that clip's next write, which costs the same one copy the old code paid on every write, and then never again. Undo keeps the deleted clip's media where it is instead of copying it into the undo store. Superseded snapshots and drop it. Orphan prevention: project read-modify-writes (, rename, reorder, delete/discard clip, undo) now read inside their writing transaction. A stale project snapshot could otherwise drop a just-saved clip from and leave its record unreachable. also removes clip records filed under the project via the index, not only the listed ones. Stranded clips are restored, never reclaimed. A clip record filed under a live project but missing from its list is footage nobody deleted, so it counts toward its project and puts it back on the timeline when home loads. now rejects orders that repeat an id, which could push another clip out of the list (CodeRabbit finding). / use one pure that attributes every stored byte to a project (clips, thumbnails, music, pending undo) or to leftovers whose project is gone. Reclaim runs in a single transaction over all stores, so an in-flight save is never mistaken for an orphan. UI Home cards show each project's size (). About → Storage breaks the browser total into each project, cached exports (Clear), leftovers no project uses (Clean up), and "app files & space not yet released". When that last row is large, a note explains the browser hasn't released it and tells the user to fully close and reopen the app. The home storage banner links to About → Storage and offers Clean up leftovers when there are any. !Home card with project size !About storage breakdown !After cleaning up leftovers The screenshots use a mocked 1.9 GB / 2 GB estimate over a small seeded project to show the banner and the unreleased-space note; the project sizes and the 3 MB leftover are real. Tests Unit (): media stays out of the record across all metadata writes; lazy legacy migration; undo keeps and later drops media; pending undo counts toward its project; measure plus reclaim of stray clips, ghost media, and music for deleted projects (live data intact); project delete removes unlisted clip records; a clip missing from a live project's list is restored, never reclaimed; reorders that repeat an id are rejected; a concurrent trim and save keeps the new clip listed. Unit: / ; sizes, and restoring an unlisted clip before the empty-project sweep. e2e (): Home card and About sizes. About leftovers cleanup. Persistent-profile quota regression: four edits on an 8 MB clip must grow usage by less than 1 MB. With the old inline storage this measures +32 MB and fails. , (615 passed), (114/115 passed; the one failure, , is a layout timing flake under full parallel load and passes alone on this branch and 3/3 on ). Risk High: an IndexedDB schema change on the only copy of users' footage. The upgrade only adds a store and moves no data. Reads fall back to inline blobs, so pre-v4 records keep working untouched. Merged on explicit deploy approval; worth a check on a real phone with an existing project after deploy. Summary by CodeRabbit New Features Project sizes now appear on the home page and in the About page’s storage breakdown. The storage breakdown shows cached exports, orphaned data, and other browser storage when notable. Reclaim orphaned storage from the home page or About page, with status updates after cleanup. Clip media is stored separately from clip details, limiting storage growth as clips are edited. Bug Fixes Clips missing from a project’s listing are restored when project data is loaded. Removing clips and projects preserves undo behavior while safely cleaning up associated storage.
TL;DR Proven drop mechanism (Chromium, which is Chrome/Brave on Android): a main-thread stall of about 200ms or more during a take drops frames from the saved file. Chromium's track counters show that those frames never reach MediaRecorder. I measured stalls of 60, 100, and 150ms with no loss; 250ms stalls cost about 3 frames each. Fixed: optional post-take work now waits while a take is recording, and drag-to-zoom camera writes are coalesced. On one 4s drag, calls went from 224 to 56. Not reproducible in the VM: the app's own post-take pipeline never stalled long enough to drop frames here, even at 6× CPU throttle with a 25-clip project. The remaining suspects are device-side: camera fps in dim light, heat from the always-warm encoder, hardware encoder behavior, and real zoom hardware. So every take now writes an on-device recording-health report. It records the saved file's actual frame cadence plus timestamped live signals, and each gap is matched to a likely cause. Kent can pull the report from About → Recording health after the trip. Root cause investigation Pipeline: Recording: camera → clone track → MediaRecorder (H.264 on phones, no timeslice, warm pre-armed session). After each take: release → 200ms grace → measure duration → copy blob → IndexedDB → refresh → hydrate (display size, thumbnails, audio peak). Harness: drives real hold-to-record takes in Chromium with a fake 30fps camera. It reads each saved clip's frame timestamps with a metadata-only demux (no decode) and correlates them with long tasks and event-loop lag during the hold. On the 250ms-stall take, Chromium's track counters read 131 produced → 104 delivered to MediaRecorder → 100 in the file. Healthy takes match exactly (127/127/127). Hypotheses I checked and ruled out: "60ms audio dropouts" in every clip: these were WebM blocks with no duration, not real gaps. Export frame clock: it handles jittery 30fps and 60fps sources correctly. Re-renders during a take: already eliminated by #153 and #122. What changed Capture-first scheduling (): each step waits while a take (camera or screen) is recording. Saving the previous take never waits. Refreshes also skip the display-size parse for clips already verified this session; previously every refresh re-parsed every clip in the project, O(n) work after each take. Zoom writer (): latest value wins, one in flight, at most one per camera frame, and the final value always lands. It feeds both the drag and the snap-back ramp. Before this, every pointermove at 60–120/s was a round trip into Camera2 / AVCaptureDevice. Take reports: collects live signals with no per-frame work: a 100ms lag timer, entries, visibility, preview playback counters, and zoom and background-work windows. adds encoder facts: warm age, flush and measure latency, bitrate, Chromium produced/delivered/discarded counts, and idle-encoder usage (a heat proxy). records coarse device facts, battery, and Compute Pressure state. and compute the kept-range cadence from the saved file, a verdict ( / / ), and per-gap reasons: , , , , , , , , . stores reports in their own IndexedDB database. A report is saved immediately and analyzed once capture is idle, reading the saved clip. Up to 300 are kept, so backups never carry them. About → Recording health (): shows a summary and recent takes, with Share (file, or download), Copy, Send to Kody Video, and Clear. Send appears only on kody.video; it creates one Sentry info event tagged with the JSON attached, and only when the user taps it. The panel also backfills analyses a closed tab never finished. The privacy and About copy now describe the reports. Docs: covers findings, remaining suspects with the report field that confirms each one, and how to read and pull a report. Privacy: reports contain timings and counters only, never video, audio, location, or project names. Nothing leaves the device unless the user taps Share or Send. Verify on a phone while traveling 1. Record as usual. Try a few quick back-to-back takes, one with drag-to-zoom, one indoors in dim light, and one after the camera screen has sat idle for a while. 2. Home → ⓘ → Recording health, or open . Each take gets one line: fps, missing frames, worst gap, and likely cause, with a green, amber, or red dot. 3. Tap Send to Kody Video. It arrives in Sentry as "Recording health report" (tag ) with attached. You can also use Share report (a holding JSON) or Copy. 4. How to read it: (median interval about 67ms) means the camera itself ran at about 15fps, likely from dim light or heat. / means the app stalled. means the gap happened during a drag. means the encoder was starting up. means MediaRecorder or MediaCodec lost frames it had received. Tests Unit: 600 pass (). New coverage: cadence math and a real metadata-only WebM read, the verdict and reason classifier, the capture-idle gate, the zoom writer (burst collapse, per-frame spacing, reject, dispose), the take probe (a real stall is detected and placed correctly), the report store (prune, backfill, bad file), and a hydrate test proving it waits behind a live take. e2e: 112 pass. New records a take, checks that the analyzed report exists, confirms About shows it, exports it, clears it, and asserts no non-GET requests. "mid-take re-renders…" is flaky under 4-worker load on both and this branch (1 failure in 2 repeats each); CI retries it once. passes. The entry chunk grew about 1.1 kB; the diagnostics code is its own 8 kB chunk. Artifacts About → Recording health after three real takes in one browser (idle, drag-zoom, and one with an injected 250ms main-thread stall): !Recording health panel after three takes Manual walkthrough in Chrome with a fake camera: record two takes, open About → Recording health, then Share report ("Report downloaded."): Summary by CodeRabbit New Features Added a Recording health section in About with summaries of recent takes and options to copy or share diagnostic reports, send counters-only reports where available, or clear saved reports. Recording diagnostics are stored on-device and can include frame cadence, recording outcomes, and coarse device details. Improvements Optional clip processing waits until recording stops, while saving the previous take can proceed immediately. Zoom updates are coalesced so the latest requested setting is applied. Documentation** Added guidance on recording smoothness checks and interpreting recording-health reports.
Summary Sentry KODY-VIDEO-13 is a production unhandled rejection (, empty stack, no tag). Plus send/receive cancel throws that on purpose from / sync signaling. The send and receive handlers already skip for AbortError, but the rejection still reached . Fix Add and drop only the intentional sync-cancel shape in : / whose entire value is (optional period), the wrapped form, and when the message is that exact string. Other AbortErrors still report. Treat the same cancel more robustly in send-sheet and receive-page (: any , including DOMException, or the wrapped message) and return without . Observe the receiver data-channel promise that runs beside , so a cancel no longer leaves an orphan AbortError rejection. If receiver setup throws, close the peer connection (the caller only cleans up after a successful return). Risk Low — expected-cancel filter plus local rejection observation and peer close. No privacy/PII setting changes. Intended to squash-merge after CI and Bugbot. Local verification — 73/73 passed — 552/552 passed (before the exact-match / peer-close follow-up; those paths re-ran in the targeted suite) () — clean Test plan [x] Unit tests for the Sentry filter and the receiver-cancel unhandled-rejection race [x] and [ ] CI green and Bugbot clear, then squash-merge Summary by CodeRabbit Bug Fixes Improved handling of cancelled send and receive operations, preventing expected cancellations from appearing as errors. Prevented connection failures during receiving from causing unhandled promise rejections. Preserved clearer error reporting for unexpected transfer failures. Improved cleanup when receiving connections fail. Tests** Added coverage for cancellation detection, error filtering, and aborted receive operations.
Repository: kentcdodds/kody-video. Description: Kody Video (kody.video) — hold-to-record clips camera for the web. Privacy-first, on-device. Stars: 30, Forks: 4. Primary language: TypeScript. Languages: TypeScript (88.6%), JavaScript (5.6%), CSS (5.1%), HTML (0.7%). Homepage: https://kody.video Latest release: v2026.09.27 (2d ago). Open PRs: 0, open issues: 0. Last activity: 2d ago. Community health: 57%. Top contributors: kentcdodds, cursoragent, devin-ai-integration[bot], iambharathpadhu.