Skip to content

feat: deprecate the screenshot stream formats on iOS, keep them on Android - #138

Merged
onevcat merged 8 commits into
feat/android-h264-mpegtsfrom
feat/deprecate-ios-screenshot-formats
Sep 11, 2026
Merged

onevcat merged 8 commits into
feat/android-h264-mpegtsfrom
feat/deprecate-ios-screenshot-formats

Conversation

@onevcat

@onevcat onevcat commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

stream-video --format mjpeg / raw / ffmpeg drive a screenshot loop
that re-encodes every frame. On a simulator that is now strictly worse
than --format h264, measured over the same 6 s on an iPhone 17 Pro:

h264 144 frames 1.33 MB
mjpeg 24 frames 11.2 MB
raw/ffmpeg 26 frames 92.8 MB

h264 works on every booted simulator, so no state remains in which the
loop is the better choice. The three formats now warn once on stderr and
say so in --help; removal comes in a later release.

Android keeps them and does not warn. adb screenrecord is unavailable on
some devices, and there the screencap loop is the only way to stream at
all — removing the formats would leave those devices able to record but
not stream. The asymmetry is a real capability difference, and a unit test
pins it so it does not get "tidied up" into consistency later.

This reverses the plan in #134, which had assumed the formats could be
retired globally once Android stream-video gained the automatic
screencap fallback that record-video has. That fallback turns out not
to be worth building: record-video's is transparent because the output
is a file either way, whereas a stream's container would change under a
consumer that has already started decoding — MPEG-TS has no stream type
for MJPEG, so there is no way to keep the wrapper stable. Failing with a
clear message, which is what it does today, is the better behaviour.

bgra is untouched: raw unencoded pixels are not something H.264
substitutes for.

Refs #134

Signed-off-by: onevcat onevcat@gmail.com


Stack created with GitHub Stacks CLI • Give Feedback 💬


Verification (top of stack)

  • make test — 1394 pass, including a new test pinning that the deprecation is iOS-only
  • iOS E2E StreamVideoTests + RecordVideoTests — both suites pass
  • make e2e-android — 10/10 suites, confirming Android still accepts the formats without warning
  • Behaviour checked by hand on a live simulator and emulator:
surface --format warning
iOS mjpeg yes
iOS raw yes
iOS h264 no
iOS bgra no
Android mjpeg no (load-bearing there)

CI note: this repo triggers workflows on pull_request: branches: [main], so a stacked PR only gets the DCO check. The above was run locally; CI will cover it once the lower layers merge.

Stacked on #136, which is stacked on #133.

Refs #134 — the issue tracks removal in a later release, so it stays open.

@onevcat
onevcat force-pushed the feat/deprecate-ios-screenshot-formats branch 5 times, most recently from 74a9287 to 8e7135c Compare September 8, 2026 01:31
@onevcat

onevcat commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Round 4 — no P0/P1 remains; review converged

The reviewer accepted the timeline trade-off after seeing the measurement, and corrected my reasoning for it, which was worth more than the finding itself.

Correction I took: my ffplay model was wrong

I had written that ffplay's master clock is "driven by the last picture shown". For a video-only input it actually defaults to an external clock. The real mechanism is that ffplay holds the current picture for a duration derived from the next picture's PTS — so a wall-clock gap keeps the stale picture on screen for exactly that long, and sampling the transport clock does not change that schedule.

Same conclusion, correct cause. This matters because that comment is what a future maintainer will read when they reconsider maxFrameGap and try the obvious wall-clock design again. I also dropped the claim that ffmpeg and browsers "schedule from PTS" the same way — the ffmpeg CLI is not a presentation scheduler, and MSE support for MPEG-TS is implementation-dependent. The comment now cites only the path that was actually measured.

[P2] The direct surfaces still described the old contract — accepted, fixed

sim-use android stream-video --help said "native screenrecord passthrough" and its summary printed bytes only, while the top-level verb printed frames and the changelog claimed a frame count. One stream, three descriptions. Now: MPEG-TS re-containering without re-encoding, and frames reported, matching everything else. The iOS command's abstract said "using screenshot capture", which stopped being true when h264 landed; now format-neutral.

[P2] Retained iOS screenshot formats crash on pipe hangup — rejected, measured

Measured all three: mjpeg, raw and ffmpeg exit on SIGPIPE (signal 13) with no ObjC exception, which is the ordinary Unix outcome for | head. The NSFileHandleOperationException the finding describes only occurs when SIGPIPE is ignored, and only the Android sink does that — for itself, in its own initialiser. Nothing to fix, and moving the sink into SimUseCore would add a dependency edge for no behaviour change.

Simplification pass

cut effect
tableInterval + lastTablesTime + the per-append table re-emission the sole caller already emitted tables every 40 ms, so that branch was unreachable in production; two entry points collapse into one emitProgramTables()
top-level ExecutionResult.format populated, never read — --json is refused here and the summary picks on counters alone
8 lines of architecture history in the iOS record command invalidated by this stack; the type names below it say more
duplicate test latch, misplaced test comment —

Net 47 lines lighter. MPEGTSStreamWriter is 62 lines of code where it was 76.

Kept deliberately, as advised: the write-ordering rationale, the adaptation-only continuity note, the clockReference(at:) primitive, and both concurrent-writer tests — one proves a race is permitted, the other that byte order actually holds. Neither subsumes the other.

Verification

unit 1406 pass · Android E2E 10/10 · iOS E2E stream + record both pass · live stream 78 pictures, nb_streams=1 pcr_pid=256 codec_name=h264, ffmpeg demuxing with no warnings

Review summary across four rounds

Twelve findings, all real. Fixed: PSI section syntax, PCR-only packet shape, concurrent write ordering, an uninterruptible blocked write, PTS/PCR equality, keep-alive clock restatement, a stale architecture comment, an imprecise causal explanation, direct-surface help and summary drift, and three documentation claims the code did not support. Two rejected with measurement. Two of the defects were introduced by earlier rounds' own fixes — which is the case for having run more than one round.

One process note: I briefly damaged the local branch layout while moving this commit between layers (a cherry-pick conflicted and I reset the wrong branch). Recovered from the remote; ancestry re-verified — #133 → #136 → #138 — and all three layers confirmed free of conflict markers before this push. This commit spans all three layers, so it sits on the top one rather than being split three ways.

@onevcat

onevcat commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Round 5 — clean confirmation, plus one thing the confirmation missed

The reviewer re-verified all four items and reported no P0/P1/P2 remaining, accepting the pipe-hangup rejection outright: with SIGPIPE at its default, the iOS FileHandle path terminates the ordinary Unix way before it can reach the Foundation exception, which requires SIGPIPE to be ignored.

While checking point 2 myself I found the confirmation had verified the behaviour and not the prose. Android's ExecutionResult doc still read:

The JPEG formats count frames; h264 is a byte-passthrough with no frame notion, so it reports bytes instead.

The code four hundred lines below sets framesStreamed: pipeline.framesWritten. That is the same class of drift the round-4 finding was about — I had fixed the help text and the summary and left the type's own documentation asserting the opposite. Also fixed in 503d85a: the sentence describing the h264 engine had lost its head in an earlier edit ("re-containered into MPEG-TS (no re-encoding) passthrough: variable frame rate…"), and two comments recorded that the behaviour used to differ, which is changelog material rather than a comment.

The framesStreamed == 0 fallback stays and now says why it exists: a still screen under a variable-frame-rate source can genuinely produce no picture, and bytes are the only thing left to report about such a run.

Where the stack stands

#133 → #136 → #138, ancestry verified, no conflict markers in any layer. make build clean, 1096 tests in 200 suites passed (xcsift counts the same run as 1406 — it counts individual cases including parameterised expansions; both figures are this run).

Five rounds, thirteen findings, all real. Eleven fixed, two rejected with measurement — and two of the eleven were introduced by an earlier round's own fix, which is the argument for having kept going past round two.

@onevcat

onevcat commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Final simplification pass

Went looking for code to cut and found comments that argued for the opposite of what ships — which is worse than verbose.

MPEGTSMuxer's header claimed it emits "no PCR-only packets." That is only true because nothing calls clockReference(at:), whose own doc argued for emitting them through an idle stretch: "the clock has to keep advancing even when the encoder emits nothing." That is precisely the design round 3 asked for, which I implemented, measured, and rejected. So the primitive the reviewer asked me to keep was sitting there recommending the losing option. It now says nothing emits these today, and points at MPEGTSStreamWriter.maxFrameGap for the measurement — a maintainer reaching for it finds the evidence instead of an argument.

maxFrameGap and emitProgramTables both derived the same fact — that a timeline advancing only on a picture has no honest clock to send in between — and both cited the same ffmpeg-reports-corrupt evidence. The derivation now lives with the constant that causes it; the evidence lives with the method that would otherwise be tempted to send a clock. Each fact stated once, where someone would look for it.

Also inlined a read-once local in packetize and dropped a comment sentence that restated the line under it.

MPEGTSStreamWriter is 150 lines where it was 155, comments 77 where they were 82, code unchanged at 62. MPEGTSMuxer down one line. Small numbers — the honest report is that the earlier pass already took the slack, and what remains is standard-clause citations and measured decisions. Cutting further would delete the reasons.

Verification on this HEAD

make build clean · 1096 tests in 200 suites passed · Android E2E 10/10 against the live emulator, AndroidStreamVideoTests and AndroidRecordVideoTests included.

One thing stated precisely rather than glossed: I also ran an ad-hoc stream-video --format h264 smoke and it produced a single picture. That is not a regression — the emulator currently running here has a visible window, which is the documented ~1-frame condition (CLAUDE.md records the measurement; video work needs -no-window). I did not restart it to get a prettier number, so the 78-picture live figure quoted earlier belongs to the earlier HEAD, not this one. The only code change since then is an inlined local, and the suite parses the muxer's actual bytes for PSI syntax, PCR placement, packet shape and continuity — stronger coverage for that change than a demux smoke would be.

onevcat and others added 8 commits September 9, 2026 17:23
…droid

`stream-video --format mjpeg` / `raw` / `ffmpeg` drive a screenshot loop
that re-encodes every frame. On a simulator that is now strictly worse
than `--format h264`, measured over the same 6 s on an iPhone 17 Pro:

  h264     144 frames    1.33 MB
  mjpeg     24 frames   11.2 MB
  raw/ffmpeg 26 frames   92.8 MB

`h264` works on every booted simulator, so no state remains in which the
loop is the better choice. The three formats now warn once on stderr and
say so in `--help`; removal comes in a later release.

Android keeps them and does not warn. `adb screenrecord` is unavailable on
some devices, and there the `screencap` loop is the only way to stream at
all — removing the formats would leave those devices able to record but
not stream. The asymmetry is a real capability difference, and a unit test
pins it so it does not get "tidied up" into consistency later.

This reverses the plan in #134, which had assumed the formats could be
retired globally once Android `stream-video` gained the automatic
`screencap` fallback that `record-video` has. That fallback turns out not
to be worth building: `record-video`'s is transparent because the output
is a file either way, whereas a stream's container would change under a
consumer that has already started decoding — MPEG-TS has no stream type
for MJPEG, so there is no way to keep the wrapper stable. Failing with a
clear message, which is what it does today, is the better behaviour.

`bgra` is untouched: raw unencoded pixels are not something H.264
substitutes for.

Refs #134

Signed-off-by: onevcat <onevcat@gmail.com>
The notice pointed at `ffplay -f mpegts -probesize 32 -fflags nobuffer -`,
which does not open an Android stream and is the command the previous
commit replaced everywhere else.

Signed-off-by: onevcat <onevcat@gmail.com>
…eight

Round 4 of review found no P0 or P1 and accepted the timeline trade-off.
This lands the two documentation/UX findings, one correction to my own
reasoning, and the simplification pass.

**The comment recording the timeline decision was imprecise.** I had
written that ffplay's master clock is "driven by the last picture"; for a
video-only input it actually defaults to an external clock. The mechanism
is that ffplay holds the current picture for a duration derived from the
*next* picture's PTS, so a wall-clock gap keeps the stale picture on screen
for exactly that long, and sampling the transport clock does not change
that schedule. Same conclusion, correct cause — and this is the reasoning
a future maintainer will use when reconsidering the cap, so it has to be
right. Also dropped the claim that ffmpeg and browsers "schedule from PTS"
the same way: the ffmpeg CLI is not a presentation scheduler, and MSE
support for MPEG-TS is implementation-dependent. The comment now cites
only the measured ffplay path.

**The direct Android surface still advertised byte passthrough.** Its
`--help` said "native screenrecord passthrough" and its summary printed
bytes only, while the top-level verb printed frames and the changelog said
h264 reports a frame count. Same stream, three descriptions. It now says
MPEG-TS re-containering without re-encoding and reports frames, matching
the other surfaces. The iOS command's abstract said "using screenshot
capture", which stopped being true when h264 landed; it is now
format-neutral.

**Rejected: that the retained iOS screenshot formats crash on pipe
hangup.** Measured instead — `mjpeg`, `raw` and `ffmpeg` all exit on
SIGPIPE (signal 13) with no ObjC exception, which is the ordinary Unix
outcome for `| head`. The `NSFileHandleOperationException` the finding
describes only occurs when SIGPIPE is ignored, and only the Android sink
does that, for itself. Nothing to fix.

**Cuts.** `MPEGTSStreamWriter` loses `tableInterval` and `lastTablesTime`:
the only caller emitted tables every 40 ms anyway, so the per-append
re-emission was unreachable in production and the two entry points
collapse into one `emitProgramTables()`. The top-level `ExecutionResult`
loses `format`, which was populated and never read (`--json` is refused on
this command and the summary picks on the counters alone). The iOS record
command loses eight lines of architecture history that this stack had
already invalidated. A test comment moved to the test it describes.

Net 47 lines lighter, and `MPEGTSStreamWriter` is 62 lines of code where
it was 76.

Kept, as the reviewer advised: the write-ordering rationale, the
adaptation-only continuity note, the `clockReference(at:)` primitive, and
both concurrent-writer tests — one proves a race is permitted, the other
that byte order holds.

Verified: unit 1406 pass; Android E2E 10/10; iOS E2E stream + record both
pass; live stream 78 pictures with program association intact and ffmpeg
demuxing with no warnings.

Signed-off-by: onevcat <onevcat@gmail.com>
The result type still documented h264 as a byte passthrough with no frame
notion, which the re-containering path contradicts, and the sentence
describing the engine had lost its head in an earlier edit. State the
current contract in both, and drop the two before/after notes that only
recorded that the behaviour used to differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
Three comments argued for the opposite of what ships. The muxer's header
said it emits no PCR-only packets, which is only true because nothing
calls the primitive that builds one; `clockReference` argued for emitting
them through an idle stretch, which is the design the stream writer
measured and rejected. Point it at that evidence instead, so anyone
reaching for it finds the measurement rather than an argument.

`maxFrameGap` and `emitProgramTables` also both derived why an idle
timeline has no clock to send and both cited the same ffmpeg evidence.
The derivation belongs to the constant that causes it; the evidence
belongs to the method that would otherwise be tempted to send one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
… cheatsheet

The README said `-analyzeduration 0` was required for the ffplay preview to
open on Android. Measured on a moving emulator screen, the open takes ~1 s
with the flag and ~1 s without it: zero selects ffmpeg's default analysis
window rather than disabling it, and what bounds the open is
`-probesize 32768`, because a still Android screen sends only program
tables, which never count towards a media-time budget. The command drops
the no-op flag everywhere it is printed (README, changelog, stderr hints,
the deprecation notice), the explanation now credits `-probesize`, and it
states the consequence that matters to a user: on a still Android screen
the window appears only once the device moves.

The skill's cheatsheet still described `--format h264` as Android-only
behind `ffplay -f h264 -`, both of which this stack made wrong; it now
shows the cross-platform MPEG-TS command and marks the screenshot formats
deprecated on iOS. Two header comments that still said "passthrough" and
"minus the muxer" for the Android leg describe the transport-stream writer
instead.

Signed-off-by: onevcat <onevcat@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…th iOS

`StdoutStreamSink` set `O_NONBLOCK` on stdout so a full pipe would surface
as EAGAIN. That flag lives on the open file description, not the
descriptor, so it leaked: stdout on a terminal left the user's shell
non-blocking after exit, and under `2>&1` the summary write to stderr hit
EAGAIN inside `FileHandle` and aborted the process (SIGABRT, verified with
a slow reader). Every Android stream format was affected, since they share
the sink.

The sink now leaves the flags alone. It waits for `POLLOUT` in 100 ms
slices, checking cancellation between them, and writes at most `PIPE_BUF`
bytes per call: a pipe reports writable only when that much room exists,
and a write no larger than that completes without blocking once it does.
A stalled consumer therefore still cannot pin the writer, which the
existing test keeps proving against a blocking descriptor; a new test pins
that the flags are untouched, and another that a slow reader receives
every byte in order through the chunked writes.

iOS had the same hang and no sink at all. `stream-video --format h264` and
`bgra` copied the stream through idb's `FBFileWriter.syncWriter`, which
blocks in `write(2)` on the encoder's callback thread; with a consumer that
stopped reading, `stopStreaming()` waited behind it and Ctrl-C needed
SIGKILL (sampled: the VideoToolbox callback parked in `write`, the main
thread in `semaphore_wait`). The sink moves to `SimUseVideo`, and a small
`FBDataConsumer` adapter feeds it, so both platforms share one
interruptible path. A consumer that closes the pipe now ends the iOS
stream in an orderly way instead of killing the process with SIGPIPE.
An iOS E2E test pins the cancellation: a pipe nobody drains, SIGINT after
it fills, exit within seconds.

Signed-off-by: onevcat <onevcat@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`stream-video` inherited the screenshot loop's default of 10 fps, so the
documented preview command delivered a third of the rate the README and
changelog quote for `h264`: measured 74 frames in 7.3 s at the default
against 221 in 7.4 s with `--fps 30`. `h264` shares `record-video`'s
encoder, whose default is 30, so `--fps` is now optional and resolves per
format: 30 for h264, 10 for the screenshot formats, which cannot sustain
more. The top-level verb forwards the option unresolved so the same rule
applies there. Help text on both surfaces states the split, and the iOS
help no longer claims only `bgra` reports no frame count (h264 does not
either).

Signed-off-by: onevcat <onevcat@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@onevcat
onevcat force-pushed the feat/deprecate-ios-screenshot-formats branch from a71955e to 5bd86d8 Compare September 9, 2026 08:23
@onevcat
onevcat marked this pull request as ready for review September 9, 2026 08:28
@onevcat
onevcat merged commit dd73637 into main Sep 11, 2026
4 of 7 checks passed
@onevcat
onevcat deleted the feat/deprecate-ios-screenshot-formats branch September 11, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant