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.
- 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.jsondoes 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.
- The native renderer. Done, and running on real hardware.
mobile/modules/vlog-editoris a local Expo module: on AndroidMediaExtractorandMediaCodecwith an OpenGL composite in between, on iOSAVMutableComposition.
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.
- 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) andAVFoundation(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.ymlruns on every pull request and every push tomain:ruffandpytestfor the backend;expo-doctor,tsc,npm run lint,jestandnpm run check:contractfor the app; asmokejob 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 anypip-auditfinding 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 exposinguploadAsync— the streaming upload that made raw takes survive a mobile connection.expo-image-pickerdeclares 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.