Skip to content

Commit 59e2f74

Browse files
mobile-kevingmegidish
authored andcommitted
fix(android): scale avc capture to fit the encoder and report agent failures as errors
1 parent 5a38de2 commit 59e2f74

3 files changed

Lines changed: 197 additions & 26 deletions

File tree

‎agents/android/java/AvcServer.java‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,40 @@ private static int clampBitrate(int bps) {
8484
return Math.max(MIN_BITRATE, Math.min(MAX_BITRATE, bps));
8585
}
8686

87+
// H.264 encoders only accept dimensions that are a multiple of their
88+
// alignment (usually 2); a raw (display * scale) can be odd. Round down.
89+
private static int alignDown(float size, int alignment) {
90+
int align = Math.max(alignment, 2);
91+
return ((int) size / align) * align;
92+
}
93+
94+
/**
95+
* Returns the largest {width, height} the encoder supports that is no bigger
96+
* than the one asked for and keeps its aspect ratio, or null if there is none.
97+
* Encoders often report their limits for landscape only (e.g. 4096x2176), so a
98+
* tall portrait screen can exceed the maximum height while its area is well
99+
* within what the encoder handles; it is then scaled down to fit.
100+
*/
101+
static int[] fitToEncoder(MediaCodecInfo.VideoCapabilities caps, float width, float height) {
102+
int maxWidth = caps.getSupportedWidths().getUpper();
103+
int maxHeight = caps.getSupportedHeights().getUpper();
104+
float factor = Math.min(1.0f, Math.min(maxWidth / width, maxHeight / height));
105+
// past the width/height limits there can still be a block count or aspect
106+
// ratio limit; shrink a little at a time until the encoder takes it
107+
for (int attempt = 0; attempt < 100; attempt++) {
108+
int w = alignDown(width * factor, caps.getWidthAlignment());
109+
int h = alignDown(height * factor, caps.getHeightAlignment());
110+
if (w <= 0 || h <= 0) {
111+
return null;
112+
}
113+
if (caps.isSizeSupported(w, h)) {
114+
return new int[] {w, h};
115+
}
116+
factor *= 0.95f;
117+
}
118+
return null;
119+
}
120+
87121
private void startControlServer(MediaCodec codec, DisplayUtils.DisplayMirror mirror) {
88122
new JsonRpcSocketServer(CONTROL_SOCKET, (method, params) -> {
89123
switch (method) {
@@ -127,23 +161,27 @@ private void stream() throws Exception {
127161
MediaCodecInfo.VideoCapabilities caps = codec.getCodecInfo()
128162
.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC).getVideoCapabilities();
129163

130-
// H.264 encoders only accept dimensions that are a multiple of their
131-
// alignment (usually 2); a raw (display * scale) can be odd. Round down.
132-
int widthAlign = Math.max(caps.getWidthAlignment(), 2);
133-
int heightAlign = Math.max(caps.getHeightAlignment(), 2);
134-
int width = ((int) (display.width * scale) / widthAlign) * widthAlign;
135-
int height = ((int) (display.height * scale) / heightAlign) * heightAlign;
136-
137-
if (width <= 0 || height <= 0) {
164+
int requestedWidth = alignDown(display.width * scale, caps.getWidthAlignment());
165+
int requestedHeight = alignDown(display.height * scale, caps.getHeightAlignment());
166+
if (requestedWidth <= 0 || requestedHeight <= 0) {
138167
codec.release();
139-
throw new IllegalArgumentException("Invalid dimensions: " + width + "x" + height);
168+
throw new IllegalArgumentException("Invalid dimensions: " + requestedWidth + "x" + requestedHeight);
140169
}
141-
if (!caps.isSizeSupported(width, height)) {
170+
171+
int[] size = fitToEncoder(caps, display.width * scale, display.height * scale);
172+
if (size == null) {
142173
String max = caps.getSupportedWidths().getUpper() + "x" + caps.getSupportedHeights().getUpper();
143174
codec.release();
144-
throw new IllegalArgumentException("Video dimensions " + width + "x" + height
145-
+ " exceed codec capabilities. Maximum supported: " + max
146-
+ ". Try using --scale parameter to reduce resolution (e.g., --scale 0.5)");
175+
throw new IllegalArgumentException("Video dimensions " + requestedWidth + "x" + requestedHeight
176+
+ " are not supported by the encoder, and no smaller size with the same aspect ratio is."
177+
+ " Maximum supported: " + max);
178+
}
179+
int width = size[0];
180+
int height = size[1];
181+
if (width != requestedWidth || height != requestedHeight) {
182+
// not on stderr: adb exec-out merges it into the video stream
183+
Log.i(TAG, "Encoder does not support " + requestedWidth + "x" + requestedHeight
184+
+ ", encoding at " + width + "x" + height);
147185
}
148186

149187
MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height);

‎devices/android.go‎

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/xml"
1010
"errors"
1111
"fmt"
12+
"io"
1213
"os"
1314
"os/exec"
1415
"os/signal"
@@ -1524,24 +1525,54 @@ func (d *AndroidDevice) StartScreenCapture(config ScreenCaptureConfig) error {
15241525
return fmt.Errorf("failed to start %s: %v", serverClass, err)
15251526
}
15261527

1527-
// Read bytes from the command output and send to callback
1528+
err = forwardAgentStream(stdout, config.OnData)
1529+
_ = cmd.Process.Kill()
1530+
_ = cmd.Wait()
1531+
if err != nil {
1532+
return fmt.Errorf("%s failed: %w", serverClass, err)
1533+
}
1534+
return nil
1535+
}
1536+
1537+
// agentStreamErrorPrefix starts the message MjpegServer and AvcServer print when
1538+
// they fail to start. `adb exec-out` merges the agent's stderr into its stdout,
1539+
// and does not pass on its exit code, so the message arrives in place of the
1540+
// stream and is the only sign that the agent failed.
1541+
const agentStreamErrorPrefix = "Error: "
1542+
1543+
// agentStreamErrorLimit bounds how much of a failed agent's output is read.
1544+
const agentStreamErrorLimit = 64 * 1024
1545+
1546+
// forwardAgentStream sends every chunk read from the agent's output to onData,
1547+
// until the output ends or onData returns false. If the output starts with the
1548+
// agent's error message instead of a stream, it returns that message as an error.
1549+
func forwardAgentStream(r io.Reader, onData func([]byte) bool) error {
1550+
head := make([]byte, len(agentStreamErrorPrefix))
1551+
n, err := io.ReadFull(r, head)
1552+
head = head[:n]
1553+
if bytes.HasPrefix(head, []byte(agentStreamErrorPrefix)) {
1554+
rest, _ := io.ReadAll(io.LimitReader(r, agentStreamErrorLimit))
1555+
message := strings.TrimSpace(strings.TrimPrefix(string(head)+string(rest), agentStreamErrorPrefix))
1556+
return errors.New(message)
1557+
}
1558+
if n > 0 && !onData(head) {
1559+
return nil
1560+
}
1561+
if err != nil {
1562+
// the output ended within its first few bytes
1563+
return nil
1564+
}
1565+
15281566
buffer := make([]byte, 65536)
15291567
for {
1530-
n, err := stdout.Read(buffer)
1531-
if err != nil {
1532-
break
1568+
n, err := r.Read(buffer)
1569+
if n > 0 && !onData(buffer[:n]) {
1570+
return nil
15331571
}
1534-
1535-
if n > 0 {
1536-
// Send bytes to callback, break if it returns false
1537-
if !config.OnData(buffer[:n]) {
1538-
break
1539-
}
1572+
if err != nil {
1573+
return nil
15401574
}
15411575
}
1542-
1543-
_ = cmd.Process.Kill()
1544-
return nil
15451576
}
15461577

15471578
// ScreenRecord records the device screen to a local MP4 file using adb screenrecord.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package devices
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"strings"
7+
"testing"
8+
"testing/iotest"
9+
)
10+
11+
// agentOutput is what the on-device agent wrote, delivered by r.
12+
type agentOutput struct {
13+
r io.Reader
14+
}
15+
16+
func agentWrote(output string) agentOutput {
17+
return agentOutput{r: strings.NewReader(output)}
18+
}
19+
20+
// agentWroteOneByteAtATime delivers the output in 1-byte reads, the worst case
21+
// for spotting a prefix that spans several reads.
22+
func agentWroteOneByteAtATime(output string) agentOutput {
23+
return agentOutput{r: iotest.OneByteReader(strings.NewReader(output))}
24+
}
25+
26+
// forwardEverything forwards the agent's output and returns what reached the
27+
// caller, and the error.
28+
func forwardEverything(output agentOutput) (string, error) {
29+
var received bytes.Buffer
30+
err := forwardAgentStream(output.r, func(chunk []byte) bool {
31+
received.Write(chunk)
32+
return true
33+
})
34+
return received.String(), err
35+
}
36+
37+
const avcStreamStart = "\x00\x00\x00\x01\x67\x42\xc0\x29"
38+
39+
func TestForwardAgentStream_PassesTheStreamThrough(t *testing.T) {
40+
stream := avcStreamStart + strings.Repeat("\x01\x02\x03", 50000)
41+
42+
for name, output := range map[string]agentOutput{
43+
"one read": agentWrote(stream),
44+
"one byte at time": agentWroteOneByteAtATime(stream),
45+
} {
46+
t.Run(name, func(t *testing.T) {
47+
received, err := forwardEverything(output)
48+
if err != nil {
49+
t.Fatalf("unexpected error: %v", err)
50+
}
51+
if received != stream {
52+
t.Errorf("received %d bytes, want the %d bytes of the stream", len(received), len(stream))
53+
}
54+
})
55+
}
56+
}
57+
58+
func TestForwardAgentStream_ReturnsTheAgentErrorInsteadOfForwardingIt(t *testing.T) {
59+
agentError := "Error: Invalid dimensions: 0x0\n"
60+
61+
for name, output := range map[string]agentOutput{
62+
"one read": agentWrote(agentError),
63+
"one byte at time": agentWroteOneByteAtATime(agentError),
64+
} {
65+
t.Run(name, func(t *testing.T) {
66+
received, err := forwardEverything(output)
67+
if err == nil {
68+
t.Fatal("expected the agent's error, got none")
69+
}
70+
if err.Error() != "Invalid dimensions: 0x0" {
71+
t.Errorf("error = %q, want %q", err.Error(), "Invalid dimensions: 0x0")
72+
}
73+
if received != "" {
74+
t.Errorf("forwarded %q as stream data", received)
75+
}
76+
})
77+
}
78+
}
79+
80+
func TestForwardAgentStream_ForwardsAnOutputShorterThanTheErrorPrefix(t *testing.T) {
81+
received, err := forwardEverything(agentWrote("\x00\x00"))
82+
if err != nil {
83+
t.Fatalf("unexpected error: %v", err)
84+
}
85+
if received != "\x00\x00" {
86+
t.Errorf("received %q, want %q", received, "\x00\x00")
87+
}
88+
}
89+
90+
func TestForwardAgentStream_StopsWhenTheCallerStops(t *testing.T) {
91+
chunks := 0
92+
err := forwardAgentStream(agentWroteOneByteAtATime(avcStreamStart+"more stream").r, func([]byte) bool {
93+
chunks++
94+
return chunks < 2
95+
})
96+
if err != nil {
97+
t.Fatalf("unexpected error: %v", err)
98+
}
99+
if chunks != 2 {
100+
t.Errorf("onData called %d times after asking to stop at 2", chunks)
101+
}
102+
}

0 commit comments

Comments
 (0)