ব্লগে ফিরে যান
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

লেয়ারAndroidWeb
স্ক্রিন ক্যাপচার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 ও width/height অ্যালাইনমেন্ট (একবার 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 big-endian
TIMESTAMP8 bytesএনকোডার PTS মাইক্রোসেকেন্ডে, big-endian
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-এর একটি ভিউ, কপি নয়।

ভিডিও ডিকোডিং পাইপলাইন (Web)

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,                              // যেমন '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 থেকে সরাসরি profile/compat/level বাইট পড়ে

গ্রিন স্ক্রিন সমস্যা এবং স্টার্টআপ সিকোয়েন্স

এনকোডার VirtualDisplay-এ প্রকৃত স্ক্রিন কন্টেন্ট রেন্ডার হওয়ার আগেই তার প্রথম IDR ফ্রেম তৈরি করে — এটি একটি ফাঁকা (সবুজ) ফ্রেম। ওয়েব সাইড যদি এই ফ্রেম ডিকোড করে, ব্যবহারকারী একটি সবুজ ফ্ল্যাশ দেখবেন যতক্ষণ না স্ক্রিন কন্টেন্ট পরিবর্তিত হয়ে একটি নতুন ফ্রেম ট্রিগার করে।

সমাধান: স্টার্টআপে, ওয়েব সাইড screenMirrorVideoCodec GraphQL কোয়েরির মাধ্যমে ক্যাশ করা কনফিগ পুল করে কিন্তু বান্ডেল করা কীফ্রেম ডিকোড করে না। পরিবর্তে, এটি video.requestIdr() কল করে waitingForIdr = true সেট করে (একটি IDR না আসা পর্যন্ত সব P-ফ্রেম ড্রপ করে), তারপর requestKeyFrame() কল করে একটি ফ্রেশ IDR রিকোয়েস্ট করে — একই মিউটেশন ব্যবহার করে যা লস রিকভারির জন্য ব্যবহৃত হয়। নতুন 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 getContext('webgl2', ...) ব্যর্থ হলে Canvas 2D drawImage()-এ ফলব্যাক করে, তাই পুরনো ব্রাউজারগুলোও (সামান্য বেশি লেটেন্সি সহ) ছবি পায়।

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) {
        // লস: পরবর্তী 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 ফায়ার হয়, decoderNeedsReset = true পাইপলাইন লেয়ারে (screen-mirror-pipeline.ts) সেট করা হয়। পরবর্তী 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() করুন

সারফেস সুইচ resize-এর আগে হয় — নিশ্চিত করে যে নতুন এনকোডার প্রথমে ফ্রেম পায়, এবং পুরনো এনকোডার ভুল-ডাইমেনশনের ফ্রেম পাওয়ার আগে বন্ধ হয়। যদি virtualDisplay?.surface = ... থ্রো করে, পুনর্নির্মাণ বাতিল হয় এবং পাইপলাইনকে কোনো এনকোডার ছাড়া রাখার পরিবর্তে পুরনো এনকোডার চালু রাখে।

কনফিগ চেঞ্জ নোটিফিকেশন

যখন নতুন এনকোডার প্রথমবার SPS/PPS আউটপুট করে, পাইপলাইনে pendingConfigBroadcast সেট করা হয়। যখন নতুন এনকোডার থেকে প্রথম IDR আসে, এটি সেই কনফিগের সাথে একটি একক screen_mirror_video_codec ইভেন্টে বান্ডেল করা হয়, সাধারণ ভিডিও প্যাকেট হিসেবে পাঠানোর পরিবর্তে।

ওয়েব ক্লায়েন্ট তখন, handleConfig()-এ:

  1. নতুন SPS/PPS দিয়ে ডিকোডার রিকনফিগার করে
  2. বান্ডেল করা IDR ফ্রেম অবিলম্বে ডিকোড করে
  3. video.requestIdr() কল করে পুরনো এনকোডার থেকে এখনও ট্রানজিটে থাকা যেকোনো অবশিষ্ট P-ফ্রেম ড্রপ করতে
  4. requestKeyFrame() কল করে একটি ক্লিন, ফ্রেশ IDR রিকোয়েস্ট করতে

ধাপ 3-4 একটি সেফটি নেট — এমনকি যদি নতুন এনকোডারের প্রথম IDR-এর ডাইমেনশন ভুল হয় (অ্যাসিঙ্ক resize উইন্ডোর সময়), ওয়েব ক্লায়েন্ট দ্রুত সঠিক ডাইমেনশনে রিকভার করে। 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

কোঅর্ডিনেট নরমালাইজেশন (Web)

<canvas>-এর উপরে একটি ট্রান্সপারেন্ট ওভারলে থাকে এবং পয়েন্টার ইভেন্ট ক্যাপচার করে। normalizeCoords() একটি কাঁচা clientX/clientY-কে [0,1] কোঅর্ডিনেটে রূপান্তর করে যা প্রকৃত ভিডিও কন্টেন্ট এলাকা — ওভারলের বাউন্ডিং বক্স নয় — এর সাপেক্ষে, ক্যানভাসের ব্যাকিং-স্টোর অ্যাসপেক্ট রেশিও বনাম তার রেন্ডার করা কন্টেইনার অ্যাসপেক্ট রেশিও থেকে লেটারবক্স/পিলারবক্স অফসেট গণনা করে:

if (videoAspect > containerAspect) {
    // লেটারবক্সড উপরে/নিচে
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // পিলারবক্সড বামে/ডানে
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

একটি পয়েন্টার প্রেস একটি GestureState শুরু করে যা স্টার্ট পজিশন/টাইম ট্র্যাক করে; 500ms ধরে < 10px মুভমেন্ট হলে তা 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 চ্যানেল শেয়ার করে।

Web Opus ডিকোডিং

ScreenMirrorAudioPipeline Opus ডেটা ডিকোড করতে WebCodecs AudioDecoder ব্যবহার করে, AudioData আউটপুট একটি <audio> এলিমেন্টে রুট করা হয়। অডিও ফ্রেমের timestamp A/V সিঙ্কের জন্য ব্যবহার করা হয় — ভিডিও ফ্রেমের মতো একই টাইম বেস (এনকোডার PTS, মাইক্রোসেকেন্ডে) শেয়ার করে, তাই দুটি স্ট্রিমের মধ্যে আলাদা ক্লক নেগোশিয়েশনের প্রয়োজন নেই।

পারফরম্যান্স অপ্টিমাইজেশন

জিরো-কপি পাথ

পাথপদ্ধতি
VirtualDisplay → এনকোডার SurfaceGPU direct, Surface পাসথ্রু
VideoDecoder → VideoFrame → WebGL টেক্সচারgl.texImage2D(VideoFrame), GPU direct
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 ইন্টারফেস কভার করে।