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.