| 1 | // native-motion.mjs — the app shares a 1080p page scrolling at 30 fps |
| 2 | // and the browser measures what actually arrives: frames a second, |
| 3 | // frame size and freezes on the received screen, beside the app's own |
| 4 | // account of its sender (encoder, limitation, bitrate, path). |
| 5 | // |
| 6 | // Written for a report on 2026-09-24 that a screen shared from the Mac |
| 7 | // app "isn't keeping up with what I'm doing on the screen". It measures |
| 8 | // each "Optimise for" mode twice — on an open local link, and with the |
| 9 | // app's uplink estimate capped the way a thin home connection caps it — |
| 10 | // and holds motion (and the balanced default) to a frame rate a scroll |
| 11 | // can be followed at. |
| 12 | // |
| 13 | // Run by TestNativeMacScreenMotion (make native-browser). Environment: |
| 14 | // MOTION_MODES comma-separated modes, default "balanced,detail,motion" |
| 15 | // MOTION_UPLINKS comma-separated caps in bit/s, 0 for none, default "0,1500000" |
| 16 | // MOTION_ASSERT "0" to measure without asserting |
| 17 | import { chromium } from 'playwright'; |
| 18 | import { spawn } from 'node:child_process'; |
| 19 | import { createInterface } from 'node:readline'; |
| 20 | |
| 21 | const [origin, room, binary] = process.argv.slice(2); |
| 22 | const modes = (process.env.MOTION_MODES || 'balanced,detail,motion').split(',').filter(Boolean); |
| 23 | const uplinks = (process.env.MOTION_UPLINKS || '0,1500000').split(',').map(Number); |
| 24 | const asserting = process.env.MOTION_ASSERT !== '0'; |
| 25 | // A scroll followed at fewer frames than this reads as a slideshow. |
| 26 | const MOTION_FLOOR_FPS = Number(process.env.MOTION_FLOOR_FPS || 15); |
| 27 | |
| 28 | const browser = await chromium.launch({ headless: true, args: [ |
| 29 | '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', |
| 30 | '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', |
| 31 | '--mute-audio', |
| 32 | ] }); |
| 33 | const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| 34 | await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| 35 | const page = await context.newPage(); |
| 36 | await page.addInitScript(() => { |
| 37 | const Native = window.RTCPeerConnection; |
| 38 | window.__nativeInteropPCs = new Set(); |
| 39 | window.RTCPeerConnection = class extends Native { |
| 40 | constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); } |
| 41 | }; |
| 42 | }); |
| 43 | let native; |
| 44 | let failed = false; |
| 45 | try { |
| 46 | await page.goto(`${origin}/m/${room}`); |
| 47 | await page.waitForFunction(() => !!window.meet); |
| 48 | native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| 49 | native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| 50 | let sample = null; |
| 51 | let exited = false; |
| 52 | native.on('exit', () => { exited = true; }); |
| 53 | createInterface({ input: native.stdout }).on('line', (line) => { |
| 54 | try { |
| 55 | const value = JSON.parse(line); |
| 56 | if (value.stats) sample = value; |
| 57 | } catch {} |
| 58 | }); |
| 59 | native.stderr.resume(); |
| 60 | const wait = async (predicate, label, seconds = 35) => { |
| 61 | const until = Date.now() + seconds * 1000; |
| 62 | while (Date.now() < until) { |
| 63 | if (exited) throw new Error('native executable exited'); |
| 64 | if (await predicate()) return; |
| 65 | await new Promise((r) => setTimeout(r, 250)); |
| 66 | } |
| 67 | throw new Error(label + ': ' + JSON.stringify(sample)); |
| 68 | }; |
| 69 | const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000)); |
| 70 | await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.videoBytesSent > 0, 'native and browser connect'); |
| 71 | |
| 72 | // The received screen's counters, found by the share tile's own track. |
| 73 | const received = () => page.evaluate(async () => { |
| 74 | const v = document.querySelector('#tiles figure.calls-tile-share video'); |
| 75 | const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null; |
| 76 | if (!track) return null; |
| 77 | for (const pc of window.__nativeInteropPCs) { |
| 78 | if (pc.connectionState === 'closed') continue; |
| 79 | const stats = await pc.getStats(); |
| 80 | for (const r of stats.values()) { |
| 81 | if (r.type === 'inbound-rtp' && r.kind === 'video' && r.trackIdentifier === track.id) { |
| 82 | return { |
| 83 | at: r.timestamp, decoded: r.framesDecoded || 0, width: r.frameWidth || 0, height: r.frameHeight || 0, |
| 84 | freezes: r.freezeCount || 0, frozen: r.totalFreezesDuration || 0, |
| 85 | jitter: r.jitterBufferDelay || 0, emitted: r.jitterBufferEmittedCount || 0, |
| 86 | codec: (stats.get(r.codecId) || {}).mimeType || '', decoder: r.decoderImplementation || '', |
| 87 | }; |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | return null; |
| 92 | }); |
| 93 | |
| 94 | const results = []; |
| 95 | for (const mode of modes) { |
| 96 | native.stdin.write(`screen-mode ${mode}\n`); |
| 97 | native.stdin.write('screen-motion\n'); |
| 98 | await wait(() => sample?.sharingScreen === true, 'native shares the moving screen'); |
| 99 | await wait(async () => (await received())?.decoded > 30, 'the browser decodes the moving screen'); |
| 100 | for (const uplink of uplinks) { |
| 101 | native.stdin.write(`uplink ${uplink}\n`); |
| 102 | // Long enough for the estimate to ramp or fall and the encoder to |
| 103 | // settle on it; then ten seconds measured. |
| 104 | await sleep(uplink ? 12 : 15); |
| 105 | const a = await received(); |
| 106 | const s0 = sample.stats; |
| 107 | const p0 = sample.syntheticScreenFrames || 0; |
| 108 | await sleep(10); |
| 109 | const b = await received(); |
| 110 | const s1 = sample.stats; |
| 111 | const p1 = sample.syntheticScreenFrames || 0; |
| 112 | const seconds = (b.at - a.at) / 1000; |
| 113 | const result = { |
| 114 | mode, uplink, |
| 115 | receivedFps: +((b.decoded - a.decoded) / seconds).toFixed(1), |
| 116 | receivedSize: `${b.width}x${b.height}`, |
| 117 | freezes: b.freezes - a.freezes, |
| 118 | frozenSeconds: +(b.frozen - a.frozen).toFixed(2), |
| 119 | jitterBufferMs: b.emitted > a.emitted ? Math.round(((b.jitter - a.jitter) / (b.emitted - a.emitted)) * 1000) : 0, |
| 120 | codec: b.codec, decoder: b.decoder, |
| 121 | sent: `${s1.screenSendWidth}x${s1.screenSendHeight}`, |
| 122 | sentFps: (s1.screenSendFpsTenths || 0) / 10, |
| 123 | producedFps: +((p1 - p0) / seconds).toFixed(1), |
| 124 | capturedFps: (s1.screenCapturedFpsTenths || 0) / 10, |
| 125 | encodedFps: +(((s1.screenFramesEncoded || 0) - (s0.screenFramesEncoded || 0)) / seconds).toFixed(1), |
| 126 | limitation: ['none', 'cpu', 'bandwidth', 'other'][s1.screenLimitation || 0], |
| 127 | sentKbps: Math.round((s1.screenSentBitrate || 0) / 1000), |
| 128 | targetKbps: Math.round((s1.screenTargetBitrate || 0) / 1000), |
| 129 | cameraKbps: Math.round(((s1.videoBytesSent || 0) - (s0.videoBytesSent || 0)) * 8 / seconds / 1000), |
| 130 | encodeMs: (s1.screenEncodeMsTenths || 0) / 10, |
| 131 | hardware: s1.screenHardwareEncoder === 1, |
| 132 | path: ['', 'host', 'srflx', 'prflx', 'relay'][s1.screenPath || 0], |
| 133 | }; |
| 134 | results.push(result); |
| 135 | console.log('MEASURE ' + JSON.stringify(result)); |
| 136 | } |
| 137 | native.stdin.write('uplink 0\n'); |
| 138 | native.stdin.write('screen-off\n'); |
| 139 | await wait(() => sample?.sharingScreen === false, 'native stops sharing'); |
| 140 | await wait(async () => (await received()) === null, 'the browser drops the screen'); |
| 141 | } |
| 142 | |
| 143 | if (asserting) { |
| 144 | for (const r of results) { |
| 145 | if (r.mode === 'motion' || r.mode === 'balanced') { |
| 146 | if (r.receivedFps < MOTION_FLOOR_FPS) { |
| 147 | failed = true; |
| 148 | console.log(`FAIL ${r.mode} at uplink ${r.uplink || 'open'}: ${r.receivedFps} fps received, below ${MOTION_FLOOR_FPS}`); |
| 149 | } else { |
| 150 | console.log(`PASS ${r.mode} at uplink ${r.uplink || 'open'}: ${r.receivedFps} fps received at ${r.receivedSize}`); |
| 151 | } |
| 152 | } |
| 153 | if (r.mode === 'detail' && r.receivedSize !== '1920x1080') { |
| 154 | failed = true; |
| 155 | console.log(`FAIL detail at uplink ${r.uplink || 'open'} gave up resolution: ${r.receivedSize}`); |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | native.stdin.write('leave\n'); |
| 160 | await sleep(1); |
| 161 | } finally { |
| 162 | native?.kill('SIGTERM'); |
| 163 | await browser.close(); |
| 164 | } |
| 165 | if (failed) process.exit(1); |
| 166 | |