Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-android-keyframes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"expo-device-hub": patch
---

Shorten the default Android video keyframe interval from ten seconds to one second, giving decoders more frequent opportunities to recover after lost frames. Explicit keyframe interval overrides remain supported.
2 changes: 1 addition & 1 deletion packages/serve-emu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ serve-emu --running-avds
| `--max-fps` | `60` | Cap source frame rate |
| `--bit-rate` | `8000000` | H.264 bit rate in bps |
| `--max-size` | `1280` | Downscale the longest edge to N pixels; `0` keeps native size. The default balances detail and throughput, especially for the host-side software encoder used by `grpc-screenshot` |
| `--key-frame-interval` | `10` | Ask the encoder for regular keyframes; `0` disables this codec option. Late joiners get keyframes on demand, so a long interval avoids periodic keyframe bursts |
| `--key-frame-interval` | `1` | Ask the encoder for regular keyframes in seconds; `0` disables this codec option. Frequent keyframes help decoders recover after lost frames. Late joiners also get keyframes on demand |
| `--repeat-frame-ms` | `0` | Re-encode the previous frame after N ms without screen changes (`16` ≈ steady 60fps on static screens, at extra CPU/bandwidth cost); `0` keeps the source default: 100ms for scrcpy and 500ms for `grpc-screenshot` |
| `--transport` | `websocket` | Initial browser video transport: `websocket` or `webrtc`. Each tab can switch independently in the UI |
| `--stun-url` | public STUN defaults | Comma-separated STUN URL(s) for WebRTC ICE |
Expand Down
2 changes: 1 addition & 1 deletion packages/serve-emu/packages/serve-emu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ serve-emu --running-avds
| `--max-fps` | `60` | Cap source frame rate |
| `--bit-rate` | `8000000` | H.264 bit rate in bps |
| `--max-size` | `1280` | Downscale the longest edge to N pixels; `0` keeps native size. The default balances detail and throughput, especially for the host-side software encoder used by `grpc-screenshot` |
| `--key-frame-interval` | `10` | Ask the encoder for regular keyframes; `0` disables this codec option. Late joiners get keyframes on demand, so a long interval avoids periodic keyframe bursts |
| `--key-frame-interval` | `1` | Ask the encoder for regular keyframes in seconds; `0` disables this codec option. Frequent keyframes help decoders recover after lost frames. Late joiners also get keyframes on demand |
| `--repeat-frame-ms` | `0` | Re-encode the previous frame after N ms without screen changes (`16` ≈ steady 60fps on static screens, at extra CPU/bandwidth cost); `0` keeps the source default: 100ms for scrcpy and 500ms for `grpc-screenshot` |
| `--transport` | `websocket` | Initial browser video transport: `websocket` or `webrtc`. Each tab can switch independently in the UI |
| `--stun-url` | public STUN defaults | Comma-separated STUN URL(s) for WebRTC ICE |
Expand Down
6 changes: 3 additions & 3 deletions packages/serve-emu/packages/serve-emu/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,9 @@ Options:
delivery for either source.
--key-frame-interval <sec>
Ask the encoder for regular keyframes; 0 disables this
codec option (default: ${SCRCPY_DEFAULTS.keyFrameInterval}). Late joiners get keyframes
on demand via reset-video, so a long interval avoids
periodic keyframe bursts.
codec option (default: ${SCRCPY_DEFAULTS.keyFrameInterval}). Frequent keyframes help
decoders recover after lost frames. Late joiners also
get keyframes on demand via reset-video.
--repeat-frame-ms <ms>
Re-encode the previous frame after this many ms with no
screen change, so static screens keep producing frames
Expand Down
6 changes: 3 additions & 3 deletions packages/serve-emu/packages/serve-emu/src/scrcpy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ export const SCRCPY_DEFAULTS = {
// (c2.android.avc.encoder) only sustains 60fps below roughly a megapixel,
// so cap the longest edge at 1280 unless the caller overrides it.
maxSize: 1280,
// Late joiners get keyframes on demand via reset-video, so a long interval
// avoids periodic keyframe bursts.
keyFrameInterval: 10,
// Frequent keyframes give decoders another recovery point after lost frames.
// Late joiners can still request an immediate keyframe via reset-video.
keyFrameInterval: 1,

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.

[suggestion] webrtc-publisher.ts requests a keyframe on peer open, track open, and backpressure, and wires an RtcpNackResponder, but I found no handling of RTCP Picture Loss Indication from the browser. A decoder that loses reference frames beyond NACK recovery therefore has no way to ask for a recovery point, which matches this PR's motivation. The 1s GOP is a reasonable blanket mitigation, but at CBR (-maxrate/-bufsize equal to the bit rate) a keyframe every 60 frames takes a larger share of the 8 Mbps budget and lowers P-frame quality on static UI. Forwarding PLI to onKeyframeRequest would address the root cause and would let this interval go back up later. Not blocking.

repeatFrameMs: 0,
} as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1218,10 +1218,12 @@ describe("startGrpcSession integration", () => {
try {
expect(requests).toEqual([encoder]);
expect(encoders[0]!.options.encoderName).toBe(resolved);
expect(encoders[0]!.options.keyFrameInterval).toBe(1);
expect(session.diagnostics?.().grpcCapture?.encoderName).toBe(resolved);
client.streamImage!(integrationImage(0, 6, 4), "stream", Date.now());
await waitFor(() => encoders.length === 2);
expect(encoders[1]!.options.encoderName).toBe(resolved);
expect(encoders[1]!.options.keyFrameInterval).toBe(1);
expect(requests).toHaveLength(1);
} finally {
await session.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ describe("scrcpy async lifecycle", () => {
"max_size=1280",
"video_bit_rate=8000000",
"max_fps=60",
"video_codec_options=i-frame-interval=10",
"video_codec_options=i-frame-interval=1",
"clipboard_autosync=false",
"cleanup=true",
]);
Expand Down