diff --git a/app/data/bash-completion/scrcpy b/app/data/bash-completion/scrcpy index 339a819a84..c8b6609ea1 100644 --- a/app/data/bash-completion/scrcpy +++ b/app/data/bash-completion/scrcpy @@ -10,6 +10,7 @@ _scrcpy() { --audio-source= --audio-output-buffer= -b --video-bit-rate= + --camera-ar= --camera-id= --camera-facing= --camera-size= @@ -153,6 +154,7 @@ _scrcpy() { |--audio-codec-options \ |--audio-encoder \ |--audio-output-buffer \ + |--camera-ar \ |--camera-id \ |--camera-size \ |--crop \ diff --git a/app/data/zsh-completion/_scrcpy b/app/data/zsh-completion/_scrcpy index c92f0ac1fa..823e6b9ec5 100644 --- a/app/data/zsh-completion/_scrcpy +++ b/app/data/zsh-completion/_scrcpy @@ -17,6 +17,7 @@ arguments=( '--audio-source=[Select the audio source]:source:(output mic)' '--audio-output-buffer=[Configure the size of the SDL audio output buffer (in milliseconds)]' {-b,--video-bit-rate=}'[Encode the video at the given bit-rate]' + '--camera-ar=[Select the camera size by its aspect ratio]' '--camera-id=[Specify the camera id to mirror]' '--camera-facing=[Select the device camera by its facing direction]:facing:(front back external)' '--camera-size=[Specify an explicit camera capture size]' diff --git a/app/scrcpy.1 b/app/scrcpy.1 index d2fb3ad578..c473adb5cf 100644 --- a/app/scrcpy.1 +++ b/app/scrcpy.1 @@ -75,6 +75,12 @@ Encode the video at the given bit rate, expressed in bits/s. Unit suffixes are s Default is 8M (8000000). +.TP +.BI "\-\-camera\-ar " ar +Select the camera size by its aspect ratio (+/- 10%). + +Possible values are "sensor" (use the camera sensor aspect ratio), ":" (e.g. "4:3") and "" (e.g. "1.6"). + .TP .BI "\-\-camera\-id " id Specify the device camera id to mirror. diff --git a/app/src/cli.c b/app/src/cli.c index 96b8c26dba..69a918c3d5 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -87,6 +87,7 @@ enum { OPT_CAMERA_ID, OPT_CAMERA_SIZE, OPT_CAMERA_FACING, + OPT_CAMERA_AR, }; struct sc_option { @@ -203,6 +204,15 @@ static const struct sc_option options[] = { .longopt = "bit-rate", .argdesc = "value", }, + { + .longopt_id = OPT_CAMERA_AR, + .longopt = "camera-ar", + .argdesc = "ar", + .text = "Select the camera size by its aspect ratio (+/- 10%).\n" + "Possible values are \"sensor\" (use the camera sensor aspect " + "ratio), \":\" (e.g. \"4:3\") or \"\" (e.g. " + "\"1.6\")." + }, { .longopt_id = OPT_CAMERA_ID, .longopt = "camera-id", @@ -2130,6 +2140,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } break; + case OPT_CAMERA_AR: + opts->camera_ar = optarg; + break; case OPT_CAMERA_ID: opts->camera_id = optarg; break; @@ -2245,9 +2258,16 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } - if (!opts->camera_size) { - LOGE("Camera size must be specified by --camera-size"); - return false; + if (opts->camera_size) { + if (opts->max_size) { + LOGE("Could not specify both --camera-size and -m/--max-size"); + return false; + } + + if (opts->camera_ar) { + LOGE("Could not specify both --camera-size and --camera-ar"); + return false; + } } if (opts->control) { @@ -2255,6 +2275,7 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->control = false; } } else if (opts->camera_id + || opts->camera_ar || opts->camera_facing != SC_CAMERA_FACING_ANY || opts->camera_size) { LOGE("Camera options are only available with --video-source=camera"); diff --git a/app/src/options.c b/app/src/options.c index 2adb432370..589a5a2259 100644 --- a/app/src/options.c +++ b/app/src/options.c @@ -13,6 +13,7 @@ const struct scrcpy_options scrcpy_options_default = { .audio_encoder = NULL, .camera_id = NULL, .camera_size = NULL, + .camera_ar = NULL, .log_level = SC_LOG_LEVEL_INFO, .video_codec = SC_CODEC_H264, .audio_codec = SC_CODEC_OPUS, diff --git a/app/src/options.h b/app/src/options.h index 1e783bae0b..40f04670f1 100644 --- a/app/src/options.h +++ b/app/src/options.h @@ -132,6 +132,7 @@ struct scrcpy_options { const char *audio_encoder; const char *camera_id; const char *camera_size; + const char *camera_ar; enum sc_log_level log_level; enum sc_codec video_codec; enum sc_codec audio_codec; diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index 54e794e05e..1f4c2a7cca 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -375,6 +375,7 @@ scrcpy(struct scrcpy_options *options) { .audio_encoder = options->audio_encoder, .camera_id = options->camera_id, .camera_size = options->camera_size, + .camera_ar = options->camera_ar, .force_adb_forward = options->force_adb_forward, .power_off_on_close = options->power_off_on_close, .clipboard_autosync = options->clipboard_autosync, diff --git a/app/src/server.c b/app/src/server.c index 7f8a492682..0c40bccb63 100644 --- a/app/src/server.c +++ b/app/src/server.c @@ -77,6 +77,7 @@ sc_server_params_destroy(struct sc_server_params *params) { free((char *) params->audio_encoder); free((char *) params->tcpip_dst); free((char *) params->camera_id); + free((char *) params->camera_ar); } static bool @@ -105,6 +106,7 @@ sc_server_params_copy(struct sc_server_params *dst, COPY(audio_encoder); COPY(tcpip_dst); COPY(camera_id); + COPY(camera_ar); #undef COPY return true; @@ -303,6 +305,9 @@ execute_server(struct sc_server *server, ADD_PARAM("camera_facing=%s", sc_server_get_camera_facing_name(params->camera_facing)); } + if (params->camera_ar) { + ADD_PARAM("camera_ar=%s", params->camera_ar); + } if (params->show_touches) { ADD_PARAM("show_touches=true"); } diff --git a/app/src/server.h b/app/src/server.h index 786aea5c30..71d22fe846 100644 --- a/app/src/server.h +++ b/app/src/server.h @@ -36,6 +36,7 @@ struct sc_server_params { const char *audio_encoder; const char *camera_id; const char *camera_size; + const char *camera_ar; struct sc_port_range port_range; uint32_t tunnel_host; uint16_t tunnel_port; diff --git a/server/src/main/java/com/genymobile/scrcpy/CameraAspectRatio.java b/server/src/main/java/com/genymobile/scrcpy/CameraAspectRatio.java new file mode 100644 index 0000000000..4fdf4c740c --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/CameraAspectRatio.java @@ -0,0 +1,37 @@ +package com.genymobile.scrcpy; + +public final class CameraAspectRatio { + private static final float SENSOR = -1; + + private float ar; + + private CameraAspectRatio(float ar) { + this.ar = ar; + } + + public static CameraAspectRatio fromFloat(float ar) { + if (ar < 0) { + throw new IllegalArgumentException("Invalid aspect ratio: " + ar); + } + return new CameraAspectRatio(ar); + } + + public static CameraAspectRatio fromFraction(int w, int h) { + if (w <= 0 || h <= 0) { + throw new IllegalArgumentException("Invalid aspect ratio: " + w + ":" + h); + } + return new CameraAspectRatio((float) w / h); + } + + public static CameraAspectRatio sensorAspectRatio() { + return new CameraAspectRatio(SENSOR); + } + + public boolean isSensor() { + return ar == SENSOR; + } + + public float getAspectRatio() { + return ar; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java b/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java index 949eb34394..e4aba8723e 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java +++ b/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java @@ -4,6 +4,7 @@ import android.annotation.SuppressLint; import android.annotation.TargetApi; +import android.graphics.Rect; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCaptureSession; import android.hardware.camera2.CameraCharacteristics; @@ -13,6 +14,8 @@ import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.OutputConfiguration; import android.hardware.camera2.params.SessionConfiguration; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.MediaCodec; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; @@ -21,16 +24,23 @@ import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; public class CameraCapture extends SurfaceCapture { private final String explicitCameraId; private final CameraFacing cameraFacing; private final Size explicitSize; + private int maxSize; + private final CameraAspectRatio aspectRatio; + + private String cameraId; + private Size size; private HandlerThread cameraThread; private Handler cameraHandler; @@ -39,10 +49,12 @@ public class CameraCapture extends SurfaceCapture { private final AtomicBoolean disconnected = new AtomicBoolean(); - public CameraCapture(String explicitCameraId, CameraFacing cameraFacing, Size explicitSize) { + public CameraCapture(String explicitCameraId, CameraFacing cameraFacing, Size explicitSize, int maxSize, CameraAspectRatio aspectRatio) { this.explicitCameraId = explicitCameraId; this.cameraFacing = cameraFacing; this.explicitSize = explicitSize; + this.maxSize = maxSize; + this.aspectRatio = aspectRatio; } @Override @@ -53,11 +65,16 @@ public void init() throws IOException { cameraExecutor = new HandlerExecutor(cameraHandler); try { - String cameraId = selectCamera(explicitCameraId, cameraFacing); + cameraId = selectCamera(explicitCameraId, cameraFacing); if (cameraId == null) { throw new IOException("No matching camera found"); } + size = selectSize(cameraId, explicitSize, maxSize, aspectRatio); + if (size == null) { + throw new IOException("Could not select camera size"); + } + Ln.i("Using camera '" + cameraId + "'"); cameraDevice = openCamera(cameraId); } catch (CameraAccessException | InterruptedException e) { @@ -91,6 +108,82 @@ private static String selectCamera(String explicitCameraId, CameraFacing cameraF return null; } + @TargetApi(Build.VERSION_CODES.N) + private static Size selectSize(String cameraId, Size explicitSize, int maxSize, CameraAspectRatio aspectRatio) throws CameraAccessException { + if (explicitSize != null) { + return explicitSize; + } + + CameraManager cameraManager = ServiceManager.getCameraManager(); + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); + + StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + android.util.Size[] sizes = configs.getOutputSizes(MediaCodec.class); + Stream stream = Arrays.stream(sizes); + if (maxSize > 0) { + stream = stream.filter(it -> it.getWidth() <= maxSize && it.getHeight() <= maxSize); + } + + Float targetAspectRatio = resolveAspectRatio(aspectRatio, characteristics); + if (targetAspectRatio != null) { + stream = stream.filter(it -> { + float ar = ((float) it.getWidth() / it.getHeight()); + float arRatio = ar / targetAspectRatio; + // Accept if the aspect ratio is the target aspect ratio + or - 10% + return arRatio >= 0.9f && arRatio <= 1.1f; + }); + } + + Optional selected = stream.max((s1, s2) -> { + // Greater width is better + int cmp = Integer.compare(s1.getWidth(), s2.getWidth()); + if (cmp != 0) { + return cmp; + } + + if (targetAspectRatio != null) { + // Closer to the target aspect ratio is better + float ar1 = ((float) s1.getWidth() / s1.getHeight()); + float arRatio1 = ar1 / targetAspectRatio; + float distance1 = Math.abs(1 - arRatio1); + + float ar2 = ((float) s2.getWidth() / s2.getHeight()); + float arRatio2 = ar2 / targetAspectRatio; + float distance2 = Math.abs(1 - arRatio2); + + // Reverse the order because lower distance is better + cmp = Float.compare(distance2, distance1); + if (cmp != 0) { + return cmp; + } + } + + // Greater height is better + return Integer.compare(s1.getHeight(), s2.getHeight()); + }); + + if (selected.isPresent()) { + android.util.Size size = selected.get(); + return new Size(size.getWidth(), size.getHeight()); + } + + // Not found + return null; + } + + private static Float resolveAspectRatio(CameraAspectRatio ratio, CameraCharacteristics characteristics) { + if (ratio == null) { + return null; + } + + if (ratio.isSensor()) { + Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + return (float) activeSize.width() / activeSize.height(); + } + + return ratio.getAspectRatio(); + } + @Override public void start(Surface surface) throws IOException { try { @@ -114,12 +207,23 @@ public void release() { @Override public Size getSize() { - return explicitSize; + return size; } @Override public boolean setMaxSize(int maxSize) { - return false; + if (explicitSize != null) { + return false; + } + + this.maxSize = maxSize; + try { + size = selectSize(cameraId, null, maxSize, aspectRatio); + return size != null; + } catch (CameraAccessException e) { + Ln.w("Could not select camera size", e); + return false; + } } @SuppressLint("MissingPermission") diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index 11af3cca7e..eec19e5249 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -27,6 +27,7 @@ public class Options { private String cameraId; private Size cameraSize; private CameraFacing cameraFacing; + private CameraAspectRatio cameraAspectRatio; private boolean showTouches; private boolean stayAwake; private List videoCodecOptions; @@ -131,6 +132,10 @@ public CameraFacing getCameraFacing() { return cameraFacing; } + public CameraAspectRatio getCameraAspectRatio() { + return cameraAspectRatio; + } + public boolean getShowTouches() { return showTouches; } @@ -374,6 +379,11 @@ public static Options parse(String... args) { options.cameraFacing = facing; } break; + case "camera_ar": + if (!value.isEmpty()) { + options.cameraAspectRatio = parseCameraAspectRatio(value); + } + break; case "send_device_meta": options.sendDeviceMeta = Boolean.parseBoolean(value); break; @@ -427,4 +437,20 @@ private static Size parseSize(String size) { int height = Integer.parseInt(tokens[1]); return new Size(width, height); } + + private static CameraAspectRatio parseCameraAspectRatio(String ar) { + if ("sensor".equals(ar)) { + return CameraAspectRatio.sensorAspectRatio(); + } + + String[] tokens = ar.split(":"); + if (tokens.length == 2) { + int w = Integer.parseInt(tokens[0]); + int h = Integer.parseInt(tokens[1]); + return CameraAspectRatio.fromFraction(w, h); + } + + float floatAr = Float.parseFloat(tokens[0]); + return CameraAspectRatio.fromFloat(floatAr); + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java index 1a93323a8f..4505a52331 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -137,7 +137,8 @@ private static void scrcpy(Options options) throws IOException, ConfigurationExc if (options.getVideoSource() == VideoSource.DISPLAY) { surfaceCapture = new ScreenCapture(device); } else { - surfaceCapture = new CameraCapture(options.getCameraId(), options.getCameraFacing(), options.getCameraSize()); + surfaceCapture = new CameraCapture(options.getCameraId(), options.getCameraFacing(), options.getCameraSize(), + options.getMaxSize(), options.getCameraAspectRatio()); } SurfaceEncoder surfaceEncoder = new SurfaceEncoder(surfaceCapture, videoStreamer, options.getVideoBitRate(), options.getMaxFps(), options.getVideoCodecOptions(), options.getVideoEncoder(), options.getDownsizeOnError());