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.

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.

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.

Native code is not compiled on this machine. There is no Android SDK and no Xcode here, so a Kotlin mistake surfaces only on EAS, nine minutes later. 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. Check the API level of every framework call in the native module.

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 does not handle 401 yet, so it looks like the app is broken. Log out first.

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.

Backend and infrastructure

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.

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, not the status code. send_now reads it; the everyday send deliberately does not, because a failed notification must never stop a vlog publishing.

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.