Skip to content

Roadmap

What is deliberately not being worked on, and why — written down so the order is a decision rather than whatever came up last.

The product's central claim, that the phone does the editing and the server stays light, has executed: on real hardware, in production, from a real phone.

Done, and why they were first

Kept here rather than deleted, because the sequence explains the shape of the code and because "why is it like this" is the question a newcomer asks.

  1. Development build. Expo Go cannot link native modules, so it could never run the real edit. The dev build unblocked three things at once: the native engine, push notifications — the trigger for the whole weekly loop — and the real permission prompts, which app.json does not apply inside Expo Go.

Expo Go is now not merely limited but unusable here: it ships only the newest SDK and this project is on 54.

  1. The native renderer. Done, and running on real hardware. mobile/modules/vlog-editor is a local Expo module: on Android MediaExtractor and MediaCodec with an OpenGL composite in between, on iOS AVMutableComposition.

It went in two stages, and the distinction still explains the shape of the code. Stage 1 joined takes without decoding — fast, lossless, and with no codec in the loop at all. Everything it could not do traced back to that one absence: no silence trimming (finding silences means decoding audio), no crossfades, no loudness normalisation, no stills (MediaExtractor cannot open a JPEG), no mixing formats.

Stage 2 was therefore an encoder, not a list of features. Decode, do the work, re-encode — which unlocked all five at once. Photos and mixed formats no longer send an edit to the server. The Android module used to report every capability as supported without looking; it now asks the device's codec list for the encoders the pipeline needs, and a phone without them says so up front (#161, #163). A failure the check cannot see — a codec that breaks mid-encode — is caught at runtime and the same edit retried on the server, when the server render is on (#122).

One of the five is built and switched off: silence trimming is disabled across the whole app (TRIM_SILENCES = false in mobile/src/edit/trimming.ts). Detection works on the device, on the server and in the EDL, but on real footage it read as the app editing over people rather than for them, and a pause before speaking is not necessarily dead air. App copy that still promises it is #172.

What remains: iOS has never been compiled, and the Swift side is behind — no audio analysis, no stills, no fades, no loudness. It needs a paid Apple Developer account before it can even be built; the first build and smoke test is #82.

The engine picker still routes around whatever an engine says it cannot carry, and the edit screen says which limitation applies — distinguishing "this phone has no engine" from "this material needs one", so nobody goes hunting for a fault in a device that is working fine. That machinery earns its place the moment iOS exists.

  1. Decide with numbers. The question that justified all of it: a two-minute vlog assembling in 40 seconds is viable, four minutes and a hot phone is not. It assembles, and nobody has sat down and measured it properly — see Judge the edit below.

The FFmpeg problem, and how it was answered

ffmpeg-kit-react-native was the library the on-device pipeline was originally written against. It is deprecated on npm and its native binaries are gone: every version of com.arthenica:ffmpeg-kit-* returns 404 from Maven Central and the artifact directory no longer exists. The community fork resolves the same coordinates, so it inherits the problem.

The answer was the custom module — MediaCodec on Android, AVFoundation on iOS — which is why modules/vlog-editor exists and why the engine picker still has an unreachable ffmpeg branch in it. The options that were weighed:

  • Host the AARs. ffmpeg-kit is open source; build it and publish the artifacts to a private Maven. Most faithful to the code that already exists, and the EDL and command builders would work unchanged.
  • A custom Expo module over MediaCodec (Android) and AVFoundation (iOS). More work, far smaller binary, no dependency on an abandoned project. The hard half — the EDL — is already pure TypeScript and tested, so the native side only has to execute cuts, crossfades and loudness.
  • Concede the server render. Cheapest to build, most expensive to run, and it contradicts the reason the architecture was chosen. Worth measuring before ruling out, not before trying the other two.

Next: the things a real deployment needs

  • ~~CI.~~ Done. .github/workflows/ci.yml runs on every pull request and every push to main: ruff and pytest for the backend; expo-doctor, tsc, npm run lint, jest and npm run check:contract for the app; a smoke job that boots the real stack and walks one user journey through it; and a dependency audit. Coverage thresholds fail the build, and so does the audit — on a critical npm advisory, or on any pip-audit finding not deliberately ignored.

.github/workflows/native.yml compiles the Kotlin module and runs the NewApi lint, which blocks (#105), and only runs when native files change. It also has a manual job that runs the instrumented tests on an emulator. It is the one that pays for itself: three EAS builds died on compile errors in a row, nine minutes and a build from the quota each, for mistakes a compiler names in five. testing.md has every workflow and when it runs.

What the coverage number does not include, which matters more than the number itself: the Kotlin engine. It has tests of its own — instrumented tests on a device or emulator (#81, #134), all passing since #137, and JVM unit tests in android/src/test/ — but neither feeds that number, the instrumented ones only run by hand, and no workflow runs the JVM ones yet (#169). It is still the riskiest part of the project -- every one of the last few defects came from there. On the client, only the pure modules are measured; screens and the renderers that talk to FFmpeg, the filesystem and the network are outside it on purpose, since including them would produce a number nobody could act on. - ~~Tests beyond the unit suites.~~ Done, as a set: Maestro flows for the five critical paths on a real emulator (#96), the full-stack smoke test (#99), the mobile/backend contract check (#100), the accessibility lint (#101), network-failure and upload-retry tests (#102), the nightly push check (#103), and a load test of the server render that found two concurrent renders kill the machine and led to RenderGate, which queues them instead (#98, #128). See testing.md. - ~~Spanish and English.~~ Done (#139). The app follows the phone's language — Spanish for a Spanish phone, English for anything else — and the server writes errors, pushes and emails in each person's own. - ~~Rate limiting.~~ Done: app/api/ratelimit.py covers login, registration, joining a circle and the upload routes. Reading is untouched. A startup guard also refuses to run in production on the development signing key. - ~~The rest of the auth story.~~ Done: refresh tokens, account-wide revocation, password reset by emailed code, and now deleting the account from inside the app -- which every store requires of anything that creates accounts, and which would have been an App Store rejection the first time iOS was submitted.

What deletion means here is written down in decisions.md: the person is scrubbed, the vlogs stay with the circles they were made for. - ~~A circle you can administer.~~ Done. Everything about a circle was fixed at the moment it was created: the cadence, the length, the invite code and who was in it. A group that chose three days and found it exhausting had to start again and abandon its history; a code that leaked let anybody in for ever; and an owner who left took the only account that could force a draw with them. The owner can now edit the settings, rotate the code, remove a member and hand the circle on, and leaving hands it on automatically. - ~~A deadline reminder.~~ Done: the vlogger is warned 24 h out. Before it, the only notification about a deadline was the one saying it had passed — somebody could collect strikes having never been told the clock was running. - ~~Somewhere to see past vlogs.~~ Done. The API always returned them; the app never asked. The history pages back through all of them rather than stopping at the latest twenty (#159). - ~~A way to keep your own copy of a vlog.~~ Done. A vlog only ever lived at playback_url — tied to the app, the account and the circle it was made for. Uninstall, sign out everywhere, or leave the circle, and nothing personal was left of it. "Guardar en el dispositivo" downloads the finished mp4 into the phone's own gallery, from the premiere and from the history. - ~~Silencing a circle without leaving it.~~ Done. Belonging to more than one circle used to mean every one of them pushed at full volume, or leaving outright. A member can now mute one circle's pushes for themselves alone — it touches nothing about whether the draw can land on them, whether they can record, or what anyone else in the circle receives. - ~~One push token per account.~~ Done. users.expo_push_token was a single column, so a phone and a tablet signed into the same account fought over it — whichever registered last silently took the other's place, and reinstalling did the same. push_tokens replaced it with a table: an account now gets pushed on every device it is signed into, not only the most recent one. - ~~Confirming an email belongs to whoever registered it.~~ Done. Registering never checked that an address was real or that it belonged to whoever typed it in; the only way anyone found out otherwise was a password reset they never asked for landing in somebody else's inbox. A code now goes out at registration, the same shape as a password reset and reusing its primitives — but it is informational only, by design: an unverified account uses the app exactly like a verified one. See decisions.md. - Judge the edit. Nobody has sat down with a finished vlog and decided whether the fades, the loudness matching and the four-second photo hold are right. It assembles and the numbers are fine; whether it looks good is a product judgement and it has not been made. Nor has anyone measured how long a full-length vlog actually takes on a mid-range phone. - Somebody other than the author using it. Still the largest untested thing in the project, and the only one that no amount of engineering closes. It is also the only way to verify a notification arriving on a phone that is not the one that sent it. - ~~A public bucket domain rather than r2.dev.~~ Done: video is served from vlogroulette-media.mintos.space, so Cloudflare's rate-limited development hostname is out of the path.

Deployment

Live since 2026-08-21: one Fly.io machine in Amsterdam, Postgres on Neon, video in Cloudflare R2 behind its public bucket domain. The whole loop has run against it from a real phone. The steps, and what running as exactly one machine implies, are in deployment.md.

Fly's own Managed Postgres was tried and dropped: the smallest plan is $38 a month, which buys reliability this does not need yet.

This replaced a Raspberry Pi behind a Cloudflare tunnel. The tunnel meant the origin could not be reached directly, which is why the rate limiter used to believe X-Forwarded-For; on a host with a public address that assumption is worth nothing, and the header to trust is now named explicitly by CLIENT_IP_HEADER.

Backups and error reporting are done -- scripts/backup.py on a systemd user timer, and Sentry, which is also where a failed backup reports. pg_dump exiting zero was, until now, the only evidence a backup was any good; whether it could actually be restored had been checked by hand exactly once, on 2026-08-21 (see deployment.md's "Restoring" section), which is a memory, not a safety net. scripts/restore_drill.py automates that check -- row counts, Alembic revision, foreign keys and sampled media, against a throwaway container -- on a schedule (.github/workflows/backup-restore-drill.yml) and by hand. The honest gap still left is where the copy lives: a laptop switched off for a fortnight backs up nothing for a fortnight, and nobody is actively watching the errors either. Both were written for the day the users are not just the person who can read the logs.

Later: product

Deliberately parked. None of them is blocked on anything now that the edit runs on the device; they are simply worth less than the items above.

  • ~~Reactions on a premiere.~~ Done, and it was promoted out of this list rather than picked off it. The vlogger published into silence: they recorded for days, the phone assembled, the circle watched, and the only thing that ever came back was an integer counting how many people had opened it. Five emoji and the viewers' names close that loop, and the summary when the premiere ends closes it again. Comments close it a third way, and used to do it in exactly the same silence reactions did: a short line under the premiere that nobody was told about unless they happened to reopen the circle. The vlogger is now pushed when one lands — not for reacting, which is cheap enough that a notification for every one would be noise, but a comment is a sentence somebody chose to write.
  • ~~Something for the audience to do.~~ Partly. Four days in five, for everybody but one person, this app was a countdown. The heartbeat's progress is now shown to the circle -- "Ana lleva 6 tomas" -- which is the cheap half. The rest is still open.
  • A second roulette, and what strikes are actually for. The idea, not yet designed: some turns come with a second spin that hands the vlogger something to do or to show in that vlog. A prompt, at random.

Strikes are what tilt it. Carrying none, the spin gives you something good; carrying strikes, it gives you a forfeit instead. That is the whole reason the counter exists, and it is a better answer than removal in both directions — missing a deadline costs you something inside the game rather than your place in the group, and the punishment is the joke rather than the end of it.

Nothing about the shape of this is settled: where the prompts come from, who can see yours before the premiere, whether a strike ever wears off. Written down because the strike count is live and currently does nothing, and somebody reading the code deserves to know what it is being kept for. - Streaks and standings on top of the existing strike count. - A music bed. The server render already mixes one in (render.py); the native engine, which does nearly every edit, does not. What is missing is licensing first, and then the native half. - Choosing how long each imported photo is held, rather than a fixed four seconds.

Known compromises

  • Gallery import contradicts the PRD, which rules out the camera roll to force authenticity. Added on request; imported items are tagged in the manifest so the decision stays visible.
  • expo-file-system/legacy. SDK 54 moved the classic API there and the app still uses it, because it is the only one exposing uploadAsync — the streaming upload that made raw takes survive a mobile connection.
  • expo-image-picker declares legacy storage permissions of its own (READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE). They only apply on Android 12 and below, where the system photo picker does not exist and the picker genuinely needs them. Removing them would break gallery import on older phones.
  • The scheduler runs in-process. One API instance only. Fine for the MVP, wrong the moment there are two.