Skip to content

feat: record-video captures real H.264 streams instead of screenshot polling#52

Open
subdiox wants to merge 2 commits into
lycorp-jp:mainfrom
subdiox:feat/record-video-h264-stream
Open

feat: record-video captures real H.264 streams instead of screenshot polling#52
subdiox wants to merge 2 commits into
lycorp-jp:mainfrom
subdiox:feat/record-video-h264-stream

Conversation

@subdiox

@subdiox subdiox commented Jul 17, 2026

Copy link
Copy Markdown

Summary

record-video was capped around ~8-10 fps because it polled screenshots and re-encoded every frame. It now captures a real H.264 stream and muxes it straight into the MP4 (passthrough — no re-encode).

  • iOS drives FBSimulatorVideoStream in eager H.264 mode at a constant --fps (1-60, default 30). The elementary stream carries no timestamps, so frames are laid out at exactly 1/fps — the requested rate is honored and playback is smooth (measured uniform 16.7 ms frame spacing at 60 fps).
  • Android pipes adb screenrecord --output-format=h264 at the device's native variable frame rate (measured ~50 fps under motion, vs ~7-8 before). --fps is ignored there; --quality/--scale map to bitrate/size. Recordings past the API < 34 per-invocation limit are stitched across screenrecord restarts.

--fps range widened 1-30 → 1-60 (default 10 → 30). The legacy screenshot/screencap recorders are retained as automatic fallbacks if the native path is unavailable.

Demo

Settings-screen scroll recorded with the new pipeline (no LINE app involved; alternating up/down scroll):

iOS — 60 fps constant Android — native variable rate
sim-use-ios-settings.mp4
sim-use-android-settings.mp4

New infrastructure

Shared H.264 Annex-B → MP4 passthrough muxer under Sources/iOSSimBackend/Util/:

  • AnnexBStreamParser — chunk-wise NAL-unit splitting + access-unit assembly (exp-Golomb first_mb_in_slice, emulation-prevention handling)
  • H264PassthroughRecorderAVAssetWriter passthrough; constant-rate PTS for iOS, host-clock VFR for Android; graceful zero-frame + segment-restart handling
  • H264MuxingPipeline — thread-safe parse→append glue
  • AdbStreamingProcess (AndroidBackend) — long-running adb child with incremental binary stdout

Testing

  • Unit (make test, no device): parser (NAL splitting, chunk boundaries, exp-Golomb), muxer (AVCC framing, monotonic/CFR PTS, real fixture round-trip → playable MP4, zero-frame error), pipeline, and Android screenrecord/wm size argument construction.
  • Live: verified on a booted iOS simulator (60 fps, uniform PTS, short-grace SIGTERM finalize ×5) and an Android emulator (screenrecord h264 stream, --size scaling, SDK 36 --time-limit 0).

🤖 Generated with Claude Code

subdiox and others added 2 commits July 17, 2026 16:06
…polling

Replace the screenshot-poll-and-re-encode recorder (capped ~8-10 fps) with
native H.264 capture muxed straight into MP4 (passthrough, no re-encode):

- iOS: FBSimulatorVideoStream eager H.264 at a constant --fps (1-60,
  default 30). The elementary stream carries no timestamps, so frames are
  laid out at exactly 1/fps -- the requested rate is honored and playback
  is smooth (uniform 16.7 ms spacing at 60 fps).
- Android: adb screenrecord --output-format=h264 at the device's native
  variable rate, stitched across the API<34 180 s per-invocation limit.
  --fps is ignored there; --quality/--scale map to bitrate/size.

New shared infra under Sources/iOSSimBackend/Util: AnnexBStreamParser
(NAL splitting + access-unit assembly), H264PassthroughRecorder
(AVAssetWriter passthrough; CFR for iOS, host-clock VFR for Android),
H264MuxingPipeline; plus AdbStreamingProcess for incremental adb stdout.
--fps range widened 1-30 -> 1-60 (default 10 -> 30). The legacy
screenshot/screencap recorders are retained as automatic fallbacks.

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

Replace every NSLock + `@unchecked Sendable` in the record-video path with
`OSAllocatedUnfairLock<State>`-backed plain `Sendable` classes
(CancellationFlag, OnceFlag, FirstErrorBox, H264StreamRecorder,
H264PassthroughRecorder, H264MuxingPipeline, AdbStreamingProcess).
Non-Sendable writer / subprocess state is confined to the lock, so no type
needs an unchecked escape hatch and no withLockUnchecked is used. (Mutex
would be cleaner still but requires macOS 15; the package targets macOS 14.)

Also wait for the stream's first frame before committing to it: a cold
FBSimulatorVideoStream that attaches but pushes nothing now falls back to
screenshot capture instead of producing an empty recording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@subdiox
subdiox force-pushed the feat/record-video-h264-stream branch from 98f2774 to 0b1f5c7 Compare July 17, 2026 07:36
@subdiox
subdiox marked this pull request as ready for review July 17, 2026 07:54

@onevcat onevcat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this PR! The new H.264 passthrough pipeline is a big improvement, and the code and tests are in great shape.

I pulled the branch and tested it live on both platforms (iOS simulator + Android emulator, SDK 36): build, all 1123 unit tests, recording under motion, --fps / --scale handling, SIGTERM with a short-grace SIGKILL, flag validation, and --json output all check out. The recorded videos look correct and play at the right speed.

During live testing I found three issues — one real behavior gap (1) and two smaller ones (2, 3). Details are in the inline comments. Could you confirm and fix?

  1. Android: --quality is silently ignored at the default --scale 1.0--bit-rate is only passed to screenrecord when --scale < 1.0, which contradicts the PR description and README.
  2. iOS: constant-frame-rate layout has no guard against stream under-delivery — if the stream drops frames, the video silently plays back faster than real time (non-blocking suggestion).
  3. iOS: mid-stream errors don't mention the partial file — the MP4 is already finalized and on disk, but the error message doesn't say so, unlike the Android branch (minor).


let sdk = Self.detectSDK(adb: adb, serial: serial)
let size = scale < 1.0 ? Self.detectScaledSize(adb: adb, serial: serial, scale: scale) : nil
let bitrate = size.map { H264StreamRecorder.estimateBitrate(width: $0.width, height: $0.height, fps: 30, quality: quality) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--quality does nothing on Android unless --scale < 1.0.

size is only detected when scale < 1.0, and bitrate is derived from size via size.map { ... } — so at the default scale, --bit-rate is never passed and --quality is silently ignored.

I verified this on a live emulator by capturing the device-side command line during recording (adb shell ps -ef | grep screenrecord):

  • --quality 20 (default scale): screenrecord --output-format=h264 --time-limit 0 - — no --bit-rate
  • --quality 20 --scale 0.5: screenrecord --output-format=h264 --time-limit 0 --bit-rate 3499200 --size 540x1200 - — works

This contradicts the PR description and README, which say --quality maps to bitrate on Android.

Suggested fix: always detect the display size via wm size (if detection fails, fall back to not passing --bit-rate), pass --bit-rate unconditionally, and keep passing --size only when scale < 1.0. detectScaledSize / parseWMSize can be reused as-is, so this should be a ~3-line change. (Alternatively, document the limitation and print a note like the --fps one — but making the code match the documented semantics seems better.)

/// (variable-rate) frame is held for its true wall-clock duration.
/// Throws `.noFramesCaptured` — after deleting the empty output — when
/// no frame was ever appended.
public func finish(stopHostTime: TimeInterval?) async throws {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking suggestion: guard CFR mode against stream under-delivery.

CFR mode trusts the frame count unconditionally: PTS is frameIndex / fps, so if FBSimulatorVideoStream delivers fewer frames than requested, the video is silently time-compressed and plays back faster than real time.

I hit this once during live testing: starting a 60 fps recording right after stopping a previous one produced only 77 frames over ~6 s of wall clock, i.e. a 1.28 s video that plays ~5x too fast. It didn't reproduce in controlled runs (60 fps at full size under motion tracked wall clock fine), so it looks like a cold-start / encoder-still-releasing edge case — but when it happens there is no hint to the caller, which is a silent semantic error for agent consumers.

A cheap guard: at finish time, compare framesAppended / frameRate against the actual wall-clock recording duration, and print a stderr warning when they diverge by more than ~20%. That keeps the smooth CFR playback while making the failure mode visible.

await stopStreamBestEffort(videoStream)

if let error = streamError.first {
throw error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: mid-stream errors should mention the partial file.

If the stream fails mid-recording, the MP4 has already been finalized successfully by this point and stays on disk — but the user only sees Failed to record video: ... with no hint that a playable partial recording exists. The Android branch handles the equivalent case with "...partial recording saved to ".

Suggest aligning the wording so the iOS error also points at the partial file.

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.

2 participants