From 7fc02ec73d08c98d2895cee38b199e02fde6dc43 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Fri, 24 Jul 2026 22:27:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(self-codec):=20=E5=A4=8D=E7=94=A8=20We?= =?UTF-8?q?bRTC=20=E9=87=87=E9=9B=86=E7=AE=A1=E7=BA=BF=E9=80=82=E9=85=8D?= =?UTF-8?q?=20Android=2014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自编码模式不再单独申请 MediaProjection 和 VirtualDisplay, 改为复用 WebRTC 的屏幕采集帧直接喂入编码器,规避 Android 14 单次投屏授权令牌限制。同时实现 I420 到 NV12 的颜色格式转换。 --- .../service/ScreenCaptureService.java | 61 ++--- .../controlled/webrtc/SelfCodecEncoder.java | 242 +++++++++++++----- .../ttstd/controlled/webrtc/WebRtcClient.java | 26 +- 3 files changed, 228 insertions(+), 101 deletions(-) diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java index 54da9e3..3a8b610 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java @@ -105,11 +105,10 @@ public class ScreenCaptureService extends Service { private boolean isShuttingDown = false; // ---- 屏幕串流"自编码"模式 ---- - private int resultCode; // 屏幕捕获授权结果码(供自编码模式重新获取 MediaProjection) + private int resultCode; // 屏幕捕获授权结果码(供 WebRTC 采集使用) private Intent resultDataIntent; // 屏幕捕获授权 Intent(同上) private int currentStreamMode = SelfCodecEncoder.STREAM_MODE_WEBRTC; - private MediaProjection selfMediaProjection; // 自编码模式独占的 MediaProjection - private SelfCodecEncoder selfEncoder; // 自编码 H264 编码器 + private SelfCodecEncoder selfEncoder; // 自编码 H264 编码器(复用 WebRTC 采集帧,不单独申请虚拟显示) @Override public void onCreate() { @@ -407,29 +406,21 @@ public class ScreenCaptureService extends Service { return; } if (mode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC) { - // 切到自编码:先停 WebRTC 默认屏幕采集(释放其 MediaProjection),再启动自编码。 - if (webRtcClient != null) { - webRtcClient.stopCapture(); - } + // 切到自编码:WebRTC 的屏幕采集保持运行(共享同一条采集管线), + // 自编码编码器只是额外"旁路"这些帧,不再单独申请 VirtualDisplay, + // 因此无需 stop/startCapture,规避 Android 14 单次投屏授权令牌的限制。 if (!startSelfCodecEncoder()) { - // 启动失败(如 Android 14+ 不允许复用投屏授权令牌): - // 回退 WebRTC 模式并如实上报,避免两端都黑屏。 - Log.e(TAG, "switch to self codec failed, fallback to webrtc mode"); - if (webRtcClient != null) { - webRtcClient.startCapture(); - } + // 启动失败:保持 WebRTC 模式并如实上报,避免黑屏。 + Log.e(TAG, "switch to self codec failed, stay webrtc mode"); currentStreamMode = SelfCodecEncoder.STREAM_MODE_WEBRTC; reportStreamMode(SelfCodecEncoder.STREAM_MODE_WEBRTC); return; } - // 关键:切换到自编码模式后,立即触发一次通知刷新(扰动),确保 MediaProjection 产生首帧并喂入编码器。 updateNotification("正在使用自编码模式串流..."); } else { - // 切回 WebRTC:停止自编码,恢复默认屏幕采集。 + // 切回 WebRTC:停止自编码旁路,WebRTC 采集与媒体流本就一直在线。 stopSelfCodecEncoder(); - if (webRtcClient != null) { - webRtcClient.startCapture(); - } + updateNotification("正在使用 WebRTC 模式串流..."); } currentStreamMode = mode; reportStreamMode(mode); @@ -449,18 +440,12 @@ public class ScreenCaptureService extends Service { if (webRtcClient == null || !webRtcClient.isVideoDataChannelOpen()) { Log.w(TAG, "startSelfCodecEncoder: video channel not ready yet"); } - MediaProjectionManager mpm = - (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); - try { - selfMediaProjection = mpm.getMediaProjection(resultCode, resultDataIntent); - } catch (Exception e) { - Log.e(TAG, "getMediaProjection for self codec failed", e); - return false; - } - int dpi = getResources().getDisplayMetrics().densityDpi; int[] res = getCurrentCaptureResolution(); + // 注意:自编码编码器不再创建自己的 MediaProjection/VirtualDisplay, + // 而是复用 WebRTC 的屏幕采集帧(通过 WebRtcClient.addSink 转发), + // 从根本上消除 Android 14 上"无法复用投屏令牌 / 无法创建第二个虚拟显示"的崩溃。 selfEncoder = new SelfCodecEncoder( - selfMediaProjection, res[0], res[1], res[2], dpi, + res[0], res[1], res[2], webRtcClient != null ? webRtcClient.getVideoDataChannel() : null, new SelfCodecEncoder.Callback() { @Override @@ -483,21 +468,23 @@ public class ScreenCaptureService extends Service { stopSelfCodecEncoder(); return false; } + // 立即请求一个关键帧(IDR),让控制端尽快拿到 SPS/PPS 并完成解码器配置。 + selfEncoder.requestKeyFrame(); + // 把 WebRTC 采集到的每一帧旁路给自编码编码器 + if (webRtcClient != null) { + webRtcClient.setSelfCodecFrameListener(frame -> selfEncoder.feedFrame(frame)); + } return true; } private void stopSelfCodecEncoder() { + if (webRtcClient != null) { + webRtcClient.setSelfCodecFrameListener(null); + } if (selfEncoder != null) { selfEncoder.release(); selfEncoder = null; } - if (selfMediaProjection != null) { - try { - selfMediaProjection.stop(); - } catch (Exception ignored) { - } - selfMediaProjection = null; - } } /** 经 control DataChannel 向控制端回报当前生效的串流模式。 */ @@ -560,10 +547,6 @@ public class ScreenCaptureService extends Service { selfEncoder.release(); selfEncoder = null; } - if (selfMediaProjection != null) { - selfMediaProjection.stop(); - selfMediaProjection = null; - } if (webRtcClient != null) { webRtcClient.close(); } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java index 0ad12d7..6ce5c16 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java @@ -1,25 +1,33 @@ package com.ttstd.controlled.webrtc; +import android.media.Image; import android.media.MediaCodec; import android.media.MediaCodecInfo; +import android.media.MediaCodecList; import android.media.MediaFormat; -import android.media.projection.MediaProjection; -import android.hardware.display.VirtualDisplay; -import android.hardware.display.DisplayManager; import android.os.Bundle; import android.util.Log; -import android.view.Surface; import org.webrtc.DataChannel; +import org.webrtc.VideoFrame; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.ArrayDeque; /** - * 自建屏幕编码通道:用 MediaProjection 的 VirtualDisplay 把屏幕投影到 MediaCodec - * 的输入 Surface,编码为 H.264 裸流后,经 WebRTC 的 video DataChannel 分片透传给 - * 控制端(控制端使用自建 MediaCodec 解码渲染)。绕开 WebRTC 默认媒体轨道,使编码/ - * 解码参数完全可控(码率、关键帧、低延迟调优)。 + * 自建屏幕编码通道:复用 WebRTC 的屏幕采集管线(ScreenCapturerAndroid 的 VideoFrame), + * 把每一帧编码为 H.264 裸流后,经 WebRTC 的 video DataChannel 分片透传给控制端 + * (控制端使用自建 MediaCodec 解码渲染)。 + * + * 设计要点(Android 14 兼容): + * 在 Android 14+ 上,MediaProjection 授权令牌为一次性,且一个 MediaProjection 实例 + * 只能 createVirtualDisplay 一次。因此自编码模式【不再】自己申请 MediaProjection / + * VirtualDisplay,而是复用 WebRTC 已经建立的、唯一的屏幕采集管线,从 VideoFrame 直接编码。 + * 这样全程只有一个虚拟显示、一个令牌,既避免了 + * "Must register a callback before starting capture", + * 也避免了 "Don't re-use the resultData ... Don't take multiple captures ..." 两类崩溃, + * 同时运行时可在 WebRTC 与自编码模式间自由切换(共享同一条采集)。 */ public class SelfCodecEncoder { @@ -34,21 +42,19 @@ public class SelfCodecEncoder { private static final String TAG = "SelfCodecEncoder"; - private final MediaProjection mediaProjection; private final int width; private final int height; private final int fps; - private final int dpi; private final DataChannel videoChannel; private final Callback callback; private MediaCodec encoder; - private Surface inputSurface; - private VirtualDisplay virtualDisplay; - private MediaProjection.Callback projectionCallback; private boolean running = false; private int frameSeq = 0; private final Object sendLock = new Object(); + private final Object inputLock = new Object(); + private final ArrayDeque freeInputIndices = new ArrayDeque<>(); + private byte[] nv12Buffer; // 缓存 SPS/PPS 等参数集:video 通道不可靠(可能丢包),在每个关键帧前重发, // 保证控制端即使错过首次 CONFIG 也能在下一个 IDR 前完成解码器配置。 private final java.util.ArrayList cachedConfigs = new java.util.ArrayList<>(); @@ -59,13 +65,10 @@ public class SelfCodecEncoder { void onEncoderError(String error); } - public SelfCodecEncoder(MediaProjection mp, int width, int height, int fps, int dpi, - DataChannel videoChannel, Callback cb) { - this.mediaProjection = mp; + public SelfCodecEncoder(int width, int height, int fps, DataChannel videoChannel, Callback cb) { this.width = width; this.height = height; this.fps = fps; - this.dpi = dpi; this.videoChannel = videoChannel; this.callback = cb; } @@ -78,7 +81,7 @@ public class SelfCodecEncoder { format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate); format.setInteger(MediaFormat.KEY_FRAME_RATE, fps); format.setInteger(MediaFormat.KEY_CAPTURE_RATE, fps); - format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, selectColorFormat()); // 关键帧间隔:1 秒一个 IDR,便于断流后快速恢复;也可经 requestKeyFrame 即时强求 format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); @@ -86,27 +89,9 @@ public class SelfCodecEncoder { // 异步回调需在 configure() 之前设置(部分系统版本在 configure 后设置会抛异常) encoder.setCallback(encoderCallback); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); - inputSurface = encoder.createInputSurface(); encoder.start(); - // Android 14+ (API 34) 强制要求:在 createVirtualDisplay 之前必须先注册 - // MediaProjection.Callback,否则会抛 - // IllegalStateException("Must register a callback before starting capture..."), - // 导致自编码模式无法启动、控制端黑屏。onStop 时释放资源。 - projectionCallback = new MediaProjection.Callback() { - @Override - public void onStop() { - Log.e(TAG, "MediaProjection stopped, releasing encoder"); - release(); - if (callback != null) callback.onEncoderError("MediaProjection stopped"); - } - }; - mediaProjection.registerCallback(projectionCallback, null); - - virtualDisplay = mediaProjection.createVirtualDisplay( - "SelfCodec", width, height, dpi, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null); - + nv12Buffer = new byte[width * height * 3 / 2]; running = true; if (callback != null) callback.onEncoderStarted(width, height, fps); Log.i(TAG, "self codec encoder started: " + width + "x" + height + "@" + fps + ", bitrate=" + bitrate); @@ -117,15 +102,166 @@ public class SelfCodecEncoder { } } + /** 选择合适的 YUV 输入颜色格式(优先 NV12 系;几乎全部 AVC 编码器均支持)。 */ + private int selectColorFormat() { + try { + MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + MediaCodecInfo info = null; + for (MediaCodecInfo c : list.getCodecInfos()) { + if (!c.isEncoder()) continue; + for (String t : c.getSupportedTypes()) { + if (t.equalsIgnoreCase(MediaFormat.MIMETYPE_VIDEO_AVC)) { + info = c; + break; + } + } + if (info != null) break; + } + if (info == null) return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + MediaCodecInfo.CodecCapabilities caps = info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC); + // 优先 NV12(COLOR_FormatYUV420SemiPlanar):其输入 Image 为 2 平面(Y + UV 交错), + // 与下方 writeNv12 的写入逻辑严格一致。仅在设备不支持 NV12 时才退而求其次用 Flexible。 + for (int f : caps.colorFormats) { + if (f == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) { + return f; + } + } + for (int f : caps.colorFormats) { + if (f == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible) { + return f; + } + } + return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + } catch (Exception e) { + return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + } + } + private int computeBitrate(int w, int h, int fps) { int b = (int) (w * h * fps * 0.12); return Math.max(500_000, Math.min(8_000_000, b)); } + /** + * 由 WebRTC 采集管线喂入一帧(共享同一条屏幕采集,无需再申请 VirtualDisplay)。 + * 当编码器输入缓冲不足时会丢弃该帧(编码器跟不上),保证不堆积、不阻塞采集线程。 + */ + public void feedFrame(VideoFrame frame) { + if (!running || encoder == null) return; + Integer idx; + synchronized (inputLock) { + idx = freeInputIndices.poll(); + } + if (idx == null) { + return; // 丢掉这一帧,等待下一个空闲输入缓冲 + } + try { + encodeFrame(idx, frame); + } catch (Exception e) { + Log.e(TAG, "encodeFrame failed", e); + try { + encoder.queueInputBuffer(idx, 0, 0, System.nanoTime() / 1000, 0); + } catch (Exception ignored) { + } + } + } + + private void encodeFrame(int index, VideoFrame frame) { + long ptsUs = System.nanoTime() / 1000; + VideoFrame.I420Buffer i420 = frame.getBuffer().toI420(); + int w = i420.getWidth(); + int h = i420.getHeight(); + ensureNv12Buffer(w, h); + i420ToNv12(i420, nv12Buffer, w, h); + i420.release(); + writeNv12(index, nv12Buffer, w, h, ptsUs); + } + + private void ensureNv12Buffer(int w, int h) { + int need = w * h * 3 / 2; + if (nv12Buffer == null || nv12Buffer.length < need) { + nv12Buffer = new byte[need]; + } + } + + /** I420(三平面)→ NV12(Y + UV 交错),写入 out。 */ + private void i420ToNv12(VideoFrame.I420Buffer i420, byte[] out, int w, int h) { + ByteBuffer y = i420.getDataY(); + ByteBuffer u = i420.getDataU(); + ByteBuffer v = i420.getDataV(); + int yStride = i420.getStrideY(); + int uStride = i420.getStrideU(); + int vStride = i420.getStrideV(); + int off = 0; + for (int r = 0; r < h; r++) { + y.position(r * yStride); + y.get(out, off, w); + off += w; + } + int cw = w / 2; + int ch = h / 2; + for (int r = 0; r < ch; r++) { + for (int c = 0; c < cw; c++) { + out[off++] = u.get(r * uStride + c); + out[off++] = v.get(r * vStride + c); + } + } + } + + /** 把 NV12 按编码器输入 Image 的 stride 写入输入缓冲并入队。 */ + private void writeNv12(int index, byte[] nv12, int w, int h, long ptsUs) { + Image image = encoder.getInputImage(index); + if (image == null) { + encoder.queueInputBuffer(index, 0, 0, ptsUs, 0); + return; + } + try { + Image.Plane yPlane = image.getPlanes()[0]; + Image.Plane uvPlane = image.getPlanes()[1]; + ByteBuffer yBuf = yPlane.getBuffer(); + ByteBuffer uvBuf = uvPlane.getBuffer(); + int yRow = yPlane.getRowStride(); + int uvRow = uvPlane.getRowStride(); + int yPix = yPlane.getPixelStride(); + int uvPix = uvPlane.getPixelStride(); + + int src = 0; + for (int r = 0; r < h; r++) { + int dst = r * yRow; + for (int c = 0; c < w; c++) { + yBuf.put(dst + c * yPix, nv12[src + c]); + } + src += w; + } + int ch = h / 2; + int cw = w / 2; + int uvSrc = w * h; + for (int r = 0; r < ch; r++) { + int sRow = uvSrc + r * cw * 2; + int dRow = r * uvRow; + for (int c = 0; c < cw; c++) { + int s = sRow + c * 2; + int d = dRow + c * uvPix; + uvBuf.put(d, nv12[s]); + uvBuf.put(d + 1, nv12[s + 1]); + } + } + encoder.queueInputBuffer(index, 0, w * h * 3 / 2, ptsUs, 0); + } catch (Exception e) { + Log.e(TAG, "writeNv12 failed", e); + try { + encoder.queueInputBuffer(index, 0, 0, ptsUs, 0); + } catch (Exception ignored) { + } + } + } + private final MediaCodec.Callback encoderCallback = new MediaCodec.Callback() { @Override public void onInputBufferAvailable(MediaCodec mc, int index) { - // 使用 Surface 输入,编码器自动从输入 Surface 取帧,无需手动喂数据 + synchronized (inputLock) { + freeInputIndices.add(index); + } } @Override @@ -261,11 +397,6 @@ public class SelfCodecEncoder { } } - /** 返回编码器是否正在运行(供上层判断启动结果)。 */ - public boolean isRunning() { - return running; - } - /** 即时请求一个关键帧(强求 IDR)。 */ public void requestKeyFrame() { if (encoder == null) return; @@ -278,22 +409,14 @@ public class SelfCodecEncoder { } } + /** 返回编码器是否正在运行(供上层判断启动结果)。 */ + public boolean isRunning() { + return running; + } + public void release() { running = false; - if (mediaProjection != null && projectionCallback != null) { - try { - mediaProjection.unregisterCallback(projectionCallback); - } catch (Exception ignored) { - } - projectionCallback = null; - } - if (virtualDisplay != null) { - try { - virtualDisplay.release(); - } catch (Exception ignored) { - } - virtualDisplay = null; - } + freeInputIndices.clear(); if (encoder != null) { try { encoder.stop(); @@ -305,9 +428,6 @@ public class SelfCodecEncoder { } encoder = null; } - if (inputSurface != null) { - inputSurface.release(); - inputSurface = null; - } + nv12Buffer = null; } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java index fc1b74c..b6b10fa 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java @@ -69,6 +69,13 @@ public class WebRtcClient { private InputCommandCallback inputCallback; + /** 自编码模式帧监听器:WebRTC 采集到的每一帧都会转发给自编码编码器(共享同一条屏幕采集)。 */ + public interface SelfCodecFrameListener { + void onFrame(org.webrtc.VideoFrame frame); + } + + private SelfCodecFrameListener selfCodecFrameListener; + private RemoteDisconnectCallback remoteDisconnectCallback; private boolean remoteDisconnectNotified = false; @@ -163,6 +170,18 @@ public class WebRtcClient { if (localSink != null) { videoTrack.addSink(localSink); } + // 把视频轨道的每一帧再转发给自编码帧监听器(如果已设置)。 + // 只在此处添加一次(videoTrack 仅创建一次),避免重复连接时重复挂 sink 导致重复编码。 + // 自编码模式复用同一条屏幕采集管线,无需再申请第二个 VirtualDisplay, + // 兼容 Android 14 单次投屏授权令牌的限制。 + videoTrack.addSink(new VideoSink() { + @Override + public void onFrame(org.webrtc.VideoFrame frame) { + if (selfCodecFrameListener != null) { + selfCodecFrameListener.onFrame(frame); + } + } + }); } /** @@ -347,7 +366,7 @@ public class WebRtcClient { @Override public void onSetSuccess() { - Log.e(TAG, "onSetSuccess: modifiedSdp = " + modifiedSdpDescription); + Log.d(TAG, "onSetSuccess: modifiedSdp = " + modifiedSdpDescription); sendAnswer(modifiedSdpDescription, controllerId); } @@ -466,6 +485,11 @@ public class WebRtcClient { return videoDataChannel != null && videoDataChannel.state() == DataChannel.State.OPEN; } + /** 设置自编码帧监听器:为 null 时不再转发采集帧(WebRTC 模式)。 */ + public void setSelfCodecFrameListener(SelfCodecFrameListener listener) { + this.selfCodecFrameListener = listener; + } + /** 暂停 WebRTC 默认屏幕采集(切到自编码模式时调用)。 */ public void stopCapture() { if (videoCapturer != null) {