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.