Skip to content

Things that will confuse you

Every entry here cost real hours. They are the kind of thing that is obvious once you know and invisible before, and none of them can be worked out by reading the code, because the code is what looks correct.

Video

MediaCodec has already rotated your frame. A phone records landscape and stores a rotation-degrees in the track format. It is tempting to read that and rotate in the shader. Do not: the decoder folds it into the SurfaceTexture transform before you see anything, so rotating again applies it twice.

This one hid for three attempts, because 2×90 and 2×270 both come out as 180 — so footage from the front and back cameras appeared to be wrong in opposite directions, which looks like a sign error and is not. The diagnostic that settles it is in VideoPipeline.kt: it logs the angle it read, and the angle was always right.

FFmpeg's rotation sign is not Android's. When hand-authoring a display matrix with FFmpeg to fake a "recorded at 90°" test fixture, what ffprobe reports back as rotate=90 is the opposite matrix from the one MediaMetadataRetriever/MPEG4Extractor read as 90° on an actual Android decoder -- the two tools use opposite sign conventions for the same clockwise/counter-clockwise turn. clip_90deg.mp4 and clip_270deg.mp4 in the vlog-editor androidTest fixtures had exactly this bug for a while (#135): the files were swapped from the day they were generated, and it only surfaced once the instrumentation suite finally ran against a device. The fix is re-tagging the fixture, not touching the module -- if you write a raw tkhd matrix by hand, verify what an Android decoder reads back before trusting the filename.

Pin the pixel format everywhere. Left to itself, xfade will happily negotiate yuv444p, which decodes fine on a computer and plays as coloured smears on an Android phone with perfect audio. format=yuv420p, -pix_fmt, -profile:v and -level:v are all pinned in app/services/render.py for that reason. Same for audio: loudnorm changes the sample rate, so an aresample follows it.

The recorded duration is longer than the file. A stopwatch around recordAsync measures from asking the recorder to start to it saying it stopped, and the camera does not begin capturing at the front of that. Measured on a real device the gap ran between 0.4 and 1.5 seconds per segment. Durations are read back from the container with probeDuration, not timed, or the time budget charges people for footage that does not exist.

A take is not a file. Flipping the camera closes one file and opens another inside the same take. groupIntoTakes puts them back together; anything that treats a clip as a take will show the wrong count and let somebody undo half of what they recorded.

getCapabilities() saying true is not the same as the device actually managing it. The Android module no longer hardcodes its answer: EngineCapabilities.kt walks the codec list once and says no on a phone with no AVC encoder for the output size or no AAC encoder (#161). That only removes the devices that were never going to work. MediaCodec also has real, device-specific failures that surface halfway through an encode (Xiaomi/Redmi, Oppo, some MediaTek are the documented ones), and no capability check can see those — only catch them, at runtime, via the fallback in src/edit/nativeFallback.ts. Do not remove that fallback because the check exists; a true is a precondition, not a measurement.

One decoded stream at a time is the budget, not a suggestion. A single ffmpeg filter_complex that opens every clip segment as a simultaneous input — the natural way to write an N-way xfade chain — OOM-kills the production render machine (shared-cpu-1x, then 1 GB) on any vlog with more than one clip, no concurrency needed. _concat_with_transitions in app/services/render.py folds segments pairwise instead, exactly to keep any one ffmpeg invocation down to two streams. A future "simplification" back to one big filter graph would look cleaner and silently reintroduce this.

That budget is per render, not per machine — a second concurrent one still has nowhere to go. Even with the fix above, one render alone already uses most of 1 GB; two of them at once, any size, both die to OOM. Production now runs on 512 MB (see limits.md), so this budget is tighter than the one it was measured against. There is nothing about clip count that fixes this — it is about how many real ffmpeg encodes are in flight on the box at once. render_fallback now serialises the actual render() call through app/services/render_gate.py (settings.max_concurrent_renders, default 1): a second request queues instead of racing the first. If you're touching that endpoint, do not remove or bypass the gate to "let requests through faster" — that's exactly the change that reintroduces the OOM.

Builds

EAS does not upload gitignored files, warns about it, and builds anyway. The resulting app is missing whatever that file configured, and the only symptom is a crash at runtime. google-services.json was the case that bit; it now travels as an EAS file environment variable. The warning still appears locally, where the real file does exist and is still untracked — there it is a false positive, so verify the built artifact rather than cancelling the build.

A key at the top of a mobile/locales/*.json file goes to Android too. Expo's locales writes every top-level key into both the iOS InfoPlist.strings and an Android values-b+<lang>/strings.xml. The permission prompts are iOS keys (NSCameraUsageDescription...) with no default Android string, so Android's release lint fails the build on ExtraTranslation -- only in assembleRelease, which nothing on this machine runs. Keep iOS keys under "ios" and Android ones under "android".

Kotlin compiles locally only if you set the SDK up. It is not there by default, and without it a Kotlin mistake surfaces on EAS, nine minutes later. The one-time setup in development-build.md installs it in your home directory and scripts/build-android.sh builds with it; there is still no Xcode, so Swift is never compiled here. Either way, run npx expo-doctor before pushing a build, and let the native.yml CI job be the thing that catches compile errors.

minSdk is 24 and it is not obvious. MediaFormat.containsKey is API 29+, compiles cleanly and fails at runtime on older devices. native.yml now runs lintDebug on the module, whose NewApi check flags a call above minSdk at build time rather than on somebody's older phone. With the SDK set up the same ./gradlew :vlog-editor:lintDebug runs locally, from mobile/android after a prebuild.

Expo Go is no longer an option. It ships one SDK version — the latest — and this project is on 54. Use the development build.

Development and preview builds replace each other. Same package name, so installing one uninstalls the other's binary while keeping its data: you end up with a stored session pointing at a server that has never heard of it. The client renews on a 401 and, when the refresh is refused too, signs you out — so what you see is the login screen for no apparent reason, not your data. Log out first, or expect that.

JavaScript and Expo

expo config serialises an absent value as {}, which is truthy. So extra.thing || fallback accepts it and hands you an empty object where a string should be. This silently disabled error reporting; src/state/dsn.ts exists because of it. Never use truthiness to test a config value here.

LayoutAnimation applies to the next layout commit, and only that one. If the state change is behind an await, the commit it was supposed to animate has already happened. Two attempts died here before the reorder animation was rewritten with Animated and a per-row translation.

Derive ids from the highest existing one, not the count. Recording three takes, deleting the second and recording again produces a second clip-002 if you name by length, and the new file lands on top of a live one.

A ScrollView wrapping Screen needs its own flex: 1, not the other way round. Screen carries flex: 1 on its own root View — right for a screen that fills the navigator, wrong the moment it becomes a ScrollView's child instead. A flex: 1 view inside a content container with no bounded height of its own has nothing to grow against, and the practical result is tall content getting clipped rather than scrolling. The fix is on the ScrollView, not Screen: style={{ flex: 1 }} so it fills what its own parent gives it, plus contentContainerStyle={{ flexGrow: 1 }} so short content still fills the screen instead of collapsing to its own height. Eight screens carried this backwards for a while — the earlier fix that made them scrollable at all did not go far enough, which is why src/__tests__/screens now asserts the FlatList/SectionList/ScrollView on a screen actually has a scroll viewport rather than trusting the JSX to say so.

A string on screen goes in both dictionaries, or tsc fails. en.ts is typed against es.ts, so a key added to one and not the other does not build -- which is the point. What the compiler cannot see is a literal typed straight into a screen: it builds, and shows Spanish to everybody. Every user-visible string goes through t from @/i18n.

The Maestro flows select buttons by their Spanish label. The app follows the phone, and an emulator boots in English, so a build for Maestro has to be pinned with EXPO_PUBLIC_LANGUAGE=es (e2e-mobile.yml does) or the first tapOn fails. Pinned in the build rather than by changing the emulator's locale, which would also change the system dialogs the flows tap through.

Backend and infrastructure

Write an HTTPException detail in English, and add it to ERRORS_ES. The exception handler in app/main.py translates from that table; a detail missing from it reaches a Spanish phone in English. test_i18n.py scans app/ for literal details and fails on any the table lacks. It also fails on a detail built at runtime (an f-string, str(exc)): that cannot be looked up, and it used to hand the phone whatever language the exception was in, plus SMTP, Expo or ffmpeg internals. Log the exception and raise a fixed sentence.

Never trust X-Forwarded-For. Proxies append to it, so its first entry is whatever the caller chose to send. Reading it turns a per-address rate limit into no rate limit at all. CLIENT_IP_HEADER names the one header the proxy actually sets and nothing else is consulted.

boto3 signs a checksum header your client will not send. Since botocore 1.36 uploads get a CRC32 by default, and the presigned URL covers the headers that declare it — but the phone doing the plain PUT knows nothing about them, so R2 rejects the signature. request_checksum_calculation="when_required" is not optional here.

The presigned PUT signs Content-Length — keep it the file's real size. create_upload_url puts the declared size_bytes in the signature so R2 refuses a body of any other size (#143). That only works because the phone declares the exact size of the file it then uploads and lets its HTTP stack set the header; a client that sends a different number, or streams the body chunked with no length, gets a signature mismatch from R2. scripts/r2_check.py exercises this against the real bucket.

Managed Postgres hands out postgres://, which SQLAlchemy 2.0 rejects. Normalised in app/core/config.py so the string can be pasted from a dashboard.

Use Neon's direct connection, not the pooled one. psycopg 3 prepares statements on its own, and PgBouncer in transaction mode does not support that. The failure arrives later and looks unrelated.

Fly creates two machines by default and min_machines_running will not stop it. That is a floor, not a ceiling. Two machines means two schedulers and a turn drawn twice, silently. Deploys must pass --ha=false.

Expo answers 200 to a push it is refusing. The verdict is in the response body, one ticket per message, not in the status code. Both paths read it now: send_now turns a refusal into an error for scripts/push_check.py, and the everyday send posts in batches of 100 and returns the tokens that came back DeviceNotRegistered, which deliver() then deletes (#147). Every other refusal is only logged, and nothing on that path ever raises — a failed notification must never stop a vlog publishing. Only the tickets are read, not Expo's later delivery receipts.

pg_dump must not be newer than the server. This machine has 18 and the server runs 16; a dump from the newer one produces a file the older server cannot read back, which you discover at the worst possible moment. scripts/backup.py asks the server its version and dumps through the matching Docker image.

Two general lessons

"It works here" is not "it works on the phone." The colour smearing, the rotation and the codec profile all decoded perfectly on a laptop. The only verification that counts is on the target — which is why scripts/r2_check.py uploads the way the phone does, and why the APK gets unzipped and inspected rather than trusted.

Instrumentation can break what it measures. Twice. Engine notes were computed at render time and then dropped because the render was no longer the thing being published. And a diagnostic evaluated transformTurns(surfaceTexture!!) three lines before the SurfaceTexture existed, which threw, which skipped every video take, which produced vlogs containing only the still photos. If a measurement changes behaviour, suspect the measurement.