ब्लॉग पर वापस जाएँ
Architecture15 min read

स्क्रीन मिरर: कम-विलंबता कास्टिंग आर्किटेक्चर

यह लेख PlainApp के स्क्रीन मिरर सिस्टम के एंड-टू-एंड डिज़ाइन को कवर करता है: Android कैसे MediaCodec के माध्यम से H.264/Opus को हार्डवेयर-एनकोड करता है, फ्रेम्स कस्टम बाइनरी प्रोटोकॉल का उपयोग करके WebSocket पर कैसे यात्रा करते हैं, वेब साइड WebCodecs के माध्यम से कैसे डीकोड करता है और WebGL2 के माध्यम से जीरो CPU कॉपी के साथ रेंडर करता है, और लॉस डिटेक्शन, ओरिएंटेशन चेंज और रिमोट टच कंट्रोल को कैसे हैंडल किया जाता है।

विषय सूची

उच्च-स्तरीय आर्किटेक्चर

PlainApp स्क्रीन मिरर एक एंड-टू-एंड कम-विलंबता कास्टिंग सिस्टम है: Android डिवाइस स्क्रीन कंटेंट कैप्चर करता है, उसे H.264 वीडियो और Opus ऑडियो में हार्डवेयर-एनकोड करता है, और कस्टम बाइनरी प्रोटोकॉल का उपयोग करके WebSocket के माध्यम से वेब क्लाइंट पर पुश करता है; वेब क्लाइंट WebCodecs API के माध्यम से डीकोड करता है और WebGL2 के माध्यम से सीधे Canvas पर रेंडर करता है, जिसमें पूरे पाइपलाइन में कोई CPU कॉपी नहीं होती। एक ट्रांसपेरेंट टच ओवरले लूप को बंद करता है, पॉइंटर इनपुट को फोन पर जेस्चर में बदलता है।

कोई WebRTC नहीं, कोई RTMP नहीं, कोई इंटरमीडिएट सर्वर नहीं। पूरी पाइपलाइन इस प्रकार है:

Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas

WebRTC क्यों नहीं?

WebRTC रियल-टाइम कम्युनिकेशन के लिए डिज़ाइन किया गया है। इसकी ICE/STUN/TURN नेगोशिएशन, कंजेशन कंट्रोल और जिटर बफरिंग LAN स्क्रीन कास्टिंग के लिए ओवरकिल है। PlainApp का उपयोग मामला है:

  • एक ही LAN, लेटेंसी < 5ms, NAT ट्रैवर्सल की आवश्यकता नहीं
  • अत्यधिक कम लेटेंसी की खोज, कोई जिटर बफरिंग नहीं
  • उच्च गुणवत्ता, बिटरेट अधिक हो सकता है (8Mbps)
  • स्क्रीन कंट्रोल (टच इंजेक्शन), जहां WebRTC का DataChannel अनावश्यक जटिलता जोड़ता है

LAN परिदृश्य के लिए WebSocket पर एक कस्टम बाइनरी प्रोटोकॉल हल्का और अधिक नियंत्रणीय है।

कम्पोनेंट मैप

Diagram 1
1

लेयरAndroidवेब
स्क्रीन कैप्चरMediaProjection + VirtualDisplay—
वीडियो एन्कोडिंगMediaCodec H.264 हार्डवेयर एनकोडर—
ऑडियो एन्कोडिंगMediaCodec Opus हार्डवेयर एनकोडर—
ट्रांसपोर्टWebSocket बाइनरी इवेंट्सWebSocket रिसीवर
वीडियो डीकोडिंग—WebCodecs VideoDecoder
ऑडियो डीकोडिंग—WebCodecs AudioDecoder → <audio>
रेंडरिंग—WebGL2 टेक्सचर डायरेक्ट-रेंडर
कंट्रोलAccessibilityService जेस्चर इंजेक्शनटच ओवरले → GraphQL म्यूटेशन

वीडियो और ऑडियो हमेशा डिवाइस से ब्राउज़र की ओर एक ही WebSocket कनेक्शन पर प्रवाहित होते हैं; कंट्रोल विपरीत दिशा में GraphQL (sendScreenMirrorControl) पर प्रवाहित होता है, जो कोडेक कॉन्फ़िग (screenMirrorVideoCodec क्वेरी) और कीफ्रेम अनुरोधों (requestScreenMirrorKeyFrame म्यूटेशन) के लिए साइड-चैनल के रूप में भी काम करता है।

वीडियो एन्कोडिंग पाइपलाइन (Android)

एन्कोडिंग पैरामीटर ट्यूनिंग

एन्कोडिंग पैरामीटर विशेष रूप से कम-विलंबता LAN स्क्रीन कास्टिंग के लिए ट्यून किए गए थे:

पैरामीटरवैल्यूनोट्स
KEY_FRAME_RATE6060fps स्मूथनेस के लिए
KEY_I_FRAME_INTERVAL10IDR अंतराल 10s, कीफ्रेम ओवरहेड कम करता है
KEY_BIT_RATE_MODEVBR (इम्प्लिसिट, कोई एक्सप्लिसिट मोड सेट नहीं)वेरिएबल बिटरेट, सीन-अडैप्टिव
KEY_PRIORITY0रियलटाइम प्रायोरिटी
KEY_LATENCY1कम-विलंबता मोड

बिटरेट क्वालिटी मोड के अनुसार स्तरीकृत है — उच्च बिटरेट (जैसे 24 Mbps) का परीक्षण किया गया और पाया गया कि वे एनकोडर/डीकोडर फ्रेम ड्रॉप और एंड-टू-एंड लेटेंसी में वृद्धि का कारण बनते हैं, स्क्रीन कंटेंट के लिए दृश्य गुणवत्ता में कोई लाभ नहीं:

मोडबिटरेटकैप्चर रिज़ॉल्यूशन
HD8 Mbps1080p शॉर्ट साइड
Smooth4 Mbps1080p शॉर्ट साइड
Low2 Mbps720p शॉर्ट साइड

एनकोडर कम-विलंबता कॉन्फ़िगरेशन

MediaCodecVideoEncoder एनकोडर को क्रिएशन के समय एक बार कॉन्फ़िगर करता है:

MediaFormat.createVideoFormat(MIME, width, height).apply {
    setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
    setInteger(MediaFormat.KEY_BIT_RATE, bitrateBps)
    setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)          // 60
    setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameIntervalSec) // 10
    setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L)
    setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
    setInteger(MediaFormat.KEY_PRIORITY, 0)
    setInteger(MediaFormat.KEY_LATENCY, 1)
}

KEY_PRIORITY=0 और KEY_LATENCY=1 कम लेटेंसी की कुंजी हैं — ये एनकोडर को बताते हैं कि कंप्रेशन रेशियो पर रियल-टाइम एन्कोडिंग को प्राथमिकता दें। इनपुट एक Surface है जो MediaCodec.createInputSurface() द्वारा बनाया गया है और सीधे VirtualDisplay को फीड किया जाता है — कोई SurfaceTexture रीडबैक नहीं, कोई I420 कन्वर्ज़न नहीं, CPU पिक्सल को छूता नहीं है।

कैप्चर रिज़ॉल्यूशन

ScreenMirrorCaptureSize.compute() फिजिकल स्क्रीन साइज़, क्वालिटी मोड के शॉर्ट-साइड टार्गेट (720/1080), और एनकोडर की रिपोर्टेड maxWidth/maxHeight तथा चौड़ाई/ऊंचाई अलाइनमेंट (एक बार MediaCodecVideoEncoder.queryEncoderCaps() के माध्यम से क्वेरी की गई) से वास्तविक कैप्चर साइज़ प्राप्त करता है, ताकि एनकोडर को कभी भी ऐसे डाइमेंशन न मिलें जिन्हें वह स्वीकार नहीं कर सकता।

कीफ्रेम अनुरोध

वेब क्लाइंट पैकेट लॉस से रिकवर करने के लिए GraphQL requestScreenMirrorKeyFrame म्यूटेशन के माध्यम से IDR फ्रेम का अनुरोध कर सकता है। Android MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME के माध्यम से प्रतिक्रिया करता है:

fun requestKeyFrame() {
    val b = Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 1) }
    codec?.setParameters(b)
}

SPS/PPS और कीफ्रेम ब्रॉडकास्ट

एनकोडर शुरू होने के बाद, INFO_OUTPUT_FORMAT_CHANGED csd-0/csd-1 (SPS/PPS) डिलीवर करता है, जिसे ScreenMirrorPipeline एक सिंगल Annex-B कॉन्फ़िग ब्लॉब में जोड़ता है और कैश करता है (cachedConfig)। उसके बाद आने वाले पहले IDR को भी कैश किया जाता है (cachedKeyFrame) ताकि नए कनेक्टेड वेब क्लाइंट अगले कीफ्रेम अंतराल की प्रतीक्षा किए बिना screenMirrorVideoCodec GraphQL क्वेरी के माध्यम से दोनों प्राप्त कर सके। जब कॉन्फ़िग बदलता है (ओरिएंटेशन या क्वालिटी स्विच), Android नए IDR को सामान्य वीडियो पैकेट के रूप में नहीं भेजता — यह SPS/PPS + IDR को एक screen_mirror_video_codec WebSocket इवेंट में बंडल करता है, ताकि वेब क्लाइंट एक ही बार में डीकोडर रीकॉन्फ़िगरेशन और फर्स्ट-फ्रेम डीकोडिंग पूरा कर सके, न कि पुराने डीकोडर को नए बिटस्ट्रीम के खिलाफ रेस कराए।

कुछ OEM एनकोडर (Qualcomm/Xiaomi) SPS+PPS+IDR को एक सिंगल आउटपुट बफ़र में बंडल करते हैं जो BUFFER_FLAG_CODEC_CONFIG और BUFFER_FLAG_SYNC_FRAME दोनों ले जाता है। ड्रेन लूप केवल उन बफ़र्स को स्किप करता है जो शुद्ध कॉन्फ़िग हैं (isConfig && !isKey) — कॉन्फ़िग-फ़्लैग्ड बफ़र को स्किप करना जो सिंक फ्रेम भी ले जाता है, चुपचाप IDR को ड्रॉप कर देगा और डीकोडर को केवल P-फ्रेम्स के साथ छोड़ देगा, जिससे मोज़ेक आउटपुट उत्पन्न होगा।

VideoPacket प्रोटोकॉल डिज़ाइन

वीडियो और ऑडियो दोनों फ्रेम्स WebSocket ट्रांसपोर्ट के लिए यूनिफ़ाइड VideoPacket बाइनरी प्रोटोकॉल में लपेटे जाते हैं।

प्रोटोकॉल फ़ॉर्मेट

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| MAGIC  | FLAGS  |           FRAME_ID (4 bytes, big-endian)            |              TIMESTAMP (8 bytes, BE)              |  DATA  |
| 0x56   |        |   byte2   |   byte3   |   byte4   |   byte5   |  byte6  |  byte7  | ...  |  byte13 |        payload...        |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
 \- 1B -/ \- 1B -/ \-------------------- 4B ----------------------/ \----------------------- 8B ------------------------/ \- var -/
फ़ील्डसाइज़विवरण
MAGIC1 byteफिक्स्ड 0x56 ('V'), वैलिडेशन के लिए
FLAGS1 byte0x01=कीफ्रेम, 0x02=कॉन्फ़िग, 0x04=ऑडियो
FRAME_ID4 bytesमोनोटोनिकली बढ़ने वाला फ्रेम नंबर, uint32 बिग-एंडियन
TIMESTAMP8 bytesएनकोडर PTS माइक्रोसेकंड में, बिग-एंडियन
DATAवेरिएबलH.264 NAL यूनिट या Opus डेटा

Android का VideoPacket.encode() (commonMain में, ताकि इसका वायर फ़ॉर्मेट JVM यूनिट टेस्ट द्वारा बिना किसी Android डिपेंडेंसी के कवर किया जा सके) और वेब का parseVideoPacket() दोनों स्वतंत्र रूप से इस फ़ॉर्मेट को लागू करते हैं — कोई शेयर्ड सीरियलाइज़ेशन लाइब्रेरी नहीं, केवल एक स्पेक जिसका दोनों पक्ष पालन करते हैं।

Diagram 2
2

डिज़ाइन नोट्स

  • FRAME_ID अहस्ताक्षरित पार्सिंग: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 — अहस्ताक्षरित सुनिश्चित करने के लिए >>> 0 का उपयोग करना आवश्यक है, अन्यथा frameId > 2^31 को नकारात्मक पार्स किया जाएगा, जिससे गलत लॉस डिटेक्शन होगा।
  • FRAME_ID कभी रीसेट नहीं होता: जब ओरिएंटेशन चेंज के लिए एनकोडर पुनर्निर्मित किया जाता है, frameId बढ़ता रहता है (यह ScreenMirrorPipeline में रहता है, एनकोडर में नहीं)। यह वेब साइड को frameId गैप के माध्यम से रोटेशन के दौरान फ्रेम लॉस का पता लगाने देता है।
  • TIMESTAMP एनकोडर PTS का उपयोग करता है: वेब क्लाइंट की घड़ी पर कोई निर्भरता नहीं, क्लॉक ड्रिफ्ट के कारण A/V डिसिंक से बचाता है।
  • ज़ीरो-कॉपी पार्सिंग: वेब पार्सर Uint8Array.subarray() के साथ पेलोड को स्लाइस करता है — मूल WebSocket ArrayBuffer का एक व्यू, कॉपी नहीं।

वीडियो डीकोडिंग पाइपलाइन (वेब)

WebCodecs VideoDecoder

वेब क्लाइंट हार्डवेयर डीकोडिंग के लिए WebCodecs API के VideoDecoder का उपयोग करता है। MediaSource Extensions या WebRTC की तुलना में, WebCodecs डीकोडिंग प्रक्रिया पर बारीक नियंत्रण प्रदान करता है — कोई जिटर बफ़र नहीं, कोई कंटेनर लेयर नहीं, और डीकोड किए गए VideoFrame ऑब्जेक्ट्स को सीधे WebGL टेक्सचर के रूप में अपलोड किया जा सकता है।

const decoder = new VideoDecoder({
    output: (frame) => this.renderFrame(frame),
    error: (e) => {
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.onError?.(e)
    },
})
decoder.configure({
    codec,                              // e.g. 'avc1.42c01e', SPS NAL से पढ़ा गया
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

मुख्य कॉन्फ़िगरेशन:

  • optimizeForLatency: true — डीकोडर को कम लेटेंसी को प्राथमिकता देने के लिए कहता है, कोई फ्रेम बफरिंग नहीं
  • hardwareAcceleration: 'prefer-hardware' — GPU डीकोडिंग को प्राथमिकता दें
  • avc: { format: 'annexb' } — प्रत्येक IDR से पहले इनलाइन SPS/PPS के साथ Annex-B फ़ॉर्मेट का उपयोग करें
  • कोडेक स्ट्रिंग स्वयं हार्डकोडेड नहीं है — extractAvc1CodecString() कॉन्फ़िग ब्लॉब में पहले SPS NAL से सीधे प्रोफ़ाइल/कॉम्पैट/लेवल बाइट्स पढ़ता है

ग्रीन स्क्रीन समस्या और स्टार्टअप सीक्वेंस

एनकोडर VirtualDisplay के वास्तविक स्क्रीन कंटेंट रेंडर करने से पहले अपना पहला IDR फ्रेम उत्पन्न करता है — यह एक खाली (हरा) फ्रेम होता है। यदि वेब साइड इस फ्रेम को डीकोड करता है, तो उपयोगकर्ता को तब तक हरी फ्लैश दिखाई देगी जब तक स्क्रीन कंटेंट बदलकर नया फ्रेम ट्रिगर नहीं करता।

समाधान: स्टार्टअप पर, वेब साइड screenMirrorVideoCodec GraphQL क्वेरी के माध्यम से कैश किए गए कॉन्फ़िग को खींचता है लेकिन बंडल किए गए कीफ्रेम को डीकोड नहीं करता। इसके बजाय, यह waitingForIdr = true सेट करने के लिए video.requestIdr() कॉल करता है (जब तक IDR नहीं आता सभी P-फ्रेम्स को ड्रॉप करता है), फिर लॉस रिकवरी के लिए उपयोग किए जाने वाले उसी म्यूटेशन पर नया IDR अनुरोध करने के लिए requestKeyFrame() कॉल करता है। जब तक नया IDR आता है, VirtualDisplay के पास वास्तविक स्क्रीन कंटेंट होता है।

video.requestIdr()      // P-फ्रेम्स ड्रॉप करें, IDR की प्रतीक्षा करें
await requestKeyFrame() // GraphQL म्यूटेशन के माध्यम से नया IDR अनुरोध करें

onFirstFrameRendered कॉलबैक handleVideo() के बजाय renderFrame() से बंधा होता है, यह सुनिश्चित करता है कि UI केवल वास्तविक फ्रेम रेंडर होने के बाद अपडेट हो — न कि केवल प्राप्त होने पर।

WebGL2 रेंडरिंग

ज़ीरो-कॉपी GPU डायरेक्ट रेंडर

डीकोड किए गए VideoFrame ऑब्जेक्ट्स को सीधे WebGL2 टेक्सचर के रूप में अपलोड किया जाता है, कभी CPU से नहीं गुजरते:

VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas

gl.texImage2D VideoFrame को पिक्सल स्रोत के रूप में स्वीकार करता है। ब्राउज़र आंतरिक रूप से YUV→RGB रूपांतरण और GPU अपलोड को हैंडल करता है — कोई ImageData CPU कॉपी नहीं। MirrorGLRenderer Canvas 2D drawImage() पर फॉलबैक करता है यदि getContext('webgl2', ...) विफल होता है, ताकि पुराने ब्राउज़रों को अभी भी (थोड़ी अधिक-लेटेंसी) तस्वीर मिले।

desynchronized कॉन्टेक्स्ट

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // कंपोज़िटर को बायपास करें, सीधे स्क्रीन पर लिखें
    preserveDrawingBuffer: true, // स्क्रीनशॉट के लिए बफ़र संरक्षित करें
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true ब्राउज़र कंपोज़िटर को बायपास करता है, सीधे स्क्रीन पर लिखता है, लगभग 1 फ्रेम डिस्प्ले लेटेंसी (~16ms @ 60fps) बचाता है।

preserveDrawingBuffer: true ड्रॉइंग बफ़र को संरक्षित करता है ताकि canvas.toDataURL() स्क्रीनशॉट कंटेंट पढ़ सकें। डिफ़ॉल्ट false के साथ, कंपोज़िशन के बाद बफ़र साफ़ हो जाता है, जिससे काले स्क्रीनशॉट बनते हैं।

शेडर स्वयं जानबूझकर न्यूनतम है — एक फुलस्क्रीन-ट्राइएंगल वर्टेक्स शेडर और एक वन-लाइन फ़्रैगमेंट शेडर जो टेक्सचर को सैंपल करता है — क्योंकि प्रति फ्रेम केवल एक ही काम करने की आवश्यकता है "इस टेक्सचर को स्क्रीन पर रखो।"

Canvas ऑटो-फ़िट

Canvas बैकिंग स्टोर साइज़ VideoFrame.displayWidth/Height से सेट किया जाता है जब भी यह बदलता है। CSS साइज़ को fitCanvasToWrapper() द्वारा रैपर कंटेनर में फ़िट किया जाता है जबकि आस्पेक्ट रेशियो संरक्षित रहता है (आवश्यकतानुसार लेटरबॉक्सिंग या पिलरबॉक्सिंग)। Canvas के पैरेंट एलिमेंट पर एक ResizeObserver कंटेनर के रीसाइज़ होने पर इस फ़िट को फिर से चलाता है, ताकि वीडियो कभी स्ट्रेच न हो।

लॉस डिटेक्शन और एरर रिकवरी

FrameId गैप डिटेक्शन

प्रत्येक वीडियो फ्रेम एक मोनोटोनिकली बढ़ने वाला frameId ले जाता है। डीकोडर lastFrameId को ट्रैक करता है; यदि नए फ्रेम का frameId > lastFrameId + 1 है, तो फ्रेम खो गए हैं:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Loss: बाद के P-फ्रेम्स ड्रॉप करें, नया IDR अनुरोध करें
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

waitingForIdr स्टेट मशीन

waitingForIdr एक सरल दो-अवस्था मशीन है:

Diagram 3
3

अवस्थाव्यवहार
NORMALसभी फ्रेम्स को सामान्य रूप से डीकोड करें
WAITING_FOR_IDRसभी P-फ्रेम्स ड्रॉप करें, केवल IDR फ्रेम्स डीकोड करें; IDR आने पर NORMAL पर रीसेट करें

परिदृश्य जो WAITING_FOR_IDR में संक्रमण को ट्रिगर करते हैं:

  1. स्टार्टअप पर: पुराने GraphQL कीफ्रेम को स्किप करें, वास्तविक IDR की प्रतीक्षा करें
  2. पैकेट लॉस पर: अनडीकोडेबल P-फ्रेम्स ड्रॉप करें, IDR रिकवरी की प्रतीक्षा करें
  3. डीकोडर एरर पर: डीकोडर रीसेट करें, IDR की प्रतीक्षा करें
  4. कॉन्फ़िग बदलने पर: ओरिएंटेशन/क्वालिटी बदलाव के बाद बचे हुए P-फ्रेम्स ड्रॉप करें

डीकोडर एरर रिकवरी

जब VideoDecoder.onerror फायर होता है, तो पाइपलाइन लेयर (screen-mirror-pipeline.ts) में decoderNeedsReset = true सेट किया जाता है। अगले IDR फ्रेम पर, डीकोडर को फिर से GraphQL पर राउंड-ट्रिप करने के बजाय कैश किए गए SPS/PPS के साथ पुनर्कॉन्फ़िगर किया जाता है:

if (decoderNeedsReset) {
    if (!packet.isKeyFrame || !cachedConfig) return
    video.configure(cachedConfig)
    decoderNeedsReset = false
}

बैकप्रेशर और टाइमस्टैम्प डिडुप्लिकेशन

यदि decoder.decodeQueueSize > 5, तो आने वाले P-फ्रेम्स को क्यू करने के बजाय ड्रॉप किया जाता है — 5 की सीमा (2 के बजाय) अनावश्यक रुकावट पैदा किए बिना हार्डवेयर डीकोडर स्टार्टअप लेटेंसी को सहन करती है। अलग से, रेंडर करने के बाद, lastRenderedPts रिकॉर्ड किया जाता है; जिस फ्रेम का टाइमस्टैम्प पुराना है (आउट-ऑफ-ऑर्डर आगमन) उसे ड्रॉप किया जाता है जब तक कि वह कीफ्रेम न हो:

if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
    return
}

ओरिएंटेशन चेंज हैंडलिंग

एनकोडर पुनर्निर्माण

ScreenMirrorService में एक OrientationEventListener हर सेंसर कॉलबैक पर डिस्प्ले के rotation की तुलना कैश किए गए isPortrait फ़्लैग से करता है; केवल एक वास्तविक पोर्ट्रेट/लैंडस्केप फ्लिप pipeline.onOrientationChanged() को कॉल करता है और टच कोऑर्डिनेट स्केलिंग के लिए उपयोग किए जाने वाले एक्सेसिबिलिटी स्क्रीन-साइज़ कैश को अमान्य करता है।

Diagram 4
4

rebuildEncoderAndResize():

  1. नए डाइमेंशन पर एक नया एनकोडर बनाएं (जैसे लैंडस्केप 1920×1080)
  2. VirtualDisplay.surface को नए एनकोडर के इनपुट Surface पर स्विच करें
  3. पुराने एनकोडर को रोकें
  4. VirtualDisplay.resize() को नए डाइमेंशन पर करें

Surface स्विच रीसाइज़ से पहले होता है — यह सुनिश्चित करता है कि नया एनकोडर पहले फ्रेम प्राप्त करे, और पुराना एनकोडर गलत डाइमेंशन के फ्रेम प्राप्त करने से पहले रोक दिया जाए। यदि virtualDisplay?.surface = ... थ्रो करता है, तो पुनर्निर्माण रद्द हो जाता है और पाइपलाइन को बिना किसी एनकोडर के छोड़ने के बजाय पुराना एनकोडर चालू रहता है।

कॉन्फ़िग चेंज नोटिफिकेशन

जब नया एनकोडर पहली बार SPS/PPS आउटपुट करता है, तो पाइपलाइन पर pendingConfigBroadcast सेट किया जाता है। जब नए एनकोडर से पहला IDR आता है, तो इसे सामान्य वीडियो पैकेट के रूप में भेजने के बजाय उस कॉन्फ़िग के साथ एक सिंगल screen_mirror_video_codec इवेंट में बंडल किया जाता है।

फिर वेब क्लाइंट, handleConfig() में:

  1. नए SPS/PPS के साथ डीकोडर को पुनर्कॉन्फ़िगर करता है
  2. बंडल किए गए IDR फ्रेम को तुरंत डीकोड करता है
  3. अभी भी ट्रांज़िट में पुराने एनकोडर के किसी भी बचे हुए P-फ्रेम को ड्रॉप करने के लिए video.requestIdr() कॉल करता है
  4. एक साफ, नया IDR अनुरोध करने के लिए requestKeyFrame() कॉल करता है

चरण 3-4 एक सुरक्षा जाल हैं — भले ही नए एनकोडर के पहले IDR में गलत डाइमेंशन हों (एसिंक रीसाइज़ विंडो के दौरान), वेब क्लाइंट जल्दी से सही डाइमेंशन पर वापस आ जाता है। handleConfig() यदि आने वाला कॉन्फ़िग बाइट-आइडेंटिकल है कैश किए गए कॉन्फ़िग से, तो शॉर्ट-सर्किट भी करता है, क्योंकि अपरिवर्तित बाइट्स के साथ डीकोडर को पुनर्कॉन्फ़िगर करना एक नो-ऑप है जो फिर भी रिकवर करने के लिए एक IDR खर्च करता है।

सिस्टम MediaProjection लाइफसाइकिल

समस्या

उपयोगकर्ता Android सिस्टम नोटिफिकेशन बार के माध्यम से सिस्टम-लेवल स्क्रीन कास्ट (MediaProjection) को बंद कर सकते हैं, न कि ऐप के UI के माध्यम से। इस मामले में, ScreenMirrorService को पता नहीं चलता कि कास्टिंग बंद हो गई है — running true रहता है, वेब क्लाइंट screenMirrorState क्वेरी करता है और true प्राप्त करता है, लेकिन कोई वीडियो फ्रेम नहीं आता, और पेज लोडिंग पर अटक जाता है।

MediaProjection.Callback

MediaProjection एक Callback.onStop() कॉलबैक प्रदान करता है जो तब फायर होता है जब सिस्टम कास्टिंग बंद करता है। ScreenMirrorPipeline.startEncoders() इस कॉलबैक को रजिस्टर करता है और onStop() में ScreenMirrorService.instance?.stop() कॉल करता है:

projection.registerCallback(object : MediaProjection.Callback() {
    override fun onStop() {
        ScreenMirrorService.instance?.stop()
    }
}, null)

Diagram 5
5

Service.stop() की जिम्मेदारियां

stop() स्पष्ट स्टॉप पॉइंट है, जो वेब क्लाइंट को सूचित करने और सेवा को रोकने के लिए जिम्मेदार है:

fun stop() {
    if (!running) return  // रिकर्जन रोकें
    running = false
    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf()
}

if (!running) return गार्ड रिकर्जन को रोकता है: onStop() → stop() → stopSelf() → onDestroy() → pipeline.stop() → projection.stop() → onStop() → stop() (इस बिंदु पर running=false, तुरंत लौटता है)।

वेब-साइड हैंडलिंग

जब वेब क्लाइंट {"running":false} इवेंट प्राप्त करता है, तो यह आइडल स्थिति में रीसेट होता है और स्टार्ट बटन दिखाता है:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → स्ट्रीम से कनेक्ट करें
}

रिमोट कंट्रोल: टच इंजेक्शन

स्क्रीन मिररिंग डिफ़ॉल्ट रूप से एक-तरफ़ा है (केवल वीडियो/ऑडियो); रिमोट कंट्रोल ऑप्ट-इन है और इसके लिए उपयोगकर्ता को एक बार PlainApp की Accessibility Service सक्षम करनी होती है, क्योंकि Android के पास AccessibilityService.dispatchGesture() के बाहर मनमाने टच इवेंट इंजेक्ट करने का कोई सार्वजनिक API नहीं है।

Diagram 6
6

कोऑर्डिनेट नॉर्मलाइज़ेशन (वेब)

<canvas> के ऊपर एक ट्रांसपेरेंट ओवरले होता है जो पॉइंटर इवेंट्स को कैप्चर करता है। normalizeCoords() कच्चे clientX/clientY को वास्तविक वीडियो कंटेंट एरिया के सापेक्ष [0,1] कोऑर्डिनेट में बदलता है — ओवरले के बाउंडिंग बॉक्स के नहीं — Canvas के बैकिंग-स्टोर आस्पेक्ट रेशियो बनाम उसके रेंडर किए गए कंटेनर आस्पेक्ट रेशियो से लेटरबॉक्स/पिलरबॉक्स ऑफ़सेट की गणना करके:

if (videoAspect > containerAspect) {
    // Letterboxed top/bottom
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarboxed left/right
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

एक पॉइंटर प्रेस एक GestureState शुरू करता है जो प्रारंभ स्थिति/समय को ट्रैक करता है; 10px से कम मूवमेंट के साथ 500ms होल्ड LONG_PRESS में बदल जाता है, उस सीमा से आगे की गति SWIPE बन जाती है, और त्वरित रिलीज़ TAP होती है। एक विज़ुअल टच इंडिकेटर (एक बढ़ता/फीका होता डॉट) ऑपरेटर को फीडबैक देता है कि फोन के प्रतिक्रिया देने से पहले कौन सा जेस्चर पहचाना गया।

GraphQL → AccessibilityService

हर पहचाने गए जेस्चर को एक sendScreenMirrorControl(input) म्यूटेशन के रूप में भेजा जाता है जिसमें एक action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) और नॉर्मलाइज़्ड कोऑर्डिनेट होते हैं। रिज़ॉल्वर dispatchScreenMirrorControl() कॉल करता है, जो नॉर्मलाइज़्ड कोऑर्डिनेट को वास्तविक स्क्रीन साइज़ से गुणा करता है (PlainAccessibilityService.getScreenSize() से, हर ओरिएंटेशन बदलाव पर अमान्य) और PlainAccessibilityService.dispatchControl() को डेलिगेट करता है:

private fun dispatchTap(x: Float, y: Float) {
    val path = Path().apply { moveTo(x, y) }
    val stroke = GestureDescription.StrokeDescription(path, 0, 50)
    dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}

SWIPE और LONG_PRESS एक लंबी स्ट्रोक अवधि या एक बिंदु के बजाय एक लाइन पथ के साथ समान GestureDescription बनाते हैं; SCROLL को (x, y) से (x, y + deltaY) तक एक सिंथेटिक स्वाइप के रूप में लागू किया जाता है जो ±500px पर क्लैम्प किया गया है। चार वैश्विक क्रियाएं (BACK/HOME/RECENTS/LOCK_SCREEN) जेस्चर डिस्पैच को पूरी तरह से छोड़ देती हैं और सीधे performGlobalAction() कॉल करती हैं। यदि Accessibility Service सक्षम नहीं है, तो रिज़ॉल्वर इनपुट को चुपचाप ड्रॉप करने के बजाय GraphQLError थ्रो करता है, ताकि वेब UI उपयोगकर्ता को इसे सक्षम करने का संकेत दे सके।

ऑडियो पाइपलाइन

Android Opus एन्कोडिंग

MediaCodecAudioEncoder AudioPlaybackCaptureConfiguration (उसी MediaProjection से निर्मित) का उपयोग करके AudioRecord के माध्यम से सिस्टम ऑडियो कैप्चर करता है, कच्चे PCM को MediaCodec Opus एनकोडर में फीड करता है। इसके लिए Android 10+ और RECORD_AUDIO अनुमति की आवश्यकता है — पुराने डिवाइसों पर या अनुमति के बिना, start() एक चेतावनी लॉग करता है और ऑडियो को पूरी तरह से स्किप करता है (वीडियो काम करता रहता है)। एनकोडेड Opus पैकेट्स उसी VideoPacket प्रोटोकॉल में लपेटे जाते हैं (FLAG_AUDIO सेट के साथ) और वीडियो पैकेट के SCREEN_MIRROR_AUDIO WebSocket चैनल को साझा करते हैं।

वेब Opus डीकोडिंग

ScreenMirrorAudioPipeline Opus डेटा को डीकोड करने के लिए WebCodecs AudioDecoder का उपयोग करता है, AudioData आउटपुट करता है जिसे <audio> एलिमेंट पर रूट किया जाता है। ऑडियो फ्रेम timestamp का उपयोग A/V सिंक के लिए किया जाता है — वीडियो फ्रेम्स के समान समय आधार (एनकोडर PTS, माइक्रोसेकंड में) साझा करता है, इसलिए दो स्ट्रीम के बीच अलग घड़ी नेगोशिएशन की आवश्यकता नहीं है।

परफॉर्मेंस ऑप्टिमाइज़ेशन

ज़ीरो-कॉपी पथ

पथविधि
VirtualDisplay → एनकोडर SurfaceGPU डायरेक्ट, Surface पासथ्रू
VideoDecoder → VideoFrame → WebGL टेक्सचरgl.texImage2D(VideoFrame), GPU डायरेक्ट
WebSocket रिसीव → VideoPacket पार्सUint8Array.subarray() एक व्यू है, कॉपी नहीं

avccToAnnexB ऑप्टिमाइज़ेशन

कुछ Android एनकोडर AVCC फ़ॉर्मेट (4-बाइट लेंथ प्रीफ़िक्स) आउटपुट करते हैं, जिसे WebCodecs डीकोडिंग के लिए Annex-B फ़ॉर्मेट (00 00 00 01 स्टार्ट कोड) में बदलने की आवश्यकता होती है।

प्रारंभिक कार्यान्वयन में प्रति-बाइट बॉक्सिंग के साथ ArrayList<Byte> का उपयोग किया गया था — 50KB IDR फ्रेम 50,000 java.lang.Byte बॉक्सिंग ऑपरेशन उत्पन्न करता था, जिससे भारी GC दबाव बनता था। ऑप्टिमाइज़ेशन में टू-पास स्कैन + copyInto (जो JVM पर System.arraycopy इंट्रिन्सिक पर मैप होता है) का उपयोग किया गया है:

// पहला पास: आउटपुट साइज़ की गणना करें
var outSize = 0
// दूसरा पास: बल्क कॉपी
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

P-फ्रेम ड्रॉप रणनीति

डीकोडर इनिशियलाइज़ेशन के दौरान धीमा हो सकता है। यदि P-फ्रेम क्यू बहुत लंबी है, तो लेटेंसी जमा होती है। डीकोड क्यू साइज़ सीमा > 5 (बजाय > 2) सेट की गई है ताकि हार्डवेयर डीकोडर इनिशियलाइज़ेशन के दौरान अत्यधिक फ्रेम लॉस से बचा जा सके।

IDR अनुरोध डिडुप्लिकेशन

waitingForIdr गार्ड सुनिश्चित करता है कि प्रति लॉस इवेंट केवल एक IDR अनुरोध हो, IDR आने की प्रतीक्षा करते समय डुप्लिकेट अनुरोधों को रोकता है।

डिज़ाइन पैटर्न्स रिकैप

पैटर्नस्थानकारण
स्टेट मशीनwaitingForIdr फ़्लैगस्पष्ट P-फ्रेम ड्रॉप/रिकवरी स्टेट ट्रांज़िशन
रिकर्जन गार्डstop() में if (!running) returnonStop → stop → onDestroy → pipeline.stop → projection.stop → onStop रिकर्जन को रोकता है
ज़ीरो-कॉपी पाइपलाइनVideoFrame → gl.texImage2DGPU डायरेक्ट टेक्सचर अपलोड, कोई CPU कॉपी नहीं
टू-पास स्कैनavccToAnnexBसाइज़ प्री-कंप्यूट करें, सिंगल अलॉकेशन + बल्क कॉपी, बॉक्सिंग समाप्त करता है
बंडल्ड इवेंटSPS/PPS + IDR एक इवेंट मेंकॉन्फ़िग बदलाव एक इवेंट में रीकॉन्फ़िगरेशन + फर्स्ट-फ्रेम डीकोड पूरा करता है
कॉलबैक पृथक्करणonFirstFrameRendered बनाम onDisconnected बनाम onScreenMirrorOffफर्स्ट-फ्रेम रेंडर, ट्रांसपोर्ट विफलता, और फोन-साइड स्टॉप के बीच स्पष्ट अंतर
सुरक्षा जालrequestIdr() + requestKeyFrame()कॉन्फ़िग बदलाव के बाद बचे हुए फ्रेम्स ड्रॉप करें + साफ IDR अनुरोध करें
PTS डिडुप्लिकेशनtimestamp < lastRenderedPtsआउट-ऑफ-ऑर्डर फ्रेम्स ड्रॉप करें
FrameId गैपframeId > lastFrameId + 1ACK-मुक्त पैकेट लॉस डिटेक्शन
desynchronized कॉन्टेक्स्टWebGL2 desynchronized: trueकंपोज़िटर को बायपास करें, 1 फ्रेम लेटेंसी बचाएं
स्पष्ट फेल फास्टsendScreenMirrorControl GraphQLError थ्रो करता है"एक्सेसिबिलिटी डिसेबल्ड" को सतह पर लाता है, इनपुट को चुपचाप ड्रॉप करने के बजाय

आगे पढ़ें

  • WebCodecs API — MDN दस्तावेज़ीकरण, VideoDecoder/AudioDecoder इंटरफ़ेस को कवर करता है।