Merge branch 'fix/screen-share-motion' into main
Screen share keeps up with motion: "Optimise for" modes (balanced default), screen sender tuning with the camera capped while sharing, H.264 preferred, Mac capture queue 3 with stale-drop and idle repeat, screen send stats in the device panel, the same on the web, and a motion gate in make native-browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 files changed,
+1543
−27
Makefile+1 −1internal/calls/static/room.js+172 −1internal/calls/templates/room.html+4 −0internal/callstest/native_browser_test.go+40 −0native/README.md+25 −2native/apple/Sources/CallsInterop/Interop.swift+29 −3native/apple/Sources/CallsInterop/SyntheticVideo.swift+139 −1native/apple/Sources/CallsKit/Call.swift+73 −0native/apple/Sources/CallsKit/DevicePanel.swift+28 −0native/apple/Sources/CallsKit/MacScreenCapture.swift+69 −10native/apple/Sources/CallsKit/Media.swift+77 −5native/apple/Sources/CallsKit/ScreenSendPolicy.swift+181 −0native/apple/Sources/CallsKit/ScreenSendStats.swift+182 −0native/apple/Tests/CallsKitTests/ScreenSendTests.swift+214 −0test/browser/native-motion.mjs+165 −0test/browser/room-lane.test.mjs+12 −4test/browser/room-share.test.mjs+132 −0
CI passed — run details
diff --git a/Makefile b/Makefile| index 14922c6..7e267e5 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -170,7 +170,7 @@ native-browser: native-mac |
| # The gate runs CallsInterop, which native-mac does not build; without |
| # this it runs whatever binary a previous build left behind. |
| swift build --package-path native/apple --product CallsInterop |
| - CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 5m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/callstest/ |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 10m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|BrowserAuthentication)' -v ./internal/callstest/ |
| # Two apps in one call, sharing screens to each other. Separate from |
| # native-browser because two unsigned apps on one Mac do not reliably |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index 49e10a5..dce1300 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -92,6 +92,8 @@ |
| const speakerRow = $('speaker-row'); |
| const rawRow = $('raw-row'); |
| const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; |
| + const shareModeRow = $('share-mode-row'); |
| + const pickShareMode = $('pick-share-mode'); |
| const DEVICE_KEY = 'meet.devices'; |
| const BACKOFF_MIN = 500; |
| @@ -213,6 +215,14 @@ |
| const RAW_AUDIO_BITRATE = 128000; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| + // §5.4, sending: "Optimise shared screen for". What a screen gives up |
| + // first when the uplink or the encoder cannot carry all of it. The |
| + // first is the default; sharePlan says what each one means. |
| + const SHARE_MODES = [ |
| + ['balanced', 'Balanced'], |
| + ['detail', 'Text and detail'], |
| + ['motion', 'Motion and video'], |
| + ]; |
| const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && |
| !!navigator.mediaDevices && |
| typeof navigator.mediaDevices.getDisplayMedia === 'function'; |
| @@ -452,6 +462,108 @@ |
| }); |
| } |
| + // sharePlan is what a shared screen is sent as, for one "Optimise |
| + // for" mode and the capture's size. Pure, and lifted by name in the |
| + // node test; the native app's ScreenSendPolicy is its twin, and the two |
| + // are meant to agree number for number. |
| + // |
| + // Why it exists (2026-09-24, "the screenshare isn't keeping up"): a |
| + // screen capture left to the browser's defaults keeps every pixel and |
| + // drops frames, at the encoder's default ceiling of about 2.5 Mbit/s |
| + // for 1080p. On anything but a fast uplink that is a few frames a |
| + // second of someone scrolling. |
| + // |
| + // detail full resolution, frames give way — slides, code, text |
| + // balanced frame rate held, full size while the link carries it |
| + // and smaller rather than a slideshow when it cannot |
| + // motion frame rate held, at most 1280 on the long side |
| + // |
| + // Measured in the native gate (ScreenSendPolicy has the numbers): on a |
| + // 1.5 Mbit/s uplink a scrolling 1080p page arrived at 2 fps as before, |
| + // 13 in detail, 19 in balanced and 30 in motion. |
| + // |
| + // contentHint is the browser's own name for the same choice, and |
| + // degradationPreference the sender's; both are set because browsers |
| + // honour one or the other. |
| + function sharePlan(mode, width, height) { |
| + const longest = Math.max(width || 0, height || 0); |
| + const fit = (side) => (longest > side ? longest / side : 1); |
| + const high = { priority: 'high', networkPriority: 'high', maxFramerate: 30 }; |
| + if (mode === 'detail') { |
| + return { |
| + contentHint: 'detail', degradationPreference: 'maintain-resolution', |
| + encoding: Object.assign({ maxBitrate: 3500000, scaleResolutionDownBy: fit(1920) }, high), |
| + }; |
| + } |
| + if (mode === 'motion') { |
| + return { |
| + contentHint: 'motion', degradationPreference: 'maintain-framerate', |
| + encoding: Object.assign({ maxBitrate: 2500000, scaleResolutionDownBy: fit(1280) }, high), |
| + }; |
| + } |
| + return { |
| + contentHint: 'detail', degradationPreference: 'maintain-framerate', |
| + encoding: Object.assign({ maxBitrate: 3000000, scaleResolutionDownBy: fit(1920) }, high), |
| + }; |
| + } |
| + |
| + // cameraCap is the camera's encoding while this tab shares a screen, |
| + // and its defaults again when it stops: the screen is what everyone is |
| + // looking at, and it should have the uplink. `undefined` rather than a |
| + // number is how the API spells "the encoder's own choice". Pure, and |
| + // lifted by name in the node test. |
| + function cameraCap(sharing, height) { |
| + if (!sharing) return { maxBitrate: undefined, scaleResolutionDownBy: 1 }; |
| + return { maxBitrate: 500000, scaleResolutionDownBy: height > 360 ? height / 360 : 1 }; |
| + } |
| + |
| + function shareMode() { |
| + return SHARE_MODES.some(([m]) => m === prefs.shareMode) ? prefs.shareMode : SHARE_MODES[0][0]; |
| + } |
| + |
| + // tuneSender sets one sender's first encoding (and, where given, its |
| + // degradation preference). Best effort for the same reasons as |
| + // tuneAudioSender: a sender parameter needs no SDP and no |
| + // renegotiation, so §5.1 never learns it happened, and a browser that |
| + // refuses carries the call at its own defaults. |
| + function tuneSender(sender, encoding, degradationPreference) { |
| + if (!sender || typeof sender.getParameters !== 'function') return; |
| + let params; |
| + try { |
| + params = sender.getParameters(); |
| + } catch { |
| + return; |
| + } |
| + if (!params.encodings || !params.encodings.length) return; |
| + for (const [key, value] of Object.entries(encoding)) { |
| + if (value === undefined) delete params.encodings[0][key]; |
| + else params.encodings[0][key] = value; |
| + } |
| + if (degradationPreference) params.degradationPreference = degradationPreference; |
| + Promise.resolve().then(() => sender.setParameters(params)).catch(() => { |
| + // Refused, or raced a renegotiation: the next call to this for the |
| + // same sender (a mode change, the next connection) tries again. |
| + }); |
| + } |
| + |
| + // tuneShare puts one connection's screen and camera where the mode |
| + // wants them: the screen at its plan, the camera capped beside it — |
| + // or, with nothing shared, the camera back at its defaults. |
| + function tuneShare(entry) { |
| + if (!entry) return; |
| + const sharing = !!(share && entry.shareTx); |
| + if (sharing) { |
| + const s = share.track.getSettings ? share.track.getSettings() : {}; |
| + const plan = sharePlan(shareMode(), s.width, s.height); |
| + tuneSender(entry.shareTx.sender, plan.encoding, plan.degradationPreference); |
| + } |
| + const camera = senderFor(entry, 'video'); |
| + const track = camera && camera.sender && camera.sender.track; |
| + if (!track) return; |
| + const settings = track.getSettings ? track.getSettings() : {}; |
| + tuneSender(camera.sender, cameraCap(sharing, settings.height || 0)); |
| + } |
| + |
| // startLocal walks down a ladder: the remembered devices, then the |
| // browser's defaults, then audio alone. A camera that is missing or |
| // in use should not cost the call its microphone as well, and a |
| @@ -3792,6 +3904,12 @@ |
| return; |
| } |
| share = { stream, track }; |
| + try { |
| + track.contentHint = sharePlan(shareMode()).contentHint; |
| + } catch { |
| + // A browser without contentHint: the sender's degradation |
| + // preference below says the same thing. |
| + } |
| // The browser's own "Stop sharing" bar ends the track without |
| // telling this file anything else. §5.4 gives it the same ending as |
| // the button. |
| @@ -3823,6 +3941,7 @@ |
| direction: 'sendonly', |
| streams: [share.stream], |
| }); |
| + tuneShare(entry); |
| } catch { |
| // A browser without addTransceiver, or a connection already |
| // closing: that peer simply never sees this screen, and the call |
| @@ -3871,6 +3990,7 @@ |
| const tx = entry.shareTx; |
| if (!tx) return; |
| entry.shareTx = null; |
| + tuneShare(entry); |
| try { |
| if (typeof tx.stop === 'function') { |
| tx.stop(); |
| @@ -4139,7 +4259,7 @@ |
| // live-looking select that silently does nothing is the worse half |
| // of that, so they are disabled and say so. |
| if (devicesToggle) devicesToggle.disabled = !on; |
| - for (const el of [pickCamera, pickMic, pickSpeaker, rawInput]) { |
| + for (const el of [pickCamera, pickMic, pickSpeaker, rawInput, pickShareMode]) { |
| if (el) el.disabled = !on; |
| } |
| } |
| @@ -4587,6 +4707,31 @@ |
| }); |
| } |
| + // "Optimise shared screen for". Applied at once to a share in |
| + // progress — contentHint on the track, parameters on each sender — |
| + // and remembered for the next. |
| + if (pickShareMode && shareModeRow && HAS_DISPLAY_MEDIA) { |
| + for (const [value, label] of SHARE_MODES) { |
| + const option = document.createElement('option'); |
| + option.value = value; |
| + option.textContent = label; |
| + pickShareMode.append(option); |
| + } |
| + pickShareMode.value = shareMode(); |
| + shareModeRow.hidden = false; |
| + pickShareMode.addEventListener('change', () => { |
| + prefs.shareMode = pickShareMode.value; |
| + savePrefs(); |
| + if (!share) return; |
| + try { |
| + share.track.contentHint = sharePlan(shareMode()).contentHint; |
| + } catch { |
| + // No contentHint here: the sender parameters carry it. |
| + } |
| + for (const entry of pcs.values()) tuneShare(entry); |
| + }); |
| + } |
| + |
| // §5.4's gesture. The handler is not async: a rejection escaping one |
| // goes where neither a person nor a gate could see it, so the work is |
| // a named function with a catch of its own — the same shape as the |
| @@ -4812,6 +4957,26 @@ |
| return reports.length === 1 ? reports[0] : null; |
| } |
| + // shareSendReport is this tab's own screen on the way out, from one |
| + // connection's outbound reports: what size and rate it is actually |
| + // leaving at, what (if anything) is holding it back, and which encoder |
| + // is doing the work — the questions "the screenshare isn't keeping up" |
| + // asks. Pure, and lifted by name in the node test. |
| + function shareSendReport(reports, mid) { |
| + if (!mid) return null; |
| + const r = reports.find((x) => x.mid === mid); |
| + if (!r) return null; |
| + return { |
| + width: r.frameWidth || 0, |
| + height: r.frameHeight || 0, |
| + fps: r.framesPerSecond || 0, |
| + limitation: r.qualityLimitationReason || '', |
| + targetBitrate: r.targetBitrate || 0, |
| + encoder: r.encoderImplementation || '', |
| + framesEncoded: r.framesEncoded || 0, |
| + }; |
| + } |
| + |
| // judgeCamera watches one connection's own camera encoder and says |
| // what to do about it: '' to leave it alone, 'repair' to rebuild the |
| // encoder, 'reset' to rebuild the whole connection. |
| @@ -5083,6 +5248,7 @@ |
| if (verdict === 'replay') replayTile(key, t, entry, key !== id); |
| else if (verdict === 'reset') requestReset(id); |
| } |
| + entry.shareSend = shareSendReport(outbound, (entry.shareTx && entry.shareTx.mid) || ''); |
| const tx = localCameraTx(entry); |
| const verdict = judgeCamera(entry, tx, cameraReport(outbound, (tx && tx.mid) || '')); |
| if (verdict === 'repair') await repairCamera(tx); |
| @@ -5273,6 +5439,11 @@ |
| // the message is what draws the tile. |
| sharing: !!share, |
| shares: sharingPeers(), |
| + // And how this tab's own screen is leaving, per connection, as of |
| + // the last stats walk (shareSendReport); the mode it was asked for. |
| + shareMode: shareMode(), |
| + shareSend: Object.fromEntries( |
| + Array.from(pcs, ([id, entry]) => [id, entry.shareSend || null])), |
| // §15.19: what this tab reports, and what it has been told, by |
| // peer id. Peers that never reported are absent. |
| media: localMedia(), |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index ff1fb23..50117f0 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -73,6 +73,10 @@ |
| <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| <div id="raw-row" class="calls-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}} |
| <p id="raw-help" class="calls-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div> |
| + {{/* §5.4: what a shared screen gives up first when the connection |
| + cannot carry all of it. room.js fills the options, remembers the |
| + choice and reveals the row in a browser that can share. */}} |
| + <div id="share-mode-row" hidden>{{template "field-select" dict "ID" "pick-share-mode" "Name" "share-mode" "Label" "Optimise shared screen for" "Help" "Balanced keeps text readable and keeps up with scrolling. Text and detail keeps small print sharp; movement may look jerky. Motion and video keeps movement smooth; text may look softer."}}</div> |
| {{/* Device messages get a live region of their own, like the relay |
| warning: renderStatus owns #status and rewrites it on every |
| render, so a sentence about a camera that would not open has |
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go| index 53da5c7..e4d346f 100644 |
| --- a/internal/callstest/native_browser_test.go |
| +++ b/internal/callstest/native_browser_test.go |
| @@ -310,3 +310,43 @@ func waitNative(t *testing.T, label string, ok func() bool, apps ...*nativeApp) |
| } |
| t.Fatal(label) |
| } |
| + |
| +// TestNativeMacScreenMotion is the app sharing a 1080p page that scrolls |
| +// at 30 fps, measured at the browser: frames a second and frame size on |
| +// the received screen, for each "Optimise for" mode, on an open link and |
| +// under a capped uplink. It holds motion and the balanced default to a |
| +// frame rate a scroll can be followed at, and detail to full resolution |
| +// (test/browser/native-motion.mjs has the thresholds). |
| +func TestNativeMacScreenMotion(t *testing.T) { |
| + rig := newBrowserRig(t) |
| + room, _ := rig.createMeeting(t) |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| + if _, err := os.Stat(binary); err != nil { |
| + t.Fatal("build native interop executable with make native-mac first") |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-motion.mjs"), rig.origin, room, binary) |
| + cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| + input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| + cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input)) |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd.Stderr = cmd.Stdout |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + scanner := bufio.NewScanner(out) |
| + for scanner.Scan() { |
| + t.Log(scanner.Text()) |
| + } |
| + if err := cmd.Wait(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/native/README.md b/native/README.md| index 6e2ed13..8c39352 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -335,8 +335,31 @@ transceiver in its own stream, exactly as the browser sends one, announced with |
| `share {on:true}`; stopping stops the transceiver and renegotiates, so the far |
| side's track ends rather than freezing, and says `share {on:false}`. The menu |
| bar's stop control and a shared window closing end it the same way as the |
| -button. The source is a screencast, so WebRTC gives up frame rate before |
| -resolution, and frames are capped at 1920 on the long side and 30 a second. |
| +button. Frames are capped at 1920 on the long side and 30 a second. |
| + |
| +**Optimise shared screen for** (in the device panel, remembered, applied live |
| +to a share in progress) decides what gives way when the uplink or the encoder |
| +cannot carry everything: **Balanced** (the default) holds the frame rate and |
| +sends full size while the link allows; **Text and detail** holds resolution |
| +and lets frames fall; **Motion and video** holds the frame rate at 1280 on the |
| +long side at most. They are sender parameters (`ScreenSendPolicy`), so nothing |
| +renegotiates. While a screen goes out it asks for H.264 first (Apple's |
| +hardware encoder), with every other codec still offered, and has high priority; |
| +the camera beside it is capped at 500 kbit/s and 360 lines, and given its |
| +defaults back when the share stops. While sharing, the panel shows one line on |
| +how the screen is actually leaving, e.g. "Screen: 1920×1080 at 8 fps, limited |
| +by bandwidth, 1.2 Mbit/s, direct (H264 hardware)". The Mac capture keeps three |
| +frames in its queue, drops a frame that arrives more than 100 ms late, and |
| +repeats the last frame once a second while the screen is still, so a keyframe |
| +can be made for a receiver that asks for one. The browser offers the same three |
| +choices, as `contentHint`, `degradationPreference` and sender parameters. |
| + |
| +The numbers behind the modes come from `TestNativeMacScreenMotion` (in |
| +`make native-browser`): the app shares a 1080p page scrolling at 30 fps with a |
| +window dragged across it, and Chromium measures what arrives, with the app's |
| +uplink estimate open and capped at 1.5 Mbit/s. Before this, a 1.5 Mbit/s uplink |
| +delivered 2 fps at full size. Balanced and motion must now arrive at 15 fps or |
| +more, and detail must keep 1920×1080. |
| On **iPhone** the whole screen is shared through a ReplayKit broadcast |
| extension, which hands its frames to the app over a socket in an App Group |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index ddeadee..28cff12 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -103,6 +103,30 @@ struct Input: Decodable { |
| print("{\"screenSharing\":\(on)}") |
| fflush(stdout) |
| } |
| + // "Optimise for", as the device panel sets it. |
| + if command.hasPrefix("screen-mode ") { |
| + let mode = ScreenShareMode(rawValue: String(command.dropFirst("screen-mode ".count))) |
| + if let mode { await MainActor.run { call.screenMode = mode } } |
| + print("{\"screenMode\":\"\(mode?.rawValue ?? "")\"}") |
| + fflush(stdout) |
| + } |
| + // The motion gate: a 1080p page scrolling at 30 fps. |
| + if command == "screen-motion" { |
| + let screen = await SyntheticScreen(motion: true) |
| + await MainActor.run { Interop.screen = screen } |
| + await call.shareScreen(screen) |
| + let on = await call.sharingScreen |
| + print("{\"screenSharing\":\(on)}") |
| + fflush(stdout) |
| + } |
| + // A thin uplink, for the same gate: `uplink 1200000`, or `uplink 0` |
| + // to lift it. |
| + if command.hasPrefix("uplink ") { |
| + let bps = Int(command.dropFirst("uplink ".count)) ?? 0 |
| + await call.limitUplink(bps > 0 ? bps : nil) |
| + print("{\"uplink\":\(bps)}") |
| + fflush(stdout) |
| + } |
| if command == "screen-off" { |
| await call.stopSharingScreen() |
| print("{\"screenSharing\":false}") |
| @@ -121,9 +145,10 @@ struct Input: Decodable { |
| } |
| } |
| } |
| - // Long enough for the member scenario, screens and all, with room to |
| - // spare: the gate always says `leave` long before this runs out. |
| - for _ in 0..<150 { |
| + // Long enough for the member scenario, screens and all, and for the |
| + // motion gate's three modes: the gate always says `leave` long before |
| + // this runs out. |
| + for _ in 0..<400 { |
| try? await Task.sleep(nanoseconds: 1_000_000_000) |
| let stats = await call.sampleStats() |
| if let data = try? JSONSerialization.data(withJSONObject: [ |
| @@ -140,6 +165,7 @@ struct Input: Decodable { |
| // place a screen says whose it is. |
| "screenCaptions": call.screens.map { screenCaption(call.name(of: $0)) }, |
| "sharingScreen": call.sharingScreen, |
| + "syntheticScreenFrames": Interop.screen?.produced ?? 0, |
| "cameraOpens": Interop.camera.opens, |
| "cameraStatus": { |
| switch call.cameraStatus { |
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift| index 186a6b4..1ce3093 100644 |
| --- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| +++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| @@ -93,11 +93,24 @@ import LiveKitWebRTC |
| private var capturer: RTCVideoCapturer? |
| private var task: Task<Void, Never>? |
| private var ended: (@MainActor () -> Void)? |
| + /// A 1920×1080 page of text scrolling at 30 frames a second, for the |
| + /// motion gate: what someone scrolling a document looks like to the |
| + /// encoder, which is where a screencast source falls behind. |
| + private let page: ScrollingPage? |
| + |
| + init(motion: Bool = false) { page = motion ? ScrollingPage() : nil } |
| func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws { |
| self.source = source |
| self.ended = ended |
| - capturer = RTCVideoCapturer(delegate: source) |
| + let capturer = RTCVideoCapturer(delegate: source) |
| + self.capturer = capturer |
| + if let page { |
| + // Off the main actor and on a timer of its own, as a real capture |
| + // is: the gate measures WebRTC, not this process's main thread. |
| + page.start(source: source, capturer: capturer) |
| + return |
| + } |
| task = Task { @MainActor [weak self] in |
| while !Task.isCancelled { |
| self?.frame() |
| @@ -105,7 +118,10 @@ import LiveKitWebRTC |
| } |
| } |
| } |
| + /// Frames the moving page has produced, for the gate. |
| + var produced: Int { page?.produced ?? 0 } |
| func stop() { |
| + page?.stop() |
| task?.cancel() |
| task = nil |
| source = nil |
| @@ -141,3 +157,125 @@ import LiveKitWebRTC |
| timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000))) |
| } |
| } |
| + |
| +/// Lines of dark "glyphs" on white, twice a screen tall, shown through a |
| +/// 1920×1080 window that moves down 12 rows a frame and wraps: a fast |
| +/// scroll through a dense document, at 30 frames a second, with a window |
| +/// dragged across it. Built once; |
| +/// each frame is row copies into a pooled buffer, on a queue of its own. |
| +final class ScrollingPage: @unchecked Sendable { |
| + static let width = 1920 |
| + static let height = 1080 |
| + private let rows = ScrollingPage.height * 2 |
| + private let texture: [UInt8] |
| + private var offset = 0 |
| + private var frame = 0 |
| + private let queue = DispatchQueue(label: "calls.synthetic-screen", qos: .userInteractive) |
| + private var timer: DispatchSourceTimer? |
| + private var pool: CVPixelBufferPool? |
| + private let lock = NSLock() |
| + private var count = 0 |
| + var produced: Int { lock.withLock { count } } |
| + |
| + init() { |
| + let stride = Self.width * 4 |
| + var texture = [UInt8](repeating: 0xF4, count: stride * rows) |
| + var seed: UInt64 = 0x9E37_79B9_7F4A_7C15 |
| + func random(_ n: Int) -> Int { |
| + seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 |
| + return Int((seed >> 33) % UInt64(n)) |
| + } |
| + let rows = self.rows |
| + texture.withUnsafeMutableBytes { raw in |
| + guard let base = raw.baseAddress else { return } |
| + var line = 8 |
| + while line + 16 < rows { |
| + var x = 40 + random(40) |
| + let end = Self.width - 40 - random(600) |
| + while x < end { |
| + let glyph = 5 + random(9) |
| + let tall = 9 + random(6) |
| + let shade = Int32(20 + random(60)) |
| + for y in (line + 14 - tall)..<(line + 14) { |
| + memset(base + y * stride + x * 4, shade, min(glyph, end - x) * 4) |
| + } |
| + x += glyph + 2 + (random(6) == 0 ? 8 : 0) |
| + } |
| + line += 22 |
| + } |
| + } |
| + self.texture = texture |
| + CVPixelBufferPoolCreate( |
| + kCFAllocatorDefault, nil, |
| + [ |
| + kCVPixelBufferWidthKey: Self.width, kCVPixelBufferHeightKey: Self.height, |
| + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA, |
| + kCVPixelBufferIOSurfacePropertiesKey: [:], |
| + ] as CFDictionary, &pool) |
| + } |
| + |
| + func start(source: RTCVideoSource, capturer: RTCVideoCapturer) { |
| + // Strict: a lenient timer on a background process is coalesced to a |
| + // third of this rate, which measured the timer and not WebRTC. |
| + let timer = DispatchSource.makeTimerSource(flags: .strict, queue: queue) |
| + timer.schedule(deadline: .now(), repeating: .nanoseconds(33_333_333), leeway: .milliseconds(2)) |
| + timer.setEventHandler { [weak self] in |
| + guard let self, let pixel = self.next() else { return } |
| + source.capturer( |
| + capturer, |
| + didCapture: RTCVideoFrame( |
| + buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0, |
| + timeStampNs: Int64(DispatchTime.now().uptimeNanoseconds))) |
| + self.lock.withLock { self.count += 1 } |
| + } |
| + self.timer = timer |
| + timer.resume() |
| + } |
| + |
| + func stop() { |
| + timer?.cancel() |
| + timer = nil |
| + } |
| + |
| + private func bounce(_ travelled: Int, _ span: Int) -> Int { |
| + let lap = travelled % (2 * span) |
| + return lap < span ? lap : 2 * span - lap |
| + } |
| + |
| + private func next() -> CVPixelBuffer? { |
| + guard let pool else { return nil } |
| + var pixel: CVPixelBuffer? |
| + CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixel) |
| + guard let pixel else { return nil } |
| + CVPixelBufferLockBaseAddress(pixel, []) |
| + defer { CVPixelBufferUnlockBaseAddress(pixel, []) } |
| + guard let base = CVPixelBufferGetBaseAddress(pixel) else { return nil } |
| + let destination = CVPixelBufferGetBytesPerRow(pixel) |
| + let stride = Self.width * 4 |
| + let first = offset |
| + let rows = self.rows |
| + frame += 1 |
| + // A window being dragged across the page — 800×450, moving 10 rows |
| + // and 14 columns a frame and bouncing off the edges — whose content |
| + // changes every half second, as a page loading or a slide advancing |
| + // would. Together with the scroll beneath it, what someone moving |
| + // things around a busy screen sends the encoder. |
| + let span = (x: Self.width - 800, y: Self.height - 450) |
| + let wx = bounce(frame * 14, span.x) |
| + let wy = bounce(frame * 10, span.y) |
| + let scene = frame / 15 |
| + let jump = (scene &* 7919) % (rows - 450) |
| + let shift = ((scene &* 104_729) % 900) * 4 |
| + texture.withUnsafeBytes { raw in |
| + guard let source = raw.baseAddress else { return } |
| + for y in 0..<Self.height { |
| + memcpy(base + y * destination, source + ((first + y) % rows) * stride, stride) |
| + if y >= wy, y < wy + 450 { |
| + memcpy(base + y * destination + wx * 4, source + (jump + y - wy) * stride + shift, 800 * 4) |
| + } |
| + } |
| + } |
| + offset = (offset + 12) % rows |
| + return pixel |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index dbfe124..3f32290 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -43,6 +43,20 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @Published public private(set) var sharingScreen = false |
| @Published public private(set) var screenNote = "" |
| private var screenPending = false |
| + /// How this app's own screen is going out, while it is sharing: the |
| + /// slowest connection's, read once a second. See ScreenSendReport. |
| + @Published public private(set) var screenReport: ScreenSendReport? |
| + /// "Optimise for": what a shared screen gives up first. Remembered, and |
| + /// applied at once to a share in progress (see ScreenSendPolicy). |
| + @Published public var screenMode: ScreenShareMode = .remembered { |
| + didSet { |
| + guard screenMode != oldValue else { return } |
| + ScreenShareMode.remembered = screenMode |
| + media.screenMode = screenMode |
| + } |
| + } |
| + private var screenReports: [String: ScreenSendReport] = [:] |
| + private var screenWatch: Task<Void, Never>? |
| @Published public private(set) var localTrack: RTCVideoTrack? |
| @Published public private(set) var muted = false |
| @Published public private(set) var cameraOff = false |
| @@ -160,6 +174,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| remoteScreens = [:] |
| sharingScreen = false |
| screenNote = "" |
| + endScreenWatch() |
| localTrack = nil |
| ice = nil |
| state = nil |
| @@ -408,6 +423,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| screens = [] |
| remoteScreens = [:] |
| sharingScreen = false |
| + endScreenWatch() |
| localTrack = nil |
| cameraChanged() |
| default: status = "Connecting…" |
| @@ -485,6 +501,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| defer { screenPending = false } |
| let joined = epoch |
| screenNote = "" |
| + media.screenMode = screenMode |
| do { |
| try await media.startScreen(capture) { [weak self] in |
| // The capture ended on its own: the system's stop control, or |
| @@ -505,6 +522,37 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| sharingScreen = true |
| send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "") |
| + watchScreen() |
| + } |
| + |
| + /// Reads the screen's outbound statistics once a second while sharing, |
| + /// for the device panel's line and the gate. |
| + private func watchScreen() { |
| + screenWatch?.cancel() |
| + screenWatch = Task { @MainActor [weak self] in |
| + while !Task.isCancelled { |
| + try? await Task.sleep(nanoseconds: 1_000_000_000) |
| + guard let self, !Task.isCancelled, self.sharingScreen else { return } |
| + let fresh = await self.media.screenReports() |
| + guard !Task.isCancelled, self.sharingScreen else { return } |
| + var measured: [String: ScreenSendReport] = [:] |
| + for (peer, report) in fresh { |
| + measured[peer] = report.measured(since: self.screenReports[peer]) |
| + } |
| + self.screenReports = measured |
| + let worst = ScreenSendReport.worst(Array(measured.values)) |
| + self.screenReport = worst |
| + // Motion mode scales from the source's own size, which only the |
| + // statistics know (a window can be any shape, and re-picked). |
| + if let worst { self.media.screenSource(width: worst.sourceWidth, height: worst.sourceHeight) } |
| + } |
| + } |
| + } |
| + private func endScreenWatch() { |
| + screenWatch?.cancel() |
| + screenWatch = nil |
| + screenReports = [:] |
| + screenReport = nil |
| } |
| /// Stops sharing, from every way out: the button, the system's own stop |
| @@ -512,6 +560,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| public func stopSharingScreen() { |
| guard sharingScreen else { return } |
| sharingScreen = false |
| + endScreenWatch() |
| media.stopScreen() |
| send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "") |
| } |
| @@ -533,6 +582,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| remoteScreens.removeValue(forKey: peer) |
| } |
| func mediaFailed() { status = "A participant’s media could not connect." } |
| + /// Caps the call's uplink estimate at `bps` (nil lifts it): the gate's |
| + /// stand-in for a thin connection, which it has no other way to get. |
| + public func limitUplink(_ bps: Int?) { media.limitUplink(bps) } |
| public func sampleStats() async -> [String: UInt64] { |
| var values = await media.stats() |
| values["hasICE"] = ice == nil ? 0 : 1 |
| @@ -540,6 +592,27 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| values["remoteScreens"] = UInt64(remoteScreens.count) |
| values["screens"] = UInt64(screens.count) |
| values["invalidEvents"] = invalidEvents |
| + // This app's own screen, as it leaves: the slowest connection's. |
| + if let screen = screenReport { |
| + values["screenSendWidth"] = UInt64(screen.width) |
| + values["screenSendHeight"] = UInt64(screen.height) |
| + values["screenSendFpsTenths"] = UInt64((screen.framesPerSecond * 10).rounded()) |
| + values["screenCapturedFpsTenths"] = UInt64((screen.capturedPerSecond * 10).rounded()) |
| + values["screenFramesSent"] = screen.framesSent |
| + values["screenFramesEncoded"] = screen.framesEncoded |
| + values["screenFramesCaptured"] = screen.framesCaptured |
| + values["screenLimitation"] = screen.limitationCode |
| + values["screenLimitedBandwidthMs"] = UInt64(((screen.limitationSeconds["bandwidth"] ?? 0) * 1000).rounded()) |
| + values["screenLimitedCpuMs"] = UInt64(((screen.limitationSeconds["cpu"] ?? 0) * 1000).rounded()) |
| + values["screenTargetBitrate"] = screen.targetBitrate |
| + values["screenSentBitrate"] = screen.sentBitrate |
| + values["screenEncodeMsTenths"] = UInt64((screen.encodeMsPerFrame * 10).rounded()) |
| + values["screenHardwareEncoder"] = screen.hardwareEncoder ? 1 : 0 |
| + values["screenCodecH264"] = screen.codec.lowercased().hasSuffix("h264") ? 1 : 0 |
| + values["screenPath"] = screen.pathCode |
| + values["screenRttMs"] = UInt64((screen.roundTripTime * 1000).rounded()) |
| + values["screenPacketsLost"] = UInt64(max(0, screen.packetsLost)) |
| + } |
| values["rosterPeers"] = UInt64(state?.peers.count ?? 0) |
| values["noiseFramesProcessed"] = media.audio.denoiser.framesProcessed |
| values["noiseSuppressor"] = media.audio.denoiser.engineName == "RNNoise" ? 1 : 2 |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index 07a29bc..cde891b 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -51,6 +51,8 @@ import SwiftUI |
| noiseToggle |
| + screenSection |
| + |
| Divider() |
| audioReadout |
| } |
| @@ -229,6 +231,32 @@ import SwiftUI |
| } |
| } |
| + /// "Optimise for", and while this app is sharing, one line saying how |
| + /// the screen is actually leaving — the answer to "it isn't keeping up". |
| + private var screenSection: some View { |
| + VStack(alignment: .leading, spacing: 4) { |
| + Picker("Optimise shared screen for", selection: $call.screenMode) { |
| + ForEach(ScreenShareMode.allCases) { mode in |
| + Text(mode.title).tag(mode) |
| + } |
| + } |
| + .accessibilityIdentifier("screenMode") |
| + #if os(macOS) |
| + .pickerStyle(.menu) |
| + #endif |
| + Text(call.screenMode.explanation) |
| + .font(.footnote).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + if call.sharingScreen { |
| + Text(call.screenReport?.summary ?? "Screen: starting…") |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .textSelection(.enabled) |
| + .accessibilityIdentifier("screenReport") |
| + } |
| + } |
| + } |
| + |
| private var audioReadout: some View { |
| VStack(alignment: .leading, spacing: 4) { |
| Text("Audio health").font(.headline) |
diff --git a/native/apple/Sources/CallsKit/MacScreenCapture.swift b/native/apple/Sources/CallsKit/MacScreenCapture.swift| index 738539f..ee6d3c7 100644 |
| --- a/native/apple/Sources/CallsKit/MacScreenCapture.swift |
| +++ b/native/apple/Sources/CallsKit/MacScreenCapture.swift |
| @@ -53,7 +53,7 @@ |
| release() |
| throw error |
| } |
| - let frames = ScreenFrames(source: source) |
| + let frames = ScreenFrames(source: source, queue: queue) |
| let stream = SCStream(filter: filter, configuration: Self.configuration(for: filter), delegate: self) |
| do { |
| try stream.addStreamOutput(frames, type: .screen, sampleHandlerQueue: queue) |
| @@ -71,6 +71,7 @@ |
| ended = nil |
| let stream = self.stream |
| self.stream = nil |
| + frames?.stop() |
| frames = nil |
| release() |
| stream?.stopCapture { _ in } |
| @@ -123,8 +124,12 @@ |
| /// Full resolution up to 1920 on the long side, at up to 30 frames a |
| /// second. Larger is sharper than any receiver will be shown it and |
| - /// costs the encoder for nothing; the source is a screencast, so |
| - /// under pressure WebRTC gives up frames before it gives up pixels. |
| + /// costs the encoder for nothing. What gives way under pressure is the |
| + /// "Optimise for" mode's (ScreenSendPolicy). |
| + /// |
| + /// Three surfaces in the queue, not five: a frame waiting behind two |
| + /// others is already a tenth of a second late, and a screen that |
| + /// arrives late is exactly the complaint this was tuned against. |
| static func configuration(for filter: SCContentFilter) -> SCStreamConfiguration { |
| let configuration = SCStreamConfiguration() |
| let rect = filter.contentRect |
| @@ -135,7 +140,7 @@ |
| configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30) |
| configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange |
| configuration.showsCursor = true |
| - configuration.queueDepth = 5 |
| + configuration.queueDepth = 3 |
| return configuration |
| } |
| } |
| @@ -198,13 +203,49 @@ |
| /// Hands each complete frame to the source. Called on the capture's own |
| /// queue, never the main actor: a screen is thirty frames a second and |
| /// the main actor has an interface to draw. |
| + /// |
| + /// Two things beyond passing frames on: |
| + /// |
| + /// - A frame that reaches here more than a tenth of a second after it |
| + /// was captured is dropped: the queue is behind, and a newer frame is |
| + /// already waiting. Sending it would only add its lateness to the |
| + /// picture everyone is watching. |
| + /// - An idle screen sends nothing (ScreenCaptureKit reports only |
| + /// changes), and the encoder only makes a keyframe when a frame comes |
| + /// to encode. So a receiver that lost a packet on a still screen, or |
| + /// joined during one, could wait on a broken or empty picture until |
| + /// something moved. The last frame is sent again once a second while |
| + /// nothing changes, which gives the encoder its chance. |
| private final class ScreenFrames: NSObject, SCStreamOutput, @unchecked Sendable { |
| private let source: RTCVideoSource |
| private let capturer: RTCVideoCapturer |
| + private let queue: DispatchQueue |
| + private var timer: DispatchSourceTimer? |
| + // Only touched on `queue`. Holding the newest frame pins one of the |
| + // capture's surfaces, but it is the one the encoder was just given |
| + // and is usually still holding anyway. |
| + private var last: CVPixelBuffer? |
| + private var lastSent: UInt64 = 0 |
| - init(source: RTCVideoSource) { |
| + static let stale = 0.1 |
| + static let repeatAfter: UInt64 = 1_000_000_000 |
| + |
| + init(source: RTCVideoSource, queue: DispatchQueue) { |
| self.source = source |
| + self.queue = queue |
| capturer = RTCVideoCapturer(delegate: source) |
| + super.init() |
| + let timer = DispatchSource.makeTimerSource(queue: queue) |
| + timer.schedule(deadline: .now() + .milliseconds(500), repeating: .milliseconds(500), leeway: .milliseconds(50)) |
| + timer.setEventHandler { [weak self] in self?.repeatIfIdle() } |
| + timer.resume() |
| + self.timer = timer |
| + } |
| + |
| + func stop() { |
| + timer?.cancel() |
| + timer = nil |
| + queue.async { self.last = nil } |
| } |
| func stream( |
| @@ -212,20 +253,38 @@ |
| ) { |
| guard type == .screen, sample.isValid, let pixels = sample.imageBuffer else { return } |
| // Only complete frames carry pixels. An idle screen sends status |
| - // frames and nothing else, and the receiver keeps the last picture, |
| - // which is what an idle screen looks like. |
| + // frames and nothing else; the repeat below covers it. |
| guard |
| let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) |
| as? [[SCStreamFrameInfo: Any]], |
| let raw = attachments.first?[.status] as? Int, |
| SCFrameStatus(rawValue: raw) == .complete |
| else { return } |
| - let time = CMTimeGetSeconds(sample.presentationTimeStamp) |
| + let captured = CMTimeGetSeconds(sample.presentationTimeStamp) |
| + let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) |
| + last = pixels |
| + if now - captured > Self.stale { return } |
| + deliver(pixels, at: captured) |
| + } |
| + |
| + private func repeatIfIdle() { |
| + guard let last else { return } |
| + let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) |
| + let ns = UInt64(max(0, now) * 1_000_000_000) |
| + guard ns >= lastSent, ns - lastSent >= Self.repeatAfter else { return } |
| + deliver(last, at: now) |
| + } |
| + |
| + private func deliver(_ pixels: CVPixelBuffer, at seconds: Double) { |
| + let ns = UInt64(max(0, seconds) * 1_000_000_000) |
| + // Never backwards: a repeat stamped just after a real frame would |
| + // otherwise be followed by an older one. |
| + guard ns > lastSent else { return } |
| + lastSent = ns |
| source.capturer( |
| capturer, |
| didCapture: RTCVideoFrame( |
| - buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0, |
| - timeStampNs: Int64(time * 1_000_000_000))) |
| + buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0, timeStampNs: Int64(ns))) |
| } |
| } |
| #endif |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index bb0360b..a33cd0c 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -18,6 +18,12 @@ import LiveKitWebRTC |
| private var screenAttempt = 0 |
| private var capture: VideoCapture? |
| private var peers: [String: MediaPeer] = [:] |
| + /// What the screen is optimised for (ScreenSendPolicy), and the size |
| + /// its source was last seen at, which motion mode scales down from. |
| + var screenMode = ScreenShareMode.balanced { |
| + didSet { applySendPolicy() } |
| + } |
| + private var screenSize = (width: 0, height: 0) |
| /// The audio device module and processing behind the factory. See CallAudio. |
| let audio: CallAudio |
| @@ -98,15 +104,14 @@ import LiveKitWebRTC |
| /// who changes their mind at the system's picker has shared nothing, |
| /// and nothing is added to any connection. |
| /// |
| - /// The source is marked as a screencast, which is what tells WebRTC to |
| - /// keep resolution and give up frame rate under pressure — text that |
| - /// goes soft is unreadable, where text that updates a little late is |
| - /// not. |
| + /// What gives way under pressure — resolution or frame rate — is the |
| + /// "Optimise for" mode's, through sender parameters (ScreenSendPolicy). |
| func startScreen(_ capture: ScreenCapture, ended: @escaping @MainActor () -> Void) async throws { |
| guard localScreen == nil else { return } |
| screenAttempt += 1 |
| let attempt = screenAttempt |
| - let source = factory.videoSource(forScreenCast: true) |
| + screenSize = (0, 0) |
| + let source = factory.videoSource(forScreenCast: ScreenSendPolicy.screencast(screenMode)) |
| try await capture.start(source: source, ended: ended) |
| guard attempt == screenAttempt, localScreen == nil else { |
| capture.stop() |
| @@ -129,6 +134,21 @@ import LiveKitWebRTC |
| capture.stop() |
| } |
| + /// Puts every connection's screen and camera senders where the policy |
| + /// wants them; a no-op for any that already are. |
| + func applySendPolicy() { |
| + for peer in peers.values { peer.applySendPolicy() } |
| + } |
| + /// The screen's source size, as its statistics report it. |
| + func screenSource(width: Int, height: Int) { |
| + guard width > 0, height > 0, (width, height) != screenSize else { return } |
| + screenSize = (width, height) |
| + applySendPolicy() |
| + } |
| + var screenPlan: EncodingPlan { |
| + ScreenSendPolicy.screen(screenMode, width: screenSize.width, height: screenSize.height) |
| + } |
| + |
| func close() { |
| stopScreen() |
| closePeers() |
| @@ -245,6 +265,33 @@ import LiveKitWebRTC |
| } |
| return values |
| } |
| + |
| + /// Caps every connection's bandwidth estimate, which is what a thin |
| + /// uplink does to it. For the gate, which has no thin uplink to test on; |
| + /// nil takes the cap off. |
| + func limitUplink(_ bps: Int?) { |
| + uplinkLimit = bps |
| + for peer in peers.values { peer.limitUplink(bps) } |
| + } |
| + private(set) var uplinkLimit: Int? |
| + |
| + /// This app's screen on the way out, one report per connection sending |
| + /// it (see ScreenSendReport). |
| + func screenReports() async -> [String: ScreenSendReport] { |
| + var reports: [String: ScreenSendReport] = [:] |
| + for (id, peer) in peers { |
| + guard let pc = peer.pc, let mid = peer.ownScreenMid else { continue } |
| + let report: RTCStatisticsReport = await withCheckedContinuation { c in |
| + pc.statistics { c.resume(returning: $0) } |
| + } |
| + let flat = report.statistics.mapValues { (type: $0.type, values: $0.values as [String: Any]) } |
| + if var read = ScreenSendReport.read(flat, mid: mid) { |
| + read.timestamp = report.timestamp_us / 1_000_000 |
| + reports[id] = read |
| + } |
| + } |
| + return reports |
| + } |
| } |
| /// A transceiver direction as SDP spells it, which is how the classifier, |
| @@ -315,6 +362,7 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String { |
| if let video = engine.localVideo { |
| sentVideo = pc?.add(video, streamIds: ["calls"]) != nil |
| } |
| + if let limit = engine.uplinkLimit { limitUplink(limit) } |
| // A connection built while this app is already sharing gets the |
| // screen with everything else: someone joining mid-share sees it. |
| // Last, as in the browser, so the camera and microphone take the |
| @@ -342,6 +390,24 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String { |
| setup.direction = .sendOnly |
| setup.streamIds = ["screen"] |
| screenTx = pc.addTransceiver(with: track, init: setup) |
| + if let tx = screenTx, let engine { |
| + try? tx.setCodecPreferences( |
| + ScreenSendPolicy.preferringH264(engine.factory.rtpSenderCapabilities(forKind: "video").codecs), |
| + error: ()) |
| + } |
| + applySendPolicy() |
| + } |
| + |
| + /// The screen at its mode's parameters and the camera capped beside |
| + /// it, or the camera back at its defaults when there is no screen. |
| + /// Sender parameters only: nothing here renegotiates. |
| + func applySendPolicy() { |
| + guard let pc, !closed, let engine else { return } |
| + if let screen = screenTx?.sender { screen.apply(engine.screenPlan) } |
| + let camera = engine.localVideo?.trackId |
| + if let sender = pc.senders.first(where: { $0.track != nil && $0.track?.trackId == camera }) { |
| + sender.apply(ScreenSendPolicy.camera(sharing: screenTx != nil)) |
| + } |
| } |
| /// Ends this connection's screen m-line. Stopping the transceiver is |
| @@ -356,6 +422,7 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String { |
| guard let tx = screenTx else { return } |
| screenTx = nil |
| tx.stopInternal() |
| + applySendPolicy() |
| renegotiate() |
| } |
| @@ -387,6 +454,7 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String { |
| private func settled() { |
| guard let pc, !closed, pc.signalingState == .stable else { return } |
| addScreen() |
| + applySendPolicy() |
| if owesOffer { |
| owesOffer = false |
| renegotiate() |
| @@ -394,6 +462,10 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String { |
| } |
| func isScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenMid } |
| func isOwnScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenTx?.mid } |
| + var ownScreenMid: String? { screenTx?.mid.isEmpty == false ? screenTx?.mid : nil } |
| + func limitUplink(_ bps: Int?) { |
| + _ = pc?.setBweMinBitrateBps(nil, currentBitrateBps: nil, maxBitrateBps: bps.map { NSNumber(value: $0) }) |
| + } |
| /// The `share` message: on draws the captioned tile ahead of the media, |
| /// off is one of §5.4's two endings. |
diff --git a/native/apple/Sources/CallsKit/ScreenSendPolicy.swift b/native/apple/Sources/CallsKit/ScreenSendPolicy.swift| new file mode 100644 |
| index 0000000..b159dd1 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ScreenSendPolicy.swift |
| @@ -0,0 +1,181 @@ |
| +import Foundation |
| +import LiveKitWebRTC |
| + |
| +/// What a shared screen is for, which decides what WebRTC gives up first |
| +/// when the uplink or the encoder cannot carry everything. |
| +/// |
| +/// A screencast source on its own keeps every pixel and drops frames, which |
| +/// is right for a document and exactly wrong for someone scrolling, |
| +/// dragging or playing video: the far side sees a slideshow of a screen |
| +/// that is moving ("the screenshare isn't keeping up", 2026-09-24). |
| +public enum ScreenShareMode: String, CaseIterable, Identifiable, Sendable { |
| + /// Keeps it reasonably sharp while tracking motion. The default. |
| + case balanced |
| + /// Keeps full resolution and lets the frame rate fall: slides, code, text. |
| + case detail |
| + /// Keeps the frame rate and lets resolution fall, to 1280 at most. |
| + case motion |
| + |
| + public var id: String { rawValue } |
| + |
| + public var title: String { |
| + switch self { |
| + case .balanced: return "Balanced" |
| + case .detail: return "Text and detail" |
| + case .motion: return "Motion and video" |
| + } |
| + } |
| + |
| + public var explanation: String { |
| + switch self { |
| + case .balanced: return "Keeps up with scrolling and moving windows, at full sharpness when the connection allows." |
| + case .detail: return "Keeps small text sharp. Movement may look jerky." |
| + case .motion: return "Keeps movement smooth. Text may look softer." |
| + } |
| + } |
| + |
| + static let key = "screenShareMode" |
| + |
| + /// The remembered choice, balanced until someone picks another. |
| + public static var remembered: ScreenShareMode { |
| + get { UserDefaults.standard.string(forKey: key).flatMap(Self.init(rawValue:)) ?? .balanced } |
| + set { UserDefaults.standard.set(newValue.rawValue, forKey: key) } |
| + } |
| +} |
| + |
| +/// One sender's encoding, as the policy wants it. Nil means the encoder's |
| +/// own default, and applying nil puts that default back. |
| +struct EncodingPlan: Equatable { |
| + enum Degradation: Equatable { case maintainResolution, maintainFramerate, balanced } |
| + |
| + var maxBitrate: Int? |
| + var maxFramerate: Int? |
| + var scaleResolutionDownBy: Double? |
| + /// WebRTC's bitrate priority: 1 is everyone's default, 4 is "high". |
| + var bitratePriority = 1.0 |
| + var highPriority = false |
| + var degradation: Degradation? |
| +} |
| + |
| +/// The sender parameters for a screen and for the camera beside it. |
| +/// |
| +/// Measured on 2026-09-24 with the native/browser motion gate (a 1080p |
| +/// page scrolling at 30 fps with a window dragged across it, H.264 on the |
| +/// Mac's hardware encoder, received in Chromium), frames a second arriving |
| +/// with the uplink open / capped at 2.5 / 1.5 / 1.0 Mbit/s: |
| +/// |
| +/// before (screencast, no parameters) 26 / 22 / 2 / 1 at 1920×1080 |
| +/// detail (maintain resolution, 1920) 27 / 21 / 13 / 2 at 1920×1080 |
| +/// balanced (maintain frame rate, 1920) 30 / 30 / 19 / 25 at 960–320 wide |
| +/// motion (not screencast, 1280) 30 / 30 / 30 / 23 at 640–320 wide |
| +/// |
| +/// WebRTC's own "balanced" degradation gave 7 / 2 / 2 / 1.5 on the same |
| +/// source, which is why the balanced mode does not use it. The same page |
| +/// on software VP8 managed 2 fps even on the open link (80 ms a frame to |
| +/// encode), which is why the screen asks for H.264 first. |
| +/// |
| +/// Sender parameters and nothing else, so none of it touches SDP: a mode |
| +/// changed mid-share, the camera capped and restored, all without a |
| +/// renegotiation for perfect negotiation to wobble over (see room.js's |
| +/// tuneAudioSender for the same reasoning on the browser side). |
| +enum ScreenSendPolicy { |
| + /// A screen longer than this on its long side is scaled down to it in |
| + /// motion mode: 720p's worth of pixels is what 30 fps fits in. |
| + static let motionLongestSide = 1280.0 |
| + /// And never sent larger than this in any mode (the Mac's capture is |
| + /// already fitted to it; this holds for anything else that shares). |
| + static let longestSide = 1920.0 |
| + |
| + /// The camera's ceiling while a screen is going out: the screen is what |
| + /// everyone is looking at, and the camera is a thumbnail beside it. |
| + static let sharingCameraBitrate = 500_000 |
| + static let sharingCameraHeight = 360.0 |
| + |
| + /// Whether the source is marked as a screencast. Motion is not: a |
| + /// screencast is encoded as screen content, which is tuned for |
| + /// sharpness, and in the measurement above the plain source held 30 fps |
| + /// on a thinner link than the screencast did. |
| + static func screencast(_ mode: ScreenShareMode) -> Bool { mode != .motion } |
| + |
| + /// The screen's encoding, for a source of the given size (zero while |
| + /// it is not known yet, which leaves the scale alone). |
| + static func screen(_ mode: ScreenShareMode, width: Int = 0, height: Int = 0) -> EncodingPlan { |
| + let longest = Double(max(width, height)) |
| + func fit(_ side: Double) -> Double? { |
| + longest > side ? longest / side : nil |
| + } |
| + switch mode { |
| + case .detail: |
| + return EncodingPlan( |
| + maxBitrate: 3_500_000, maxFramerate: 30, scaleResolutionDownBy: fit(longestSide), |
| + bitratePriority: 4, highPriority: true, degradation: .maintainResolution) |
| + case .balanced: |
| + // Full size while the link and the encoder can carry it, and a |
| + // smaller picture rather than a slideshow when they cannot. |
| + return EncodingPlan( |
| + maxBitrate: 3_000_000, maxFramerate: 30, scaleResolutionDownBy: fit(longestSide), |
| + bitratePriority: 4, highPriority: true, degradation: .maintainFramerate) |
| + case .motion: |
| + return EncodingPlan( |
| + maxBitrate: 2_500_000, maxFramerate: 30, scaleResolutionDownBy: fit(motionLongestSide), |
| + bitratePriority: 4, highPriority: true, degradation: .maintainFramerate) |
| + } |
| + } |
| + |
| + /// The sender's codecs with H.264 first, the rest after it in their own |
| + /// order: Apple's hardware encoder where the far side can take H.264, |
| + /// and everything else still on offer where it cannot, so a browser |
| + /// without H.264 negotiates what it always did. Held on the screen's |
| + /// transceiver so a renegotiation the far side offers keeps it. |
| + static func preferringH264(_ codecs: [RTCRtpCodecCapability]) -> [RTCRtpCodecCapability] { |
| + codecs.filter { $0.name.uppercased() == "H264" } + codecs.filter { $0.name.uppercased() != "H264" } |
| + } |
| + |
| + /// The camera's encoding: capped while this app shares a screen, and |
| + /// the encoder's own defaults again once it stops. `height` is the |
| + /// camera's own frame height where known; the native camera is |
| + /// captured nearest 640 wide, so 480 lines. |
| + static func camera(sharing: Bool, height: Int = 480) -> EncodingPlan { |
| + guard sharing else { return EncodingPlan() } |
| + let scale = Double(height) > sharingCameraHeight ? Double(height) / sharingCameraHeight : nil |
| + return EncodingPlan(maxBitrate: sharingCameraBitrate, scaleResolutionDownBy: scale) |
| + } |
| +} |
| + |
| +extension RTCRtpSender { |
| + /// Applies a plan to this sender's first encoding, and reports whether |
| + /// anything changed. Reads the parameters fresh each time: WebRTC |
| + /// refuses a set that did not come from the latest get. |
| + @discardableResult func apply(_ plan: EncodingPlan) -> Bool { |
| + let parameters = self.parameters |
| + guard let encoding = parameters.encodings.first else { return false } |
| + let before = describe(parameters) |
| + encoding.maxBitrateBps = plan.maxBitrate.map { NSNumber(value: $0) } |
| + encoding.maxFramerate = plan.maxFramerate.map { NSNumber(value: $0) } |
| + encoding.scaleResolutionDownBy = NSNumber(value: plan.scaleResolutionDownBy ?? 1) |
| + encoding.bitratePriority = plan.bitratePriority |
| + encoding.networkPriority = plan.highPriority ? .high : .low |
| + switch plan.degradation { |
| + case .maintainResolution?: |
| + parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.maintainResolution.rawValue) |
| + case .maintainFramerate?: |
| + parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.maintainFramerate.rawValue) |
| + case .balanced?: |
| + parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.balanced.rawValue) |
| + case nil: |
| + parameters.degradationPreference = nil |
| + } |
| + guard describe(parameters) != before else { return false } |
| + self.parameters = parameters |
| + return true |
| + } |
| + |
| + private func describe(_ parameters: RTCRtpParameters) -> String { |
| + guard let e = parameters.encodings.first else { return "" } |
| + return [ |
| + e.maxBitrateBps?.stringValue, e.maxFramerate?.stringValue, e.scaleResolutionDownBy?.stringValue, |
| + String(e.bitratePriority), String(e.networkPriority.rawValue), |
| + parameters.degradationPreference?.stringValue, |
| + ].map { $0 ?? "-" }.joined(separator: ",") |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/ScreenSendStats.swift b/native/apple/Sources/CallsKit/ScreenSendStats.swift| new file mode 100644 |
| index 0000000..6bc8847 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ScreenSendStats.swift |
| @@ -0,0 +1,182 @@ |
| +import Foundation |
| + |
| +/// What this app's own screen is doing on the way out, read from one |
| +/// connection's statistics. |
| +/// |
| +/// It exists because of a report on 2026-09-24 that "the screenshare |
| +/// isn't keeping up with what I'm doing on the screen", and because |
| +/// nothing in the app could say why: a slow screen can be the encoder |
| +/// giving up frames to keep resolution, a thin or still-ramping uplink, |
| +/// a relay in the path, or a software encoder that cannot keep up. These |
| +/// fields tell those apart, and `summary` says which in one line. |
| +public struct ScreenSendReport: Equatable, Sendable { |
| + public var width = 0 |
| + public var height = 0 |
| + /// Frames a second leaving the encoder, as WebRTC measures it. |
| + public var framesPerSecond = 0.0 |
| + /// Frames a second arriving from the capture (the media source), and |
| + /// its size before any scaling. |
| + public var capturedPerSecond = 0.0 |
| + public var sourceWidth = 0 |
| + public var sourceHeight = 0 |
| + public var framesSent: UInt64 = 0 |
| + public var framesEncoded: UInt64 = 0 |
| + public var framesCaptured: UInt64 = 0 |
| + /// "none", "bandwidth", "cpu" or "other", as WebRTC names them. |
| + public var limitation = "" |
| + /// Seconds spent under each limitation since the stream began. |
| + public var limitationSeconds: [String: Double] = [:] |
| + public var targetBitrate: UInt64 = 0 |
| + public var bytesSent: UInt64 = 0 |
| + /// When the counters were read, in seconds, for turning bytes into a rate. |
| + public var timestamp = 0.0 |
| + /// The encoder behind the stream, e.g. "VideoToolbox" or "libvpx". |
| + public var encoder = "" |
| + /// The codec's MIME type, e.g. "video/H264". |
| + public var codec = "" |
| + public var totalEncodeTime = 0.0 |
| + public var packetsLost: Int64 = 0 |
| + public var roundTripTime = 0.0 |
| + /// The selected path: "host", "srflx", "prflx" or "relay". A relay on |
| + /// either end is reported as relay, because that is the extra hop. |
| + public var path = "" |
| + /// Bits a second actually sent, from the byte count between two reads. |
| + /// Zero until there have been two. |
| + public var sentBitrate: UInt64 = 0 |
| + |
| + public init() {} |
| + |
| + /// Milliseconds the encoder took per frame, on average. |
| + public var encodeMsPerFrame: Double { |
| + framesEncoded == 0 ? 0 : totalEncodeTime * 1000 / Double(framesEncoded) |
| + } |
| + |
| + /// The rate from `previous` to this read, when both are the same stream. |
| + public func measured(since previous: ScreenSendReport?) -> ScreenSendReport { |
| + var next = self |
| + guard let previous, timestamp > previous.timestamp, bytesSent >= previous.bytesSent else { |
| + return next |
| + } |
| + next.sentBitrate = UInt64( |
| + (Double(bytesSent - previous.bytesSent) * 8 / (timestamp - previous.timestamp)).rounded()) |
| + return next |
| + } |
| + |
| + /// Of several connections, the one a person would notice: the slowest. |
| + public static func worst(_ reports: [ScreenSendReport]) -> ScreenSendReport? { |
| + reports.min { $0.framesPerSecond < $1.framesPerSecond } |
| + } |
| + |
| + /// One line for the device panel, e.g. |
| + /// "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, relay". |
| + public var summary: String { |
| + if width == 0 || height == 0 { return "Screen: starting…" } |
| + var parts = ["Screen: \(width)×\(height) at \(Int(framesPerSecond.rounded())) fps"] |
| + switch limitation { |
| + case "bandwidth": parts.append("limited by bandwidth") |
| + case "cpu": parts.append("limited by the processor") |
| + case "other": parts.append("limited") |
| + default: break |
| + } |
| + let rate = sentBitrate > 0 ? sentBitrate : targetBitrate |
| + if rate > 0 { parts.append(Self.megabits(rate)) } |
| + if !path.isEmpty { parts.append(path == "relay" ? "relay" : "direct") } |
| + var line = parts.joined(separator: ", ") |
| + if !encoder.isEmpty { line += " (\(Self.encoderName(encoder, codec: codec)))" } |
| + return line |
| + } |
| + |
| + static func megabits(_ bps: UInt64) -> String { |
| + let mbit = Double(bps) / 1_000_000 |
| + return mbit >= 1 ? String(format: "%.1f Mbit/s", mbit) : "\(Int((Double(bps) / 1000).rounded())) kbit/s" |
| + } |
| + |
| + /// "VideoToolbox" means the Mac's hardware encoder, which is the thing |
| + /// worth saying; anything else is software. |
| + static func encoderName(_ implementation: String, codec: String) -> String { |
| + let name = codec.split(separator: "/").last.map(String.init) ?? "" |
| + let hardware = implementation.contains("VideoToolbox") |
| + return [name, hardware ? "hardware" : "software"].filter { !$0.isEmpty }.joined(separator: " ") |
| + } |
| + |
| + /// A limitation as a number, for the gate's integer statistics. |
| + public var limitationCode: UInt64 { |
| + ["none": 0, "cpu": 1, "bandwidth": 2, "other": 3][limitation] ?? 0 |
| + } |
| + /// The path as a number, for the same: 1 host, 2 srflx, 3 prflx, 4 relay. |
| + public var pathCode: UInt64 { |
| + ["host": 1, "srflx": 2, "prflx": 3, "relay": 4][path] ?? 0 |
| + } |
| + public var hardwareEncoder: Bool { encoder.contains("VideoToolbox") } |
| +} |
| + |
| +extension ScreenSendReport { |
| + /// Reads one connection's statistics for the outbound stream on `mid`. |
| + /// `stats` is the report as WebRTC gives it, flattened: id to type and |
| + /// values. Nil when that stream is not in it (not negotiated yet). |
| + static func read(_ stats: [String: (type: String, values: [String: Any])], mid: String) |
| + -> ScreenSendReport? |
| + { |
| + guard !mid.isEmpty, |
| + let (outboundId, outbound) = stats.first(where: { |
| + $0.value.type == "outbound-rtp" && ($0.value.values["mid"] as? String) == mid |
| + }).map({ ($0.key, $0.value.values) }) |
| + else { return nil } |
| + var r = ScreenSendReport() |
| + r.width = int(outbound["frameWidth"]) |
| + r.height = int(outbound["frameHeight"]) |
| + r.framesPerSecond = double(outbound["framesPerSecond"]) |
| + r.framesSent = UInt64(int(outbound["framesSent"])) |
| + r.framesEncoded = UInt64(int(outbound["framesEncoded"])) |
| + r.limitation = outbound["qualityLimitationReason"] as? String ?? "" |
| + if let durations = outbound["qualityLimitationDurations"] as? [String: Any] { |
| + r.limitationSeconds = durations.mapValues { double($0) } |
| + } |
| + r.targetBitrate = UInt64(int(outbound["targetBitrate"])) |
| + r.bytesSent = UInt64(int(outbound["bytesSent"])) |
| + r.encoder = outbound["encoderImplementation"] as? String ?? "" |
| + r.totalEncodeTime = double(outbound["totalEncodeTime"]) |
| + if let source = outbound["mediaSourceId"] as? String, let media = stats[source]?.values { |
| + r.framesCaptured = UInt64(int(media["frames"])) |
| + r.capturedPerSecond = double(media["framesPerSecond"]) |
| + r.sourceWidth = int(media["width"]) |
| + r.sourceHeight = int(media["height"]) |
| + } |
| + if let codec = outbound["codecId"] as? String { |
| + r.codec = stats[codec]?.values["mimeType"] as? String ?? "" |
| + } |
| + if let remote = stats.first(where: { |
| + $0.value.type == "remote-inbound-rtp" && ($0.value.values["localId"] as? String) == outboundId |
| + })?.value.values { |
| + r.packetsLost = Int64(int(remote["packetsLost"])) |
| + r.roundTripTime = double(remote["roundTripTime"]) |
| + } |
| + r.path = path(stats) |
| + return r |
| + } |
| + |
| + /// The selected candidate pair's kind of path. |
| + static func path(_ stats: [String: (type: String, values: [String: Any])]) -> String { |
| + var pairId = stats.values.first { $0.type == "transport" }?.values["selectedCandidatePairId"] |
| + as? String |
| + if pairId == nil { |
| + pairId = stats.first { |
| + $0.value.type == "candidate-pair" && ($0.value.values["nominated"] as? Bool ?? false) |
| + && ($0.value.values["state"] as? String) == "succeeded" |
| + }?.key |
| + } |
| + guard let pairId, let pair = stats[pairId]?.values else { return "" } |
| + let kinds = ["localCandidateId", "remoteCandidateId"].compactMap { key in |
| + (pair[key] as? String).flatMap { stats[$0]?.values["candidateType"] as? String } |
| + } |
| + if kinds.contains("relay") { return "relay" } |
| + return kinds.first ?? "" |
| + } |
| + |
| + private static func int(_ value: Any?) -> Int { |
| + (value as? NSNumber)?.intValue ?? (value as? Int) ?? 0 |
| + } |
| + private static func double(_ value: Any?) -> Double { |
| + (value as? NSNumber)?.doubleValue ?? (value as? Double) ?? 0 |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/ScreenSendTests.swift b/native/apple/Tests/CallsKitTests/ScreenSendTests.swift| new file mode 100644 |
| index 0000000..4ebdda3 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/ScreenSendTests.swift |
| @@ -0,0 +1,214 @@ |
| +import LiveKitWebRTC |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// The screen's sending half: what each "Optimise for" mode asks of the |
| +/// sender, the camera capped beside a share and given back after it, and |
| +/// the statistics line that says how the screen is actually leaving. |
| +/// room-share.test.mjs holds the browser's sharePlan and cameraCap to the |
| +/// same numbers; a change here belongs there too. |
| +final class ScreenSendTests: XCTestCase { |
| + // MARK: - Policy |
| + |
| + func testDetailKeepsResolutionAtTheHighestCeiling() { |
| + let plan = ScreenSendPolicy.screen(.detail, width: 1920, height: 1080) |
| + XCTAssertEqual(plan.degradation, .maintainResolution) |
| + XCTAssertEqual(plan.maxBitrate, 3_500_000) |
| + XCTAssertEqual(plan.maxFramerate, 30) |
| + XCTAssertNil(plan.scaleResolutionDownBy, "a 1080p screen is sent whole") |
| + XCTAssertTrue(plan.highPriority) |
| + XCTAssertEqual(plan.bitratePriority, 4) |
| + } |
| + |
| + func testMotionKeepsTheFrameRateAt1280() { |
| + let plan = ScreenSendPolicy.screen(.motion, width: 1920, height: 1080) |
| + XCTAssertEqual(plan.degradation, .maintainFramerate) |
| + XCTAssertEqual(plan.maxFramerate, 30) |
| + XCTAssertEqual(plan.scaleResolutionDownBy ?? 0, 1.5, accuracy: 0.0001, "1920 down to 1280") |
| + XCTAssertTrue(plan.highPriority) |
| + // A portrait window is scaled by its long side, too. |
| + XCTAssertEqual( |
| + ScreenSendPolicy.screen(.motion, width: 1000, height: 1600).scaleResolutionDownBy ?? 0, 1.25, |
| + accuracy: 0.0001) |
| + // Something already small enough is left alone. |
| + XCTAssertNil(ScreenSendPolicy.screen(.motion, width: 1280, height: 720).scaleResolutionDownBy) |
| + } |
| + |
| + func testBalancedIsTheDefaultAndKeepsTheFrameRateAtFullSize() { |
| + XCTAssertEqual(ScreenShareMode.allCases.first, .balanced) |
| + let plan = ScreenSendPolicy.screen(.balanced, width: 1920, height: 1080) |
| + // Frames are held; resolution gives way only when it must, from full |
| + // size rather than motion's 1280. |
| + XCTAssertEqual(plan.degradation, .maintainFramerate) |
| + XCTAssertNil(plan.scaleResolutionDownBy) |
| + XCTAssertEqual(plan.maxBitrate, 3_000_000) |
| + XCTAssertEqual(plan.maxFramerate, 30) |
| + XCTAssertTrue(plan.highPriority) |
| + XCTAssertTrue(ScreenSendPolicy.screencast(.balanced)) |
| + XCTAssertTrue(ScreenSendPolicy.screencast(.detail)) |
| + XCTAssertFalse(ScreenSendPolicy.screencast(.motion)) |
| + } |
| + |
| + func testH264IsPreferredAndNothingIsDropped() { |
| + let factory = RTCPeerConnectionFactory( |
| + encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory()) |
| + let codecs = factory.rtpSenderCapabilities(forKind: "video").codecs |
| + let preferred = ScreenSendPolicy.preferringH264(codecs) |
| + XCTAssertEqual(preferred.count, codecs.count, "every codec stays on offer") |
| + XCTAssertEqual(preferred.first?.name, "H264") |
| + XCTAssertTrue(preferred.contains { $0.name == "VP8" }, "a browser without H.264 still has VP8") |
| + } |
| + |
| + func testAScreenLargerThan1920IsNeverSentLarger() { |
| + for mode in ScreenShareMode.allCases { |
| + let plan = ScreenSendPolicy.screen(mode, width: 3840, height: 2160) |
| + XCTAssertGreaterThanOrEqual(plan.scaleResolutionDownBy ?? 1, 2, "\(mode)") |
| + } |
| + } |
| + |
| + func testAnUnknownSizeLeavesTheScaleAlone() { |
| + for mode in ScreenShareMode.allCases { |
| + XCTAssertNil(ScreenSendPolicy.screen(mode).scaleResolutionDownBy, "\(mode)") |
| + } |
| + } |
| + |
| + func testTheCameraIsCappedWhileSharingAndGivenBackAfter() { |
| + let capped = ScreenSendPolicy.camera(sharing: true, height: 480) |
| + XCTAssertEqual(capped.maxBitrate, 500_000) |
| + XCTAssertEqual(capped.scaleResolutionDownBy ?? 0, 480.0 / 360.0, accuracy: 0.0001) |
| + XCTAssertFalse(capped.highPriority) |
| + XCTAssertNil(ScreenSendPolicy.camera(sharing: true, height: 360).scaleResolutionDownBy) |
| + XCTAssertEqual(ScreenSendPolicy.camera(sharing: false), EncodingPlan(), "the encoder's own defaults") |
| + } |
| + |
| + func testTheModeIsRemembered() { |
| + let defaults = UserDefaults.standard |
| + let saved = defaults.object(forKey: ScreenShareMode.key) |
| + defer { defaults.set(saved, forKey: ScreenShareMode.key) } |
| + defaults.removeObject(forKey: ScreenShareMode.key) |
| + XCTAssertEqual(ScreenShareMode.remembered, .balanced) |
| + ScreenShareMode.remembered = .motion |
| + XCTAssertEqual(ScreenShareMode.remembered, .motion) |
| + defaults.set("nonsense", forKey: ScreenShareMode.key) |
| + XCTAssertEqual(ScreenShareMode.remembered, .balanced) |
| + } |
| + |
| + // MARK: - Applying it to a real sender |
| + |
| + /// The plan reaches a real WebRTC sender, and the camera's defaults |
| + /// really come back: set, read back, restored. No call is placed. |
| + func testAPlanIsAppliedToASenderAndRestored() throws { |
| + let factory = RTCPeerConnectionFactory() |
| + let configuration = RTCConfiguration() |
| + configuration.sdpSemantics = .unifiedPlan |
| + let pc = try XCTUnwrap( |
| + factory.peerConnection( |
| + with: configuration, |
| + constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), |
| + delegate: nil)) |
| + defer { pc.close() } |
| + let track = factory.videoTrack(with: factory.videoSource(), trackId: "camera") |
| + let sender = try XCTUnwrap(pc.add(track, streamIds: ["calls"])) |
| + |
| + XCTAssertTrue(sender.apply(ScreenSendPolicy.camera(sharing: true, height: 480))) |
| + var encoding = try XCTUnwrap(sender.parameters.encodings.first) |
| + XCTAssertEqual(encoding.maxBitrateBps?.intValue, 500_000) |
| + XCTAssertEqual(encoding.scaleResolutionDownBy?.doubleValue ?? 0, 480.0 / 360.0, accuracy: 0.001) |
| + XCTAssertFalse( |
| + sender.apply(ScreenSendPolicy.camera(sharing: true, height: 480)), "the same plan twice is a no-op") |
| + |
| + XCTAssertTrue(sender.apply(ScreenSendPolicy.camera(sharing: false))) |
| + encoding = try XCTUnwrap(sender.parameters.encodings.first) |
| + XCTAssertNil(encoding.maxBitrateBps) |
| + XCTAssertEqual(encoding.scaleResolutionDownBy?.doubleValue ?? 1, 1, accuracy: 0.001) |
| + |
| + XCTAssertTrue(sender.apply(ScreenSendPolicy.screen(.motion, width: 1920, height: 1080))) |
| + let parameters = sender.parameters |
| + encoding = try XCTUnwrap(parameters.encodings.first) |
| + XCTAssertEqual(encoding.maxBitrateBps?.intValue, 2_500_000) |
| + XCTAssertEqual(encoding.maxFramerate?.intValue, 30) |
| + XCTAssertEqual(encoding.networkPriority, .high) |
| + XCTAssertEqual(encoding.bitratePriority, 4) |
| + XCTAssertEqual( |
| + parameters.degradationPreference?.intValue, RTCDegradationPreference.maintainFramerate.rawValue) |
| + } |
| + |
| + // MARK: - The readout |
| + |
| + private typealias Stats = [String: (type: String, values: [String: Any])] |
| + |
| + private func stats(mid: String = "2", local: String = "host", remote: String = "host") -> Stats { |
| + [ |
| + "OT1": ( |
| + "outbound-rtp", |
| + [ |
| + "mid": mid, "frameWidth": 1920, "frameHeight": 1080, "framesPerSecond": 8.0, |
| + "framesSent": 400, "framesEncoded": 400, "qualityLimitationReason": "bandwidth", |
| + "qualityLimitationDurations": ["none": 2.0, "bandwidth": 30.0, "cpu": 0.0, "other": 0.0], |
| + "targetBitrate": 1_200_000, "bytesSent": 5_000_000, |
| + "encoderImplementation": "VideoToolbox", "totalEncodeTime": 8.0, |
| + "mediaSourceId": "SV1", "codecId": "C1", |
| + ] |
| + ), |
| + "OT0": ("outbound-rtp", ["mid": "1", "frameWidth": 640, "framesPerSecond": 30.0]), |
| + "SV1": ("media-source", ["frames": 900, "framesPerSecond": 30.0, "width": 1920, "height": 1080]), |
| + "C1": ("codec", ["mimeType": "video/H264"]), |
| + "RI1": ("remote-inbound-rtp", ["localId": "OT1", "packetsLost": 12, "roundTripTime": 0.085]), |
| + "T1": ("transport", ["selectedCandidatePairId": "P1"]), |
| + "P1": ("candidate-pair", ["localCandidateId": "L1", "remoteCandidateId": "R1"]), |
| + "L1": ("local-candidate", ["candidateType": local]), |
| + "R1": ("remote-candidate", ["candidateType": remote]), |
| + ] |
| + } |
| + |
| + func testTheScreensOwnStreamIsReadByMid() throws { |
| + let r = try XCTUnwrap(ScreenSendReport.read(stats(), mid: "2")) |
| + XCTAssertEqual(r.width, 1920) |
| + XCTAssertEqual(r.height, 1080) |
| + XCTAssertEqual(r.framesPerSecond, 8) |
| + XCTAssertEqual(r.capturedPerSecond, 30, "the capture is fine; the sender is not keeping up") |
| + XCTAssertEqual(r.framesCaptured, 900) |
| + XCTAssertEqual(r.sourceWidth, 1920) |
| + XCTAssertEqual(r.limitation, "bandwidth") |
| + XCTAssertEqual(r.limitationSeconds["bandwidth"], 30) |
| + XCTAssertEqual(r.targetBitrate, 1_200_000) |
| + XCTAssertEqual(r.codec, "video/H264") |
| + XCTAssertTrue(r.hardwareEncoder) |
| + XCTAssertEqual(r.encodeMsPerFrame, 20, accuracy: 0.001) |
| + XCTAssertEqual(r.packetsLost, 12) |
| + XCTAssertEqual(r.roundTripTime, 0.085) |
| + XCTAssertEqual(r.path, "host") |
| + XCTAssertNil(ScreenSendReport.read(stats(), mid: "9"), "a stream not negotiated yet") |
| + XCTAssertNil(ScreenSendReport.read(stats(), mid: "")) |
| + } |
| + |
| + func testARelayAtEitherEndIsTheRelay() { |
| + XCTAssertEqual(ScreenSendReport.path(stats(local: "relay")), "relay") |
| + XCTAssertEqual(ScreenSendReport.path(stats(remote: "relay")), "relay") |
| + XCTAssertEqual(ScreenSendReport.path(stats(local: "srflx", remote: "host")), "srflx") |
| + } |
| + |
| + func testTheSummaryIsOneLine() throws { |
| + var r = try XCTUnwrap(ScreenSendReport.read(stats(local: "relay"), mid: "2")) |
| + XCTAssertEqual( |
| + r.summary, "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, relay (H264 hardware)") |
| + var previous = r |
| + previous.bytesSent = 4_900_000 |
| + previous.timestamp = 9 |
| + r.timestamp = 10 |
| + r = r.measured(since: previous) |
| + XCTAssertEqual(r.sentBitrate, 800_000, "the rate actually sent wins over the target") |
| + XCTAssertTrue(r.summary.contains("800 kbit/s")) |
| + XCTAssertEqual(ScreenSendReport().summary, "Screen: starting…") |
| + } |
| + |
| + func testTheSlowestConnectionIsTheOneShown() { |
| + var fast = ScreenSendReport() |
| + fast.framesPerSecond = 29 |
| + var slow = ScreenSendReport() |
| + slow.framesPerSecond = 4 |
| + XCTAssertEqual(ScreenSendReport.worst([fast, slow])?.framesPerSecond, 4) |
| + XCTAssertNil(ScreenSendReport.worst([])) |
| + } |
| +} |
diff --git a/test/browser/native-motion.mjs b/test/browser/native-motion.mjs| new file mode 100644 |
| index 0000000..f0ab777 |
| --- /dev/null |
| +++ b/test/browser/native-motion.mjs |
| @@ -0,0 +1,165 @@ |
| +// native-motion.mjs — the app shares a 1080p page scrolling at 30 fps |
| +// and the browser measures what actually arrives: frames a second, |
| +// frame size and freezes on the received screen, beside the app's own |
| +// account of its sender (encoder, limitation, bitrate, path). |
| +// |
| +// Written for a report on 2026-09-24 that a screen shared from the Mac |
| +// app "isn't keeping up with what I'm doing on the screen". It measures |
| +// each "Optimise for" mode twice — on an open local link, and with the |
| +// app's uplink estimate capped the way a thin home connection caps it — |
| +// and holds motion (and the balanced default) to a frame rate a scroll |
| +// can be followed at. |
| +// |
| +// Run by TestNativeMacScreenMotion (make native-browser). Environment: |
| +// MOTION_MODES comma-separated modes, default "balanced,detail,motion" |
| +// MOTION_UPLINKS comma-separated caps in bit/s, 0 for none, default "0,1500000" |
| +// MOTION_ASSERT "0" to measure without asserting |
| +import { chromium } from 'playwright'; |
| +import { spawn } from 'node:child_process'; |
| +import { createInterface } from 'node:readline'; |
| + |
| +const [origin, room, binary] = process.argv.slice(2); |
| +const modes = (process.env.MOTION_MODES || 'balanced,detail,motion').split(',').filter(Boolean); |
| +const uplinks = (process.env.MOTION_UPLINKS || '0,1500000').split(',').map(Number); |
| +const asserting = process.env.MOTION_ASSERT !== '0'; |
| +// A scroll followed at fewer frames than this reads as a slideshow. |
| +const MOTION_FLOOR_FPS = Number(process.env.MOTION_FLOOR_FPS || 15); |
| + |
| +const browser = await chromium.launch({ headless: true, args: [ |
| + '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', |
| + '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', |
| + '--mute-audio', |
| +] }); |
| +const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| +await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| +const page = await context.newPage(); |
| +await page.addInitScript(() => { |
| + const Native = window.RTCPeerConnection; |
| + window.__nativeInteropPCs = new Set(); |
| + window.RTCPeerConnection = class extends Native { |
| + constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); } |
| + }; |
| +}); |
| +let native; |
| +let failed = false; |
| +try { |
| + await page.goto(`${origin}/m/${room}`); |
| + await page.waitForFunction(() => !!window.meet); |
| + native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| + native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| + let sample = null; |
| + let exited = false; |
| + native.on('exit', () => { exited = true; }); |
| + createInterface({ input: native.stdout }).on('line', (line) => { |
| + try { |
| + const value = JSON.parse(line); |
| + if (value.stats) sample = value; |
| + } catch {} |
| + }); |
| + native.stderr.resume(); |
| + const wait = async (predicate, label, seconds = 35) => { |
| + const until = Date.now() + seconds * 1000; |
| + while (Date.now() < until) { |
| + if (exited) throw new Error('native executable exited'); |
| + if (await predicate()) return; |
| + await new Promise((r) => setTimeout(r, 250)); |
| + } |
| + throw new Error(label + ': ' + JSON.stringify(sample)); |
| + }; |
| + const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000)); |
| + await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.videoBytesSent > 0, 'native and browser connect'); |
| + |
| + // The received screen's counters, found by the share tile's own track. |
| + const received = () => page.evaluate(async () => { |
| + const v = document.querySelector('#tiles figure.calls-tile-share video'); |
| + const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null; |
| + if (!track) return null; |
| + for (const pc of window.__nativeInteropPCs) { |
| + if (pc.connectionState === 'closed') continue; |
| + const stats = await pc.getStats(); |
| + for (const r of stats.values()) { |
| + if (r.type === 'inbound-rtp' && r.kind === 'video' && r.trackIdentifier === track.id) { |
| + return { |
| + at: r.timestamp, decoded: r.framesDecoded || 0, width: r.frameWidth || 0, height: r.frameHeight || 0, |
| + freezes: r.freezeCount || 0, frozen: r.totalFreezesDuration || 0, |
| + jitter: r.jitterBufferDelay || 0, emitted: r.jitterBufferEmittedCount || 0, |
| + codec: (stats.get(r.codecId) || {}).mimeType || '', decoder: r.decoderImplementation || '', |
| + }; |
| + } |
| + } |
| + } |
| + return null; |
| + }); |
| + |
| + const results = []; |
| + for (const mode of modes) { |
| + native.stdin.write(`screen-mode ${mode}\n`); |
| + native.stdin.write('screen-motion\n'); |
| + await wait(() => sample?.sharingScreen === true, 'native shares the moving screen'); |
| + await wait(async () => (await received())?.decoded > 30, 'the browser decodes the moving screen'); |
| + for (const uplink of uplinks) { |
| + native.stdin.write(`uplink ${uplink}\n`); |
| + // Long enough for the estimate to ramp or fall and the encoder to |
| + // settle on it; then ten seconds measured. |
| + await sleep(uplink ? 12 : 15); |
| + const a = await received(); |
| + const s0 = sample.stats; |
| + const p0 = sample.syntheticScreenFrames || 0; |
| + await sleep(10); |
| + const b = await received(); |
| + const s1 = sample.stats; |
| + const p1 = sample.syntheticScreenFrames || 0; |
| + const seconds = (b.at - a.at) / 1000; |
| + const result = { |
| + mode, uplink, |
| + receivedFps: +((b.decoded - a.decoded) / seconds).toFixed(1), |
| + receivedSize: `${b.width}x${b.height}`, |
| + freezes: b.freezes - a.freezes, |
| + frozenSeconds: +(b.frozen - a.frozen).toFixed(2), |
| + jitterBufferMs: b.emitted > a.emitted ? Math.round(((b.jitter - a.jitter) / (b.emitted - a.emitted)) * 1000) : 0, |
| + codec: b.codec, decoder: b.decoder, |
| + sent: `${s1.screenSendWidth}x${s1.screenSendHeight}`, |
| + sentFps: (s1.screenSendFpsTenths || 0) / 10, |
| + producedFps: +((p1 - p0) / seconds).toFixed(1), |
| + capturedFps: (s1.screenCapturedFpsTenths || 0) / 10, |
| + encodedFps: +(((s1.screenFramesEncoded || 0) - (s0.screenFramesEncoded || 0)) / seconds).toFixed(1), |
| + limitation: ['none', 'cpu', 'bandwidth', 'other'][s1.screenLimitation || 0], |
| + sentKbps: Math.round((s1.screenSentBitrate || 0) / 1000), |
| + targetKbps: Math.round((s1.screenTargetBitrate || 0) / 1000), |
| + cameraKbps: Math.round(((s1.videoBytesSent || 0) - (s0.videoBytesSent || 0)) * 8 / seconds / 1000), |
| + encodeMs: (s1.screenEncodeMsTenths || 0) / 10, |
| + hardware: s1.screenHardwareEncoder === 1, |
| + path: ['', 'host', 'srflx', 'prflx', 'relay'][s1.screenPath || 0], |
| + }; |
| + results.push(result); |
| + console.log('MEASURE ' + JSON.stringify(result)); |
| + } |
| + native.stdin.write('uplink 0\n'); |
| + native.stdin.write('screen-off\n'); |
| + await wait(() => sample?.sharingScreen === false, 'native stops sharing'); |
| + await wait(async () => (await received()) === null, 'the browser drops the screen'); |
| + } |
| + |
| + if (asserting) { |
| + for (const r of results) { |
| + if (r.mode === 'motion' || r.mode === 'balanced') { |
| + if (r.receivedFps < MOTION_FLOOR_FPS) { |
| + failed = true; |
| + console.log(`FAIL ${r.mode} at uplink ${r.uplink || 'open'}: ${r.receivedFps} fps received, below ${MOTION_FLOOR_FPS}`); |
| + } else { |
| + console.log(`PASS ${r.mode} at uplink ${r.uplink || 'open'}: ${r.receivedFps} fps received at ${r.receivedSize}`); |
| + } |
| + } |
| + if (r.mode === 'detail' && r.receivedSize !== '1920x1080') { |
| + failed = true; |
| + console.log(`FAIL detail at uplink ${r.uplink || 'open'} gave up resolution: ${r.receivedSize}`); |
| + } |
| + } |
| + } |
| + native.stdin.write('leave\n'); |
| + await sleep(1); |
| +} finally { |
| + native?.kill('SIGTERM'); |
| + await browser.close(); |
| +} |
| +if (failed) process.exit(1); |
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs| index 345b452..d1c4d4f 100644 |
| --- a/test/browser/room-lane.test.mjs |
| +++ b/test/browser/room-lane.test.mjs |
| @@ -898,7 +898,7 @@ test('a connection replaced under the negotiation is not offered to', async () = |
| // --------------------------------------------------------------- |
| const sharerBox = new Function( |
| - 'share', 'pcs', 'renderTiles', 'renderShare', 'broadcast', 'stopStream', |
| + 'share', 'pcs', 'renderTiles', 'renderShare', 'broadcast', 'stopStream', 'tuneShare', |
| [lift(src, 'addShareTo'), lift(src, 'removeShareFrom'), lift(src, 'dropShare')].join('\n') + |
| '\nreturn { addShareTo, removeShareFrom, dropShare };', |
| ); |
| @@ -930,9 +930,14 @@ function shareOver(signalingState) { |
| const entry = connection(signalingState); |
| const pcs = new Map([['peer1', entry]]); |
| const rendered = []; |
| + // Sender tuning (the screen's parameters, the camera capped and given |
| + // back) runs after each add and each removal; it is recorded with the |
| + // transceiver the entry holds at that moment. |
| + const tuned = []; |
| const box = sharerBox({ stream, track: display }, pcs, |
| - () => rendered.push('tiles'), () => rendered.push('share'), () => {}, () => {}); |
| - return Object.assign(box, { entry, display, stream, rendered }); |
| + () => rendered.push('tiles'), () => rendered.push('share'), () => {}, () => {}, |
| + (e) => tuned.push(e.shareTx)); |
| + return Object.assign(box, { entry, display, stream, rendered, tuned }); |
| } |
| test('a screen is added to a stable connection', () => { |
| @@ -942,6 +947,7 @@ test('a screen is added to a stable connection', () => { |
| assert.equal(s.entry.pc.added[0].track, s.display); |
| assert.deepEqual(s.entry.pc.added[0].init, { direction: 'sendonly', streams: [s.stream] }); |
| assert.equal(s.entry.shareTx, s.entry.pc.added[0]); |
| + assert.deepEqual(s.tuned, [s.entry.shareTx], 'the new screen is tuned once it exists'); |
| }); |
| test('a screen is not added to a connection that is mid-negotiation', () => { |
| @@ -983,7 +989,9 @@ test('stopping a screen stops its own transceiver and nothing else', () => { |
| // fake camera sender throws from replaceTrack, so reaching it at all |
| // fails loudly rather than quietly. |
| assert.deepEqual(s.entry.pc.removed, [], 'a sender was removed from the connection'); |
| - assert.equal(s.entry.pc.camera.track.id, 'camera'); |
| + assert.equal(s.entry.pc.camera.track.id, 'camera'); // Retuned with no screen on it, which is what gives the camera its |
| + // own defaults back (cameraCap(false) in room-share.test.mjs). |
| + assert.deepEqual(s.tuned, [tx, null], 'the camera was not given back its defaults'); |
| }); |
| test('the fallback is only for a transceiver that cannot be stopped', () => { |
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs| index aa20f7a..7be3622 100644 |
| --- a/test/browser/room-share.test.mjs |
| +++ b/test/browser/room-share.test.mjs |
| @@ -878,3 +878,135 @@ test('a browser without requestVideoFrameCallback counts nothing here', () => { |
| assert.equal(video.pending, null); |
| assert.equal(frames.size, 0); |
| }); |
| + |
| +// --------------------------------------------------------------- |
| +// Sending a screen: sharePlan, cameraCap, tuneSender, shareSendReport |
| +// |
| +// The native app's ScreenSendPolicy is sharePlan's twin (its tests are |
| +// native/apple/Tests/CallsKitTests/ScreenSendTests.swift), and the two |
| +// are meant to agree number for number. |
| +// --------------------------------------------------------------- |
| + |
| +const sharePlan = build('sharePlan'); |
| +const cameraCap = build('cameraCap'); |
| +const tuneSender = build('tuneSender'); |
| +const shareSendReport = build('shareSendReport'); |
| + |
| +test('the default is balanced, and every mode is offered by name', () => { |
| + const m = /const SHARE_MODES = \[([\s\S]*?)\];/.exec(src); |
| + assert.notEqual(m, null, 'SHARE_MODES is not in room.js any more'); |
| + const modes = [...m[1].matchAll(/\['(\w+)', '([^']+)'\]/g)].map((x) => [x[1], x[2]]); |
| + assert.deepEqual(modes, [ |
| + ['balanced', 'Balanced'], ['detail', 'Text and detail'], ['motion', 'Motion and video'], |
| + ]); |
| +}); |
| + |
| +test('detail keeps every pixel, at the highest ceiling', () => { |
| + const p = sharePlan('detail', 1920, 1080); |
| + assert.equal(p.contentHint, 'detail'); |
| + assert.equal(p.degradationPreference, 'maintain-resolution'); |
| + assert.equal(p.encoding.maxBitrate, 3500000); |
| + assert.equal(p.encoding.scaleResolutionDownBy, 1); |
| + assert.equal(p.encoding.maxFramerate, 30); |
| + assert.equal(p.encoding.priority, 'high'); |
| + assert.equal(p.encoding.networkPriority, 'high'); |
| +}); |
| + |
| +test('motion keeps the frame rate at 1280 on the long side', () => { |
| + const p = sharePlan('motion', 1920, 1080); |
| + assert.equal(p.contentHint, 'motion'); |
| + assert.equal(p.degradationPreference, 'maintain-framerate'); |
| + assert.equal(p.encoding.scaleResolutionDownBy, 1.5); |
| + assert.equal(p.encoding.maxFramerate, 30); |
| + assert.equal(p.encoding.priority, 'high'); |
| + assert.equal(sharePlan('motion', 1000, 1600).encoding.scaleResolutionDownBy, 1.25, 'portrait, by its long side'); |
| + assert.equal(sharePlan('motion', 1280, 720).encoding.scaleResolutionDownBy, 1); |
| +}); |
| + |
| +test('balanced holds the frame rate from full size, and anything unknown is balanced', () => { |
| + const b = sharePlan('balanced', 1920, 1080); |
| + assert.equal(b.contentHint, 'detail', 'still encoded as screen content'); |
| + assert.equal(b.degradationPreference, 'maintain-framerate'); |
| + assert.equal(b.encoding.maxBitrate, 3000000); |
| + assert.equal(b.encoding.scaleResolutionDownBy, 1, 'full size until the link says otherwise'); |
| + assert.equal(b.encoding.priority, 'high'); |
| + assert.deepEqual(sharePlan('nonsense', 1920, 1080), b); |
| + assert.deepEqual(sharePlan(undefined, 1920, 1080), b); |
| +}); |
| + |
| +test('a screen larger than 1920 is never sent larger, in any mode', () => { |
| + for (const mode of ['balanced', 'detail', 'motion']) { |
| + assert.ok(sharePlan(mode, 3840, 2160).encoding.scaleResolutionDownBy >= 2, mode); |
| + } |
| +}); |
| + |
| +test('an unknown size leaves the scale alone', () => { |
| + for (const mode of ['balanced', 'detail', 'motion']) { |
| + assert.equal(sharePlan(mode).encoding.scaleResolutionDownBy, 1, mode); |
| + } |
| +}); |
| + |
| +test('the camera is capped while sharing and given back after', () => { |
| + assert.deepEqual(cameraCap(true, 720), { maxBitrate: 500000, scaleResolutionDownBy: 2 }); |
| + assert.deepEqual(cameraCap(true, 360), { maxBitrate: 500000, scaleResolutionDownBy: 1 }); |
| + assert.deepEqual(cameraCap(false, 720), { maxBitrate: undefined, scaleResolutionDownBy: 1 }); |
| +}); |
| + |
| +// A sender that keeps what it was given, as Chromium's does. |
| +function fakeSender(encodings = [{ active: true }]) { |
| + const sender = { |
| + params: { encodings, degradationPreference: undefined }, |
| + sets: 0, |
| + getParameters() { return JSON.parse(JSON.stringify(this.params)); }, |
| + async setParameters(p) { this.sets += 1; this.params = p; }, |
| + }; |
| + return sender; |
| +} |
| + |
| +test('a plan reaches the sender, and giving the camera back removes the cap', async () => { |
| + const camera = fakeSender(); |
| + tuneSender(camera, cameraCap(true, 720)); |
| + await new Promise((r) => setTimeout(r, 0)); |
| + assert.equal(camera.params.encodings[0].maxBitrate, 500000); |
| + assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 2); |
| + tuneSender(camera, cameraCap(false, 720)); |
| + await new Promise((r) => setTimeout(r, 0)); |
| + assert.equal('maxBitrate' in camera.params.encodings[0], false, 'undefined deletes, never zero'); |
| + assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 1); |
| + assert.equal(camera.params.encodings[0].active, true, 'the rest of the encoding is left alone'); |
| + |
| + const screen = fakeSender(); |
| + const plan = sharePlan('motion', 1920, 1080); |
| + tuneSender(screen, plan.encoding, plan.degradationPreference); |
| + await new Promise((r) => setTimeout(r, 0)); |
| + assert.equal(screen.params.degradationPreference, 'maintain-framerate'); |
| + assert.equal(screen.params.encodings[0].maxBitrate, 2500000); |
| + assert.equal(screen.params.encodings[0].priority, 'high'); |
| +}); |
| + |
| +test('a sender with no encodings yet, or one that refuses, costs nothing', async () => { |
| + const empty = fakeSender([]); |
| + tuneSender(empty, cameraCap(true, 720)); |
| + await new Promise((r) => setTimeout(r, 0)); |
| + assert.equal(empty.sets, 0); |
| + const refusing = fakeSender(); |
| + refusing.setParameters = () => Promise.reject(new Error('InvalidModificationError')); |
| + tuneSender(refusing, cameraCap(true, 720)); |
| + await new Promise((r) => setTimeout(r, 0)); |
| + tuneSender(null, cameraCap(true, 720)); |
| + tuneSender({}, cameraCap(true, 720)); |
| +}); |
| + |
| +test('the screen is read off its own outbound report, by mid', () => { |
| + const reports = [ |
| + { mid: '1', frameWidth: 640, frameHeight: 480, framesPerSecond: 30 }, |
| + { mid: '2', frameWidth: 1920, frameHeight: 1080, framesPerSecond: 8, qualityLimitationReason: 'bandwidth', |
| + targetBitrate: 1200000, encoderImplementation: 'OpenH264', framesEncoded: 400 }, |
| + ]; |
| + assert.deepEqual(shareSendReport(reports, '2'), { |
| + width: 1920, height: 1080, fps: 8, limitation: 'bandwidth', targetBitrate: 1200000, |
| + encoder: 'OpenH264', framesEncoded: 400, |
| + }); |
| + assert.equal(shareSendReport(reports, ''), null, 'not sharing on this connection'); |
| + assert.equal(shareSendReport(reports, '9'), null, 'not negotiated yet'); |
| +}); |