diff --git a/README.md b/README.md index f0717c2a..17703152 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,14 @@ scrcpy --bit-rate 2M scrcpy -b 2M # short version ``` +### Limit capture frame rate + +On device with Android >= 10, the capture frame rate can be limited: + +```bash +scrcpy --max-fps 15 +``` + ### Crop diff --git a/app/scrcpy.1 b/app/scrcpy.1 index 2547658e..948cac4d 100644 --- a/app/scrcpy.1 +++ b/app/scrcpy.1 @@ -41,6 +41,10 @@ Force recording format (either mp4 or mkv). .B \-h, \-\-help Print this help. +.TP +.BI \-\-max\-fps " value +Limit the framerate of screen capture (only supported on devices with Android >= 10). + .TP .BI "\-m, \-\-max\-size " value Limit both the width and height of the video to \fIvalue\fR. The other dimension is computed so that the device aspect\-ratio is preserved. diff --git a/app/src/main.c b/app/src/main.c index 23d258d5..da0d2074 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -50,6 +50,10 @@ static void usage(const char *arg0) { " -h, --help\n" " Print this help.\n" "\n" + " --max-fps value\n" + " Limit the frame rate of screen capture (only supported on\n" + " devices with Android >= 10).\n" + "\n" " -m, --max-size value\n" " Limit both the width and height of the video to value. The\n" " other dimension is computed so that the device aspect-ratio\n" @@ -269,6 +273,28 @@ parse_max_size(char *optarg, uint16_t *max_size) { return true; } +static bool +parse_max_fps(const char *optarg, uint16_t *max_fps) { + char *endptr; + if (*optarg == '\0') { + LOGE("Max FPS parameter is empty"); + return false; + } + long value = strtol(optarg, &endptr, 0); + if (*endptr != '\0') { + LOGE("Invalid max FPS: %s", optarg); + return false; + } + if (value & ~0xffff) { + // in practice, it should not be higher than 60 + LOGE("Max FPS value is invalid: %ld", value); + return false; + } + + *max_fps = (uint16_t) value; + return true; +} + static bool parse_window_position(char *optarg, int16_t *position) { char *endptr; @@ -374,6 +400,7 @@ guess_record_format(const char *filename) { #define OPT_WINDOW_WIDTH 1009 #define OPT_WINDOW_HEIGHT 1010 #define OPT_WINDOW_BORDERLESS 1011 +#define OPT_MAX_FPS 1012 static bool parse_args(struct args *args, int argc, char *argv[]) { @@ -383,6 +410,7 @@ parse_args(struct args *args, int argc, char *argv[]) { {"crop", required_argument, NULL, OPT_CROP}, {"fullscreen", no_argument, NULL, 'f'}, {"help", no_argument, NULL, 'h'}, + {"max-fps", required_argument, NULL, OPT_MAX_FPS}, {"max-size", required_argument, NULL, 'm'}, {"no-control", no_argument, NULL, 'n'}, {"no-display", no_argument, NULL, 'N'}, @@ -438,6 +466,11 @@ parse_args(struct args *args, int argc, char *argv[]) { case 'h': args->help = true; break; + case OPT_MAX_FPS: + if (!parse_max_fps(optarg, &opts->max_fps)) { + return false; + } + break; case 'm': if (!parse_max_size(optarg, &opts->max_size)) { return false; diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index c64acf49..67f1de16 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -280,6 +280,7 @@ scrcpy(const struct scrcpy_options *options) { .local_port = options->port, .max_size = options->max_size, .bit_rate = options->bit_rate, + .max_fps = options->max_fps, .control = options->control, }; if (!server_start(&server, options->serial, ¶ms)) { diff --git a/app/src/scrcpy.h b/app/src/scrcpy.h index f6779080..8723f29f 100644 --- a/app/src/scrcpy.h +++ b/app/src/scrcpy.h @@ -18,6 +18,7 @@ struct scrcpy_options { uint16_t port; uint16_t max_size; uint32_t bit_rate; + uint16_t max_fps; int16_t window_x; int16_t window_y; uint16_t window_width; @@ -43,6 +44,7 @@ struct scrcpy_options { .port = DEFAULT_LOCAL_PORT, \ .max_size = DEFAULT_LOCAL_PORT, \ .bit_rate = DEFAULT_BIT_RATE, \ + .max_fps = 0, \ .window_x = -1, \ .window_y = -1, \ .window_width = 0, \ diff --git a/app/src/server.c b/app/src/server.c index b40b065b..36290326 100644 --- a/app/src/server.c +++ b/app/src/server.c @@ -118,8 +118,10 @@ static process_t execute_server(struct server *server, const struct server_params *params) { char max_size_string[6]; char bit_rate_string[11]; + char max_fps_string[6]; sprintf(max_size_string, "%"PRIu16, params->max_size); sprintf(bit_rate_string, "%"PRIu32, params->bit_rate); + sprintf(max_fps_string, "%"PRIu16, params->max_fps); const char *const cmd[] = { "shell", "CLASSPATH=/data/local/tmp/" SERVER_FILENAME, @@ -134,6 +136,7 @@ execute_server(struct server *server, const struct server_params *params) { SCRCPY_VERSION, max_size_string, bit_rate_string, + max_fps_string, server->tunnel_forward ? "true" : "false", params->crop ? params->crop : "-", "true", // always send frame meta (packet boundaries + timestamp) diff --git a/app/src/server.h b/app/src/server.h index 2140d8ab..f46ced19 100644 --- a/app/src/server.h +++ b/app/src/server.h @@ -35,6 +35,7 @@ struct server_params { uint16_t local_port; uint16_t max_size; uint32_t bit_rate; + uint16_t max_fps; bool control; }; diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index af6b2ee1..5b993f30 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -5,6 +5,7 @@ import android.graphics.Rect; public class Options { private int maxSize; private int bitRate; + private int maxFps; private boolean tunnelForward; private Rect crop; private boolean sendFrameMeta; // send PTS so that the client may record properly @@ -26,6 +27,14 @@ public class Options { this.bitRate = bitRate; } + public int getMaxFps() { + return maxFps; + } + + public void setMaxFps(int maxFps) { + this.maxFps = maxFps; + } + public boolean isTunnelForward() { return tunnelForward; } diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java index f0b6db44..e58310a1 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java +++ b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java @@ -6,6 +6,7 @@ import android.graphics.Rect; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; +import android.os.Build; import android.os.IBinder; import android.os.Looper; import android.view.Surface; @@ -26,18 +27,20 @@ public class ScreenEncoder implements Device.RotationListener { private final ByteBuffer headerBuffer = ByteBuffer.allocate(12); private int bitRate; + private int maxFps; private int iFrameInterval; private boolean sendFrameMeta; private long ptsOrigin; - public ScreenEncoder(boolean sendFrameMeta, int bitRate, int iFrameInterval) { + public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, int iFrameInterval) { this.sendFrameMeta = sendFrameMeta; this.bitRate = bitRate; + this.maxFps = maxFps; this.iFrameInterval = iFrameInterval; } - public ScreenEncoder(boolean sendFrameMeta, int bitRate) { - this(sendFrameMeta, bitRate, DEFAULT_I_FRAME_INTERVAL); + public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps) { + this(sendFrameMeta, bitRate, maxFps, DEFAULT_I_FRAME_INTERVAL); } @Override @@ -60,7 +63,7 @@ public class ScreenEncoder implements Device.RotationListener { // Looper.prepareMainLooper(); - MediaFormat format = createFormat(bitRate, iFrameInterval); + MediaFormat format = createFormat(bitRate, maxFps, iFrameInterval); device.setRotationListener(this); boolean alive; try { @@ -143,7 +146,8 @@ public class ScreenEncoder implements Device.RotationListener { return MediaCodec.createEncoderByType("video/avc"); } - private static MediaFormat createFormat(int bitRate, int iFrameInterval) throws IOException { + @SuppressWarnings("checkstyle:MagicNumber") + private static MediaFormat createFormat(int bitRate, int maxFps, int iFrameInterval) { MediaFormat format = new MediaFormat(); format.setString(MediaFormat.KEY_MIME, "video/avc"); format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); @@ -153,6 +157,13 @@ public class ScreenEncoder implements Device.RotationListener { format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval); // display the very first frame, and recover from bad quality when no new frames format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs + if (maxFps > 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + format.setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, maxFps); + } else { + Ln.w("Max FPS is only supported since Android 10, the option has been ignored"); + } + } return format; } diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java index ba44d07c..ad14e5d8 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -17,7 +17,7 @@ public final class Server { final Device device = new Device(options); boolean tunnelForward = options.isTunnelForward(); try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) { - ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate()); + ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps()); if (options.getControl()) { Controller controller = new Controller(device, connection); @@ -77,8 +77,8 @@ public final class Server { + "(" + BuildConfig.VERSION_NAME + ")"); } - if (args.length != 7) { - throw new IllegalArgumentException("Expecting 7 parameters"); + if (args.length != 8) { + throw new IllegalArgumentException("Expecting 8 parameters"); } Options options = new Options(); @@ -89,17 +89,20 @@ public final class Server { int bitRate = Integer.parseInt(args[2]); options.setBitRate(bitRate); + int maxFps = Integer.parseInt(args[3]); + options.setMaxFps(maxFps); + // use "adb forward" instead of "adb tunnel"? (so the server must listen) - boolean tunnelForward = Boolean.parseBoolean(args[3]); + boolean tunnelForward = Boolean.parseBoolean(args[4]); options.setTunnelForward(tunnelForward); - Rect crop = parseCrop(args[4]); + Rect crop = parseCrop(args[5]); options.setCrop(crop); - boolean sendFrameMeta = Boolean.parseBoolean(args[5]); + boolean sendFrameMeta = Boolean.parseBoolean(args[6]); options.setSendFrameMeta(sendFrameMeta); - boolean control = Boolean.parseBoolean(args[6]); + boolean control = Boolean.parseBoolean(args[7]); options.setControl(control); return options;