Skip to content

fix(screen-recording): pick an ffmpeg that can actually encode, not just one that exists - #680

Open
filip131311 wants to merge 1 commit into
filip/platform-dep-hintsfrom
filip/ffmpeg-capability-probe
Open

fix(screen-recording): pick an ffmpeg that can actually encode, not just one that exists#680
filip131311 wants to merge 1 commit into
filip/platform-dep-hintsfrom
filip/ffmpeg-capability-probe

Conversation

@filip131311

@filip131311 filip131311 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes #621.

resolveBinary trusted the first command -v ffmpeg hit unconditionally and consulted FFMPEG_FALLBACK_PATHS only when ffmpeg was absent. A conda-forge build (--disable-gpl, no libx264) ahead of Homebrew on PATH therefore won every time, and the known-good build sat in the fallback list unreachable.

Reproduced

This host happens to be in the reported state by accident, which is what makes the bug insidious:

screen-recording-start udid=18BE573F-…
→ ffmpeg exited (code 8) before the recording started.
  stderr: Unrecognized option 'preset'. Error splitting the argument list: Option not found

command -v ffmpeg        → /opt/miniconda3/bin/ffmpeg   ← chosen, has no libx264
/opt/homebrew/bin/ffmpeg → full GPL build               ← FFMPEG_FALLBACK_PATHS[0], never reached

-preset is a libx264-private option, which is why a missing encoder surfaces as an unknown option.

⚠️ The fix the issue suggests does not work as written

The issue proposes "probe the resolved candidate (e.g. ffmpeg -hide_banner -h encoder=libx264) and fall through". Measured on both builds:

/opt/miniconda3/bin/ffmpeg -hide_banner -h encoder=libx264 ; echo $?   → 0
/opt/homebrew/bin/ffmpeg   -hide_banner -h encoder=libx264 ; echo $?   → 0

Both exit 0. ffmpeg reports the missing codec in its output, not its status. Implemented the natural way — try { await execFileAsync(...) } catch { next } — the fix would look right and change nothing. The probe reads output, and there is a test asserting exactly that (never consults the exit status).

Design decisions worth reviewing

Match the success header, not the failure text. Encoder libx264 [...] comes from ffmpeg's help formatter and has been stable for a decade; Codec 'libx264' is not recognized by FFmpeg. is prose any release may reword. Keying on the failure string would mean a future ffmpeg silently re-breaks recording — and it would break it for people whose setup works, the one direction this must never fail in.

-hide_banner is load-bearing, not cosmetic. Without it ffmpeg writes its build banner to stderr, and that banner's configuration: line contains the literal --enable-libx264. Since the verdict comes from stdout+stderr combined, dropping the flag would put a libx264-shaped string in front of any looser matcher. (The current regex survives it; the next person to relax it shouldn't have to find that out.) Related: -loglevel is deliberately not passed — measured, it does not gate help output either way, so it would add a version-dependent variable for nothing.

Everything inconclusive is usable. A timeout, a kill, or empty output all mean "no trustworthy answer", and the candidate is used anyway. The probe can only ever demote a build that positively said it lacks libx264, so on any host where recording works today ffmpeg still gets to speak for itself.

ARGENT_FFMPEG exists because the probe does. The probe introduces a failure mode that did not exist before — a false negative on a fork whose help output we don't recognise. An override that were itself subject to the probe would rescue nobody, so it is deliberately advisory: an explicitly pinned binary is honoured even when the probe doesn't recognise it (worst case the user gets ffmpeg's own error, exactly what they got before). It is never silently swapped for a different binary either — a pinned path that isn't there fails loudly rather than falling through.

A found-but-unusable ffmpeg is not "missing". The old message told a user with three ffmpegs installed to go and install ffmpeg. That case gets its own message and its own failure code, because screen-recording-start renders the code to the user verbatim. An EACCES binary counts as found for the same reason.

resolveBinary deleted, not extended. Its generic half duplicated commandOnPath — worse, its hand-rolled /bin/sh -c command -v could never match on Windows, where Android recording is reachable. Routing through commandOnPath also means the binary we validate is the binary we spawn, rather than resolving PATH twice and proving nothing about what runs.

Not cached. The tool-server has no idle shutdown by default (ARGENT_IDLE_TIMEOUT_MINUTES defaults to 0), so a cached "no usable ffmpeg" would outlive the user acting on the advice in our own error message. One ~33 ms probe per recording start, against a start path that already waits ~800 ms.

Verified end to end

Ran a tool-server built from this branch against the still-broken host:

screen-recording-start → {"status":"recording", …}          ← previously failed instantly
screen-recording-stop  → 87401-byte mp4

ftypisom · codec_name=h264 · 1206x2622 (native) · duration 1.03s (trimmed from 10s wall clock)
resolveFfmpeg() → { ok: true, path: '/opt/homebrew/bin/ffmpeg', origin: 'fallback' }

Tests

22 new cases in test/ffmpeg-resolver.test.ts, hermetic (node:child_process, command-on-path and node:fs/promises all mocked, so a real ffmpeg on the dev machine can't leak in): the reported host pinned exactly, exit-status-independence in both directions, stdout/stderr combining, the exact probe argv, fail-open on timeout/kill/partial output, dedup (same file via PATH+fallback, and two prefixes symlinked to one Cellar binary), ENOENT-vs-EACCES, no-caching, and all five override behaviours. Plus a new screen-recording.test.ts case for the distinct failure code, and the message builder asserted not to say "not found" to someone who has ffmpeg.

Mutation-verified: replacing the output check with an exit-status check fails 2 tests; making the override subject to the probe fails 1.

Full tool-server suite green (3109 passed / 298 files).

Notes for the reviewer

  • Conflicts with feat(screen-recording): record through simulator-server instead of host ffmpeg #587 on two same-line doc edits — screen-recording-start.ts:80 and argent-screen-recording/SKILL.md:47, both of which feat(screen-recording): record through simulator-server instead of host ffmpeg #587 also replaces — plus its @@ -281,13 @@ hunk in capture.ts, whose trailing context includes if (!ffmpeg) {. All mechanical. resolveFfmpeg keeps its name and stays exported from ./watermark specifically so feat(screen-recording): record through simulator-server instead of host ffmpeg #587's (and main's) vi.mock seam still disarms it.
  • ARGENT_FFMPEG is undocumented in argent-private/docs/environment-variables.md — that file is in a submodule, so documenting it there is a separate PR. That table already omits ARGENT_TRACE_PROCESSOR_WASM and ARGENT_NATIVE_DEVTOOLS_DIR, and nothing enforces completeness.
  • Only libx264 is probed, not the mp4 muxer or the filter graph — it is the single GPL-gated capability that is actually missing in the wild, and ffmpegArgs is the only ffmpeg invocation in the repo (trimStatic is frame-dropping in the Node pump, not a second pass).
  • Windows Android recording was effectively dead before this (POSIX-only resolution) and now resolves; the escalation ladder still degrades there, since child.kill("SIGINT") is an abrupt terminate on Windows. The new test is added to the curated windows-e2e.yml list.

Stacked on #652 (filip/platform-dep-hints). Both rewrite the ffmpeg-missing message in screen-recording/capture.ts and the same argent-screen-recording/SKILL.md bullet. The rebase folds #652's per-platform install hints (apt on Debian/Ubuntu, RPM Fusion on Fedora) into this PR's ffmpegUnavailableMessage, including the Fedora ffmpeg-free case in the "found but cannot encode" branch — which is exactly the failure this PR detects. Review only the top commit. GitHub retargets it to main when #652 merges.

…ust one that exists

Resolution trusted the first `command -v ffmpeg` hit unconditionally and only
consulted the fallback list when ffmpeg was ABSENT. A conda-forge build
(`--disable-gpl`, so no libx264) ahead of Homebrew on PATH therefore won every
time, and every recording died with `Unrecognized option 'preset'` — `-preset`
being a libx264-private option — while a working build sat in the fallback list,
unreachable.

Each candidate is now asked whether it can encode, and the first that says yes
wins.

The probe has to read ffmpeg's OUTPUT: `-h encoder=libx264` exits 0 whether or
not the encoder exists, so the obvious implementation — try/catch around
execFile — would look like a fix and change nothing. It matches the success
header rather than the failure sentence, because the header comes from ffmpeg's
help formatter and has been stable for a decade while the failure text is prose
any release may reword; keying on the failure string would silently re-break
recording, and in the direction that breaks setups which currently work.
`-hide_banner` is load-bearing rather than cosmetic: without it the build banner
goes to stderr carrying the literal `--enable-libx264`.

Everything inconclusive — a timeout, a kill, no output at all — is treated as
usable, so the probe can only ever demote a build that positively said it lacks
libx264. On any host where recording works today, ffmpeg still gets to speak for
itself.

Two things the failure path was getting wrong:

- "ffmpeg was not found on PATH. Install it" was shown to someone with three
  ffmpegs installed, which is what sent the reporter looking in the wrong place.
  That case now has its own message and its own failure code, since the code is
  rendered to the user verbatim. An EACCES binary counts as found, not missing,
  for the same reason.
- There was no way to override the choice. ARGENT_FFMPEG adds one, and the probe
  is deliberately ADVISORY for it: the probe's one new failure mode is a false
  negative on a build whose help output we don't recognise, and an escape hatch
  subject to the filter it exists to escape would rescue nobody. A pinned binary
  is never silently swapped for a different one either — it fails loudly instead.

`resolveBinary` is deleted rather than extended. Its generic half duplicated
`commandOnPath`, worse: the hand-rolled `/bin/sh -c command -v` could never match
on Windows, where Android recording is reachable. Going through `commandOnPath`
also means the binary we validate is the binary we spawn, instead of resolving
PATH twice and proving nothing.

Not cached on purpose — the tool-server has no idle shutdown by default, so a
cached "no usable ffmpeg" would outlive the user acting on our own error message.
One ~33ms probe per recording start, against a path that already waits ~800ms.

`resolveFfmpeg` keeps its name and stays reachable from ./watermark so the
existing test mock seam still disarms it.

Verified end to end on a host in the reported state (conda's ffmpeg first on
PATH): recording now produces a valid 87KB h264 mp4 at native resolution where it
previously failed instantly.

Fixes #621
@filip131311
filip131311 force-pushed the filip/ffmpeg-capability-probe branch from 6d40425 to 5a3f896 Compare August 3, 2026 07:51
@filip131311
filip131311 changed the base branch from main to filip/platform-dep-hints August 3, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant