chore: merge main into spike/peer-assisted-media

# Conflicts:
#	docs/README.md
#	docs/project-memory.md
#	docs/status.md
#	docs/需求理解.md
This commit is contained in:
AAA committed 2026-08-19 02:35:11 +08:00
commit 855b023ae8
15 files changed
+572 -122

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its media path is WebRTC P2P-first, with authenticated TURN fallback for network pairs that cannot connect directly. Viewers should be able to join from a desktop or mobile browser without installing the sender application.
The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. Rooms are random and temporary by default. A password-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Each viewer currently receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise.
The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A password-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Each viewer currently receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise.
This is not yet a production release. Public TURN/UDP and TURN/TCP relay paths are verified, but real cross-network Screener media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix.
+1
View File
@@ -9,6 +9,7 @@
- [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment.
- [Peer-assisted media research](./research/peer-assisted-media.md): browser shared-encode limits, standard relay re-encoding, rejected custom encoded transport, deterministic sticky topology, and abandon gates.
- [Low-server-cost media routes](./research/low-server-media-routes.md): traffic accounting, route screening, conditional native encoded-RTP relay, and explicit SFU fallbacks.
- [Realtime quality adaptation](./research/realtime-quality-adaptation.md): browser degradation policy, live profile changes, offline-encoding boundaries, and measurement gates.
- [Agent context governance research](./research/agent-context-governance.md): official Codex, Claude Code, Hermes Agent, and GitHub practices adopted by this repository.
- [ADR-0001](./adr/0001-p2p-first-media-topology.md): accepted P2P-first media topology and its consequences.
- [ADR-0002](./adr/0002-persistent-protected-rooms.md): optional SQLite persistence, protected sequential room IDs, and room lifecycle.
+22 -21
View File
@@ -9,36 +9,38 @@ Last updated: 2026-08-19
- Use a Photon-style central rendezvous service for access, rooms, signaling, deterministic topology, STUN, TURN fallback, and observability while clients carry media whenever practical.
- Server bandwidth cost is a primary constraint. The media priority is direct P2P, then measured peer assistance, then an explicit user-operated or central SFU fallback. Never migrate topology silently.
- The broadcaster has a hard target of at most two downstream media edges. Every assigned edge independently uses ICE and authenticated TURN/UDP or TURN/TCP when direct connectivity fails; P2P-first never means direct-only.
- Browser-only convenience does not override reachability, measurable performance, or honest resource accounting. Shared encoding reduces compute, not the network copy required by each viewer.
- Browser convenience does not override reachability, measurable performance, or honest resource accounting. Shared encoding reduces compute, not the last-hop copy required by every viewer.
- Durable decisions, current snapshots, research, code, `AGENTS.md`, and `.codex/` belong in Git. Memory and status are rewritten in place rather than kept as transcripts.
- Research current official sources and established implementations before material work. Apply Occam's razor and reject speculative protocols, services, scoring, and scale.
## Current Recommendation
- Keep the deployed Windows Chrome/Edge broadcaster and responsive Web viewer as the measurement baseline. Validate current Android Chrome and iOS Safari as leaves.
- Use direct host P2P for one or two viewers. ADR-0004 owns the default-off experiment for later viewers: two sticky deterministic chains, host capacity two, viewer capacity one, no proactive rebalance or composite score, and no more than eight viewers when `PEER_ASSISTED_MEDIA=true`.
- Use direct host P2P for one or two viewers. Draft ADR-0004 and Draft PR #13 own the default-off experiment for later viewers: two sticky deterministic chains, host capacity two, viewer capacity one, no proactive rebalance or composite score, and no more than eight viewers when `PEER_ASSISTED_MEDIA=true`.
- Standard browser relays resend remote `MediaStreamTrack` values and therefore decode and re-encode at every hop. Browser WebRTC does not guarantee one shared encoder across peer connections; measure this cost rather than hiding it.
- Every ADR-0004 gate is mandatory. Reject the browser experiment if it needs Encoded Transform/DataChannel/WebCodecs media, custom congestion or RTP recovery, multiple trees, relay scoring, transcoding, codec ladders, browser RTP injection, or more than two host edges.
- Only if topology, churn, connectivity, compatibility, and latency pass and re-encoding is the sole failure may a separate ADR propose a native shared-encode host plus opt-in native volunteer encoded-RTP relays. Any other failure closes peer assistance.
- Reject the browser experiment if it needs Encoded Transform/DataChannel/WebCodecs media, custom congestion or RTP recovery, multiple trees, relay scoring, transcoding, codec ladders, browser RTP injection, or more than two host edges.
- Only if every non-encoding gate passes and re-encoding is the sole failure may a separate ADR propose a native shared-encode host plus opt-in native volunteer encoded-RTP relays. Any other failure closes peer assistance.
- Keep user-operated mini-SFU deployment and central single-node SFU Draft PR #12 as explicit fallbacks. They transfer fanout egress to their operator and do not justify automatic switching, Redis, or multi-node infrastructure.
- Prefer direct UDP, then TURN/UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses TCP 5349 by default; TCP 443 needs a dedicated address or validated L4/SNI routing.
- Treat 1080p60 as best effort and retain 720p60 and 720p30 fallbacks. Keep browser codec order until target hardware proves a more efficient common codec; do not add custom dynamic-FPS logic without stats showing a real gap.
- Treat 1080p60 at 8 Mbps as a best-effort ceiling. Keep the deployed 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps profiles switchable during sharing. `balanced` does not guarantee resolution-first behavior; measure actual browser tradeoffs.
- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements prove a material gap. Keep browser codec order until target hardware proves a more efficient common codec.
- Keep room policy deployment-driven. Public and password-only deployments use random temporary rooms. A site password plus SQLite path enables sequential persistent rooms; stopping sharing leaves the room and viewer link available.
## Current Implementation
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse proxy deployment binds loopback and exposes `/healthz`.
- The deployed media path still creates one independent host `RTCPeerConnection` per viewer. It supports capture-before-room creation, live source replacement, stable signaling identities, reconnect/ICE recovery, generation guards, short-lived TURN credentials, three manual quality profiles, and local WebRTC stats. It violates the new fanout target above two viewers.
- The spike branch contains deterministic server/client peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented.
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three quality profiles above with `contentHint = "motion"` and explicit `degradationPreference = "balanced"`. A real live quality/pause cycle remains unverified.
- Access remains deliberately small: optional site-wide `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, session map, or logout flow.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Commit `2f66770f8e90` is deployed at `https://share.bonfire.icu` behind nginx. Authenticated coturn at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
- Chromium 151 has now demonstrated the intended three-viewer peer-assisted shape: exactly two connected host outbound peers; viewer 1 with one inbound plus one outbound forwarding to viewer 3; viewer 2 direct from the host; and decoded frames at all three viewers. Closing the first-level relay reattached its branch and resumed decoding in about 5.3 seconds while host active connected outbound edges peaked at two.
- A Chromium 151 one-to-eight synthetic functional smoke formed two depth-four chains: two connected host outbound peers, one inbound plus one outbound at viewers 1 through 6, inbound-only viewers 7 and 8, and decoded frames everywhere. This is short functional evidence, not proof of 720p60 quality, performance, latency, or endurance.
- That recovery covers only a page close immediately observed by the server. A silent partition waits for the 30-second heartbeat, so detection can take 30 to 60 seconds before the default 5-second viewer grace; it is unverified. The controlled server-observed recovery gate remains 5 seconds of grace plus at most 3 seconds to a decodable picture.
- Relay outbound stats are observable, but relay senders still use a fixed `1080p60`/8 Mbps envelope rather than the host-selected profile; profile propagation blocks valid comparative measurements. Real persistent-room reuse/restart, screen and game audio, heterogeneous networks, relay resource and generational-quality cost, depth-four latency, endurance, silent partition recovery, and mobile leaves remain unverified.
- On the current spike branch, `npm run check` passes type checking, 11 Vitest files with 129 tests, and both client and server production builds.
- Draft SFU PR #12 and ADR-0003 remain unmerged, undeployed, and independently reversible. There is no infrastructure blocker for the current Web deployment or bounded spike.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
- Draft PR #13 contains deterministic server/client peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented.
- Chromium 151 demonstrated the intended three-viewer shape: exactly two connected host outbound peers; viewer 1 with one inbound plus one outbound forwarding to viewer 3; viewer 2 direct from the host; and decoded frames at all three viewers.
- A Chromium 151 one-to-eight synthetic functional smoke formed two depth-four chains: two connected host outbound peers, one inbound plus one outbound at viewers 1 through 6, inbound-only viewers 7 and 8, and decoded frames everywhere. This proves topology only, not profile quality, resource cost, latency, or endurance.
- Closing the first-level relay reattached its branch and resumed decoding in about 5.3 seconds while host active connected outbound edges peaked at two. A silent partition instead waits for the 30-second heartbeat, so detection can take 30 to 60 seconds before the default 5-second viewer grace; that path is unverified.
- Relay outbound stats are observable, but relay senders still use a fixed `1080p60`/8 Mbps envelope rather than the host-selected profile. Profile propagation blocks valid comparative quality and resource measurements.
- The recorded peer-assisted full check passes type checking, 12 Vitest files with 135 tests, and both client and server production builds. Production release `5b2fb005f6f7` separately passed 108 Vitest tests, both builds, loopback and public HTTPS/access-gate checks, and a clean activation without disturbing nginx, coturn, or the blog.
- Draft SFU PR #12 and ADR-0003 remain unmerged, undeployed, and independently reversible. There is no infrastructure blocker for the deployed Web baseline or bounded peer-assisted experiment.
## Provisional Quality Targets
@@ -51,7 +53,7 @@ These are measurement gates, not performance claims.
## Open Decisions
- Sustainable viewer count by broadcaster hardware, quality profile, network class, and media route; use instrumented 1/3/5/8 comparisons rather than a 1:8 claim.
- Sustainable viewer count by broadcaster and relay hardware, quality profile, network class, and media route; use instrumented 1/3/5/8 comparisons rather than a 1:8 claim.
- Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates.
- Exact mobile lifecycle behavior and whether Windows per-application audio is required for the first release.
- Project license and distribution model, which determines whether GPL/AGPL sources can move beyond study-only use.
@@ -60,9 +62,8 @@ These are measurement gates, not performance claims.
## Source Of Truth
- Documentation index: `docs/README.md`
- Current phase: `docs/status.md`
- Requirements and first PoC design: linked from the documentation index
- Media research: `docs/research/webrtc-p2p-screen-sharing.md`, `docs/research/peer-assisted-media.md`, and `docs/research/low-server-media-routes.md`
- Documentation index and current phase: `docs/README.md` and `docs/status.md`
- Requirements and design: `docs/需求理解.md` and `docs/方案设计.md`
- Media research: `docs/research/webrtc-p2p-screen-sharing.md`, `docs/research/realtime-quality-adaptation.md`, `docs/research/peer-assisted-media.md`, and `docs/research/low-server-media-routes.md`
- Architecture: ADR-0001, ADR-0002, proposed experiment ADR-0004, and Draft PR #12's unaccepted ADR-0003
- Deployment and maintenance: `docs/deployment.md`, `docs/maintenance.md`, `AGENTS.md`, and `.codex/`
@@ -0,0 +1,121 @@
# Realtime Screen-Share Quality Adaptation
- Research date: 2026-08-19
- Scope: realtime game screen sharing in the browser
- Status: implementation input; real-device quality remains unverified
## Finding
Screener should not optimize a single quality dimension at the expense of the
others. The previous combination of `contentHint = "motion"` and an explicit
`degradationPreference = "maintain-framerate"` told WebRTC to preserve frame
rate primarily by reducing resolution. That is appropriate for some fast
motion, but it can make game UI, maps, subtitles, and text unreadable.
The smallest correction is to retain the game-oriented `motion` hint while
explicitly selecting `balanced` degradation. The WebRTC API allows the user
agent to balance frame-rate and resolution degradation; it does not prescribe
the algorithm or guarantee a clarity-first result. Congestion control remains
authoritative, and Chromium, Firefox, and Safari behavior must be measured.
The inspected Chromium/libwebrtc source chain makes a screen-only shortcut
especially unsafe to assume for Screener. The JavaScript `motion` hint reaches
`VideoRtpSender` as `kFluid`, which explicitly clears `options.is_screencast`.
`WebRtcVideoSendStream` then configures the encoder as `kRealtimeVideo`, while
`VideoStreamEncoder` only reinterprets `BALANCED` as `MAINTAIN_RESOLUTION` when
its encoder is marked as screen share. The explicit `balanced` preference still
wins the earlier preference-selection branch, but the later screen-only remap
does not follow from this project path. Treat the effective tradeoff as an
implementation detail to verify, not a product guarantee.
An older libwebrtc experiment could detect animated screen content under
`balanced` and cap it to 1280x720. Commit `1d7d0e6e2c50` deleted that experiment
on 2024-05-22; the commit says it had already been disabled for several years
and was not maintained. Current Chromium behavior must therefore not be
described as automatically detecting game motion and forcing a 720p cap.
## Why Offline Encoding Presets Do Not Transfer
[Maruko Toolbox](https://github.com/wzxjohn/marukotoolbox) is an Apache-2.0
GUI around offline video-processing tools. Its x264-style CRF, slow presets,
lookahead, and multi-pass workflows trade encoding time and buffering for file
size and quality. Interactive WebRTC cannot buffer or revisit future frames in
that way. The transferable principle is to treat bitrate, image detail, frame
rate, and encode cost as a joint tradeoff; its concrete offline presets are not
a WebRTC configuration baseline.
The GUI repository is Apache-2.0, while x264 itself uses GPL-2.0 or commercial
licensing; no implementation or preset is copied into Screener.
Discord's published Go Live design reaches the same broad conclusion for
realtime game streaming: bandwidth estimates, frame delivery, image quality,
latency, CPU, and memory conflict and must be balanced. Its native encoder
tuning and hardware integration are not available to a browser-only sender and
must not be presented as settings this project already has.
## Current Policy
The three user-visible profiles remain ceilings rather than promised rates:
| Profile | Capture ceiling | Sender bitrate ceiling |
| --- | --- | ---: |
| 1080p60 | 1920x1080 at 60 fps | 8 Mbps |
| 1080p30 | 1920x1080 at 30 fps | 5 Mbps |
| 720p30 | 1280x720 at 30 fps | 3 Mbps |
The middle profile is an explicit manual 1080p30 ceiling: choosing it trades a
60 fps ceiling for a 1080p capture bound. LiveKit currently uses the same
1080p30 at 5 Mbps screen-share preset, but neither preset guarantees the emitted
resolution, frame rate, or bitrate.
- Initial capture supplies `ideal` and `max` bounds for resolution and frame
rate; the actual result is read back from `MediaStreamTrack.getSettings()`.
- A live profile change uses `track.applyConstraints()` and updates every
current sender with `RTCRtpSender.setParameters()`. It does not reopen the
source picker or renegotiate healthy peer connections.
- The video track keeps `contentHint = "motion"`, while each sender explicitly
uses `degradationPreference = "balanced"`. This is a user-agent preference,
not a promise to preserve either resolution or frame rate.
- `maxBitrate` and `maxFramerate` are ceilings. They are neither minimums nor
target guarantees, and the project does not use SDP bitrate hacks.
- Pausing the picture disables the existing video track, producing black video
without closing the room or media connection. Audio remains enabled.
## Deliberate Non-Goals
- No canvas pixel-difference detector, machine-learned rate controller, or
periodic profile switching.
- No copied x264 CRF/preset recipe in the browser path.
- No forced codec order until target hardware measurements identify the actual
power-efficient encoder.
- No promise that all browsers honor `applyConstraints` or degradation
preference identically.
## Verification Gate
Use the same static UI scene and deterministic high-motion game scene at
720p30, 1080p30, and 1080p60. Record actual capture and outbound dimensions/fps,
bitrate, `qualityLimitationReason`, codec, encoder implementation, encode time,
dropped frames, RTT, packet loss, and host CPU/GPU utilization. A live profile
change must preserve peer connection IDs, avoid a second source prompt, and
visibly converge to the requested bounds.
Compare image readability and motion continuity instead of declaring success
from FPS alone. If reproducible evidence later shows that `balanced` still
oscillates or makes the wrong tradeoff on supported machines, revise the fixed
profiles before adding an application-level adaptation controller.
## Primary Sources
- [W3C MediaStreamTrack Content Hints](https://www.w3.org/TR/mst-content-hint/)
- [W3C Screen Capture](https://www.w3.org/TR/screen-capture/)
- [W3C WebRTC](https://www.w3.org/TR/webrtc/)
- [Chromium `motion` to libwebrtc `kFluid` bridge](https://chromium.googlesource.com/chromium/src/third_party/+/refs/heads/main/blink/renderer/modules/peerconnection/media_stream_video_webrtc_sink.cc)
- [libwebrtc `motion`/`kFluid` sender classification](https://webrtc.googlesource.com/src/+/3b1eab8a69cb5078befb021c5492d3f204a7d6a2/pc/rtp_sender.cc)
- [libwebrtc encoder content-type selection](https://webrtc.googlesource.com/src/+/9caef2a8b88f389af10cee841732c42a98d3d45d/media/engine/webrtc_video_engine.cc)
- [libwebrtc conditional screen-share degradation mapping](https://webrtc.googlesource.com/src/+/refs/heads/main/video/video_stream_encoder.cc)
- [libwebrtc removal of the automatic animation-detection experiment, 2024-05-22](https://webrtc.googlesource.com/src/+/1d7d0e6e2c5002815853be251ce43fe88779ac85)
- [LiveKit screen-share presets](https://github.com/livekit/client-sdk-js/blob/main/src/room/track/options.ts)
- [LiveKit degradation defaults](https://github.com/livekit/client-sdk-js/blob/main/src/room/participant/publishUtils.ts)
- [Jitsi desktop degradation preference](https://github.com/jitsi/lib-jitsi-meet/blob/master/modules/RTC/TraceablePeerConnection.ts)
- [Discord Go Live architecture](https://discord.com/blog/how-it-all-goes-live-an-overview-of-discords-streaming-technology)
- [Discord encoder-quality case study](https://discord.com/blog/from-blocky-to-brilliant-improving-video-quality-on-discord-go-live-on-amd-gpus)
+2 -2
View File
@@ -154,11 +154,11 @@ const parameters = sender.getParameters();
parameters.encodings ??= [{}];
parameters.encodings[0].maxFramerate = 60;
parameters.encodings[0].maxBitrate = selectedProfileBitrate;
parameters.degradationPreference = "maintain-framerate";
parameters.degradationPreference = "balanced";
await sender.setParameters(parameters);
```
这些参数是偏好或上限,不能绕过浏览器拥塞控制,也不能保证目标码率。Screen Capture 和 WebRTC 规范允许浏览器决定实际输出帧率、编码与拥塞行为;`contentHint = "motion"` 也只是内容提示。静态画面通常能降低编码数据量,但规范不保证所有浏览器主动降低捕获频率或 GPU 开销。
这些参数是偏好或上限,不能绕过浏览器拥塞控制,也不能保证目标码率。`balanced` 允许浏览器在分辨率和帧率之间权衡,但不规定具体算法;本项目的 `contentHint = "motion"` 还会影响 Chromium/libwebrtc 对来源的内部分类,因此不能套用其仅限 screen encoder 的 `maintain-resolution` 重解释。当前源码链与测量要求见 [Realtime quality adaptation](./realtime-quality-adaptation.md)。静态画面通常能降低编码数据量,但规范不保证所有浏览器主动降低捕获频率或 GPU 开销。
因此首版不实现画面差分检测、周期性 `applyConstraints()` 或自定义动态 FPS 状态机。先对静态桌面和高动态游戏分别记录 `framesEncoded`、发送码率、`totalEncodeTime / framesEncoded`、`qualityLimitationReason` 及主机 CPU/GPU 占用;只有测量显示浏览器行为留下显著问题时,再设计最小的控制策略。这避免用额外竞态、计时器和画质跳变解决一个可能已由捕获器、编码器和 WebRTC 拥塞控制处理的问题。
+38 -39
View File
@@ -4,74 +4,73 @@ Last updated: 2026-08-19
## Phase
The standard one-`RTCPeerConnection`-per-viewer WebRTC PoC is deployed at
`https://share.bonfire.icu`. Commit `2f66770f8e90` is the deployed baseline for
protected SQLite rooms, sequential reusable IDs, and waiting after sharing
stops. This path still exceeds the new host-fanout target above two viewers.
The WebRTC proof of concept is deployed at `https://share.bonfire.icu`. Commit
`5b2fb005f6f7` adds live 1080p60/1080p30/720p30 quality changes, video-only
pause, clearer connection states, protected SQLite persistence, sequential room
IDs, reusable links, and a waiting state after sharing stops. Production room ID
`1` survived the deployment restart.
The spike branch contains a default-off, standard-WebRTC peer-assisted
experiment for later viewers. It assigns two sticky chains with host capacity
two and viewer capacity one, then relays remote tracks with a decode/re-encode
at every browser hop. `PEER_ASSISTED_MEDIA=false` remains the default and the
mode cannot be enabled above eight viewers. Limited Chromium evidence now proves
the intended three-viewer shape, a synthetic eight-viewer functional topology,
and one controlled page-close recovery, but the mode is not merged, deployed,
or production-validated. Draft SFU PR #12 is also unmerged and undeployed.
Production still uses one host `RTCPeerConnection` per viewer. Draft PR #13 adds
a default-off, standard-WebRTC peer-assisted experiment for later viewers: two
sticky chains with host capacity two, viewer capacity one, and decode/re-encode
at every browser relay hop. `PEER_ASSISTED_MEDIA=false` remains the default, the
mode cannot be enabled above eight viewers, and it is neither merged, deployed,
nor production-validated. Draft SFU PR #12 is also unmerged and undeployed.
## Current Snapshot
- Product scope is private game sharing for one broadcaster and a small trusted friend group; public broadcasting remains out of scope.
- Runtime is Node.js 24, React, TypeScript, Vite, native browser WebRTC, `ws`, Zod, Vitest, and a separate coturn deployment.
- Capture precedes room creation. Healthy peers survive source replacement, and local stats report path, candidate, RTT, bitrate, frame, loss, jitter, codec, and quality-limitation data.
- Capture precedes room creation. Live source and quality changes preserve healthy peers; picture pause keeps audio and connections active. `contentHint = "motion"` and explicit `balanced` degradation do not guarantee resolution-first behavior.
- Whole-site `ACCESS_PASSWORD` is optional. Protected sessions use a stateless 12-hour HMAC HttpOnly `SameSite=Strict` cookie; host authentication remains internal and signaling is role-bound.
- Without `ROOM_DATABASE_PATH`, rooms are random and temporary. With both the database path and site password, room IDs start at `1`, links persist, and stopping a share leaves viewers waiting. SQLite stores only room ID and host-token digest.
- Direct ICE is preferred independently per media edge. Authenticated TURN/UDP and TURN/TCP are required production fallbacks; TURN/TLS is optional.
- The experimental media priority remains direct P2P for one or two viewers, peer-assisted only after its gates pass, then user-operated or central single-node SFU as an explicit fallback. There is no automatic migration.
- The media priority is direct P2P for one or two viewers, peer assistance only after every ADR-0004 gate passes, then user-operated or central single-node SFU as an explicit fallback. There is no automatic migration.
## Verified Evidence
- On the current spike branch, `npm run check` passes type checking, 11 Vitest files with 129 tests, and both client and server production builds.
- The production site runs behind nginx on Debian 12 with Node.js 24.19.0. Authenticated coturn 4.17.2 serves public STUN and TURN/UDP+TCP on `turn.bonfire.icu:3478`; TURN/TLS is intentionally disabled.
- HTTPS/WSS, the site gate and cookie, room/WebSocket authorization, certificate renewal, SQLite permissions and clean service restart are verified. Public Chromium relay-only connections passed bidirectional traffic over TURN/UDP and TURN/TCP.
- Earlier Chromium 151 synthetic-media checks covered direct UDP media, offer/answer/create-offer recovery, signaling reconnect generation isolation, live video/audio source replacement, and the protected 390 px viewer flow.
- In a real Chromium 151 one-to-three-viewer run, the host held exactly two connected outbound media peers. Viewer 1 held one inbound and one outbound peer and forwarded media to viewer 3; viewer 2 remained directly attached to the host. All three viewers decoded frames.
- A Chromium 151 one-to-eight synthetic functional smoke formed two depth-four chains. The host held two connected outbound peers; viewers 1 through 6 each held one inbound and one outbound peer; viewers 7 and 8 were inbound-only leaves; all viewers decoded frames. This short smoke proves functional topology only, not 720p60 quality, resource cost, latency, or endurance.
- Closing that first-level relay page caused its branch to reattach and decode again after about 5.3 seconds. The host's peak active connected outbound media-edge count remained two.
- That recovery proves only the path where the server observes a page close immediately. A silent network partition depends on the 30-second heartbeat, so detection can take 30 to 60 seconds before the 5-second viewer grace begins; that path is unverified.
- Production release `5b2fb005f6f7` passed type checking, 108 Vitest tests, both production builds, loopback smoke, public HTTPS/access-gate checks, and clean activation under `/opt/screener/current`. The new 1080p30 asset loaded, Screener had zero automatic restarts, and nginx, coturn, and the existing blog remained healthy.
- The protected production database and permissions survived restart, and room ID `1` remained present. Stop-and-republish and link reuse still require a browser cycle.
- The recorded peer-assisted full check passes type checking, 12 Vitest files with 135 tests, and both client and server production builds.
- Chromium 151 one-to-three evidence held exactly two connected host outbound peers. Viewer 1 held one inbound plus one outbound peer and forwarded to viewer 3; viewer 2 stayed direct; all three decoded frames.
- A Chromium 151 one-to-eight synthetic functional smoke formed two depth-four chains. The host held two outbound peers; viewers 1 through 6 each held one inbound plus one outbound; viewers 7 and 8 were leaves; all decoded frames. This proves topology only, not quality, resource cost, latency, or endurance.
- Closing the first-level relay caused its branch to reattach and decode again after about 5.3 seconds while host active connected outbound edges peaked at two.
- Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled.
- Headless Chromium 151 synthetic checks cover direct UDP media, offer/answer/create-offer recovery, generation isolation, live video/audio replacement, quality parameter updates, and video-only pause. Layout checks used actual inner widths 500, 781, and 820 px; a true 390 px viewport remains unverified.
## Unverified Boundaries
- The peer-assisted mode has not completed 1/3/5/8-viewer 30-minute runs, relay CPU/GPU and generational-quality measurements, controlled loss/RTT tests, or depth-four latency gates.
- Android Chrome and iOS Safari remain required leaves but are not verified for this topology. The protocol has no relay-capability bit; controlled join order is the only mobile-leaf enforcement, so arbitrary-user deployment is excluded.
- Real screen/game audio, heterogeneous machines and networks, browser lifecycle behavior, and sustained 1080p60 or 720p60 performance remain unverified.
- The first real protected room still needs room ID `1`, stop-and-republish, link reuse, and service-restart verification.
- Shared browser encoding is not available. Encoded Transform, DataChannel/WebCodecs media, custom congestion control, multiple trees, relay scoring, and automatic SFU switching remain outside the spike.
- Relay outbound diagnostics are visible, but relays still use a fixed `1080p60`/8 Mbps sender envelope instead of the host's selected profile. Profile propagation must be unified before quality or resource comparisons are valid.
- Relay senders still use a fixed `1080p60`/8 Mbps envelope instead of the host-selected room profile. Profile propagation blocks valid peer-assisted quality and resource comparisons.
- Peer assistance has not completed 1/3/5/8-viewer 30-minute runs, relay CPU/GPU and generational-quality measurements, controlled loss/RTT tests, or depth-four latency gates.
- Android Chrome and iOS Safari remain required leaves but are not verified for this topology. There is no runtime relay-capability bit; controlled join order is the only mobile-leaf enforcement, so arbitrary-user deployment is excluded.
- The observed recovery starts from a page close immediately seen by the server. A silent partition can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; it remains unverified.
- Real screen/game audio, heterogeneous machines and networks, mobile lifecycle behavior, the production live quality/pause cycle, room `1` stop-and-republish/link reuse, and sustained profile performance remain unverified.
- Browser relays do not provide shared encoding. Encoded Transform, DataChannel/WebCodecs media, custom congestion control, multiple trees, relay scoring, and automatic SFU switching remain outside the experiment.
## Next Milestone
Complete only the bounded ADR-0004 experiment:
Complete only the bounded ADR-0004 experiment, with distribution work ahead of
UI polish:
- preserve host fanout at two and viewer fanout at one across joins, reconnects, grace expiry, and reparenting;
- propagate one room quality profile to host and relay senders before comparative media measurements;
- run reproducible 1/3/5/8-viewer 720p60 measurements for topology depth, selected ICE path, host/relay upload, encode/decode work, first picture, decoded FPS, quality limitation, and glass-to-glass latency;
- repeat first-level relay loss with server-observed close and separately measure silent heartbeat-detected partition recovery; and
- preserve host fanout at two and viewer fanout at one across joins, reconnects, grace expiry, and reparenting;
- run reproducible 1/3/5/8-viewer measurements using the deployed profiles, recording topology depth, selected ICE path, host/relay upload, encode/decode work, first picture, decoded FPS, quality limitation, and glass-to-glass latency;
- repeat first-level relay loss with a server-observed close and separately measure silent heartbeat-detected partition recovery; and
- verify current Chrome/Edge relays plus Android Chrome and iOS Safari leaves.
For a disconnect immediately observed by the server, the recovery gate retains
the default 5-second grace followed by at most 3 seconds to restore a decodable
picture, about 8 seconds total. Silent partitions must be reported separately
with their heartbeat-detection delay.
For a disconnect immediately observed by the server, the gate retains the
default 5-second grace followed by at most 3 seconds to restore a decodable
picture, about 8 seconds total. Silent partitions include their detection delay.
All gates fail closed. If success requires a custom browser media plane, changed
RTP recovery, multiple trees, scoring, transcoding, a codec ladder, or more than
two host edges, remove this experiment. Only an isolated re-encoding failure may
two host edges, remove the experiment. Only an isolated re-encoding failure may
advance to a separate native shared-encode and volunteer encoded-RTP proposal.
## Blockers And Decisions
No infrastructure blocker remains. Production adoption is blocked on the
measurement matrix above, not implementation claims.
No infrastructure blocker remains. Production adoption of peer assistance is
blocked on profile propagation and the measurement matrix above.
- Whole-system versus selected-game audio for the first release.
- Initial deployment region and network cohort.
+4 -2
View File
@@ -130,10 +130,10 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate
| 档位 | 约束目标 | 初始码率上限 |
| --- | --- | ---: |
| 1080p60 | 1920x1080、60 fps | 8 Mbps/peer |
| 720p60 | 1280x720、60 fps | 5 Mbps/peer |
| 1080p30 | 1920x1080、30 fps | 5 Mbps/peer |
| 720p30 | 1280x720、30 fps | 3 Mbps/peer |
浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际结果。视频 track 设置 `contentHint = "motion"`;sender 尝试设置 `maxBitrate`、`maxFramerate` 与 `degradationPreference = "maintain-framerate"`,不支持时只记录警告。
浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际结果。视频 track 保留 `contentHint = "motion"`;sender 尝试设置 `maxBitrate`、`maxFramerate` 与 `degradationPreference = "balanced"`。`balanced` 只允许浏览器在帧率和分辨率之间权衡,不保证清晰度优先;具体 Chromium、Firefox 与 Safari 行为仍需实测。不支持 sender 参数时只记录警告。
PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、`encoderImplementation` 和 `powerEfficientEncoder`,再依据目标机器测量决定是否优先 H.264。这样避免静态 codec 偏好意外触发软件编码。
@@ -141,6 +141,8 @@ PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商
直播中切换来源仍由明确的按钮手势重新调用 `getDisplayMedia()`,不改变房间、host generation 或媒体拓扑。选源取消或失败时继续发送旧流;成功后为每个现有 sender 调用 `replaceTrack()`,健康连接不重新协商。初始 offer 预留 send-only audio transceiver,使音频可以在 `null -> track -> null` 间切换。若某个 peer 的替换或回滚失败,只重建该 peer;新流提交后才停止旧流,并以当前 stream 身份保护旧 track 的迟到 `ended` 事件。统计码率累加器在换源后重置,避免把不同 track 的计数器混算。
直播中切换质量档位不重新打开选源器:对当前视频 track 调用 `applyConstraints()`,再更新所有现有 sender 的参数;新加入或重建的 sender 使用当前档位。失败时保留当前分享并给出状态,不以重建整个房间补偿。临时暂停只禁用当前视频 track,使远端收到黑画面;音频、房间和连接保持不变,恢复时重新启用同一 track。
`frameRate` 约束和 sender 的 `maxFramerate` 都是目标或上限;浏览器仍控制实际捕获、重复帧处理、编码和拥塞降级。静态画面可能由浏览器减少实际编码数据,但标准没有保证每个实现以同一种方式节省采集或 GPU 开销。本 PoC 不添加画面差分、定时改约束或自定义动态 FPS 状态机,先用 `framesEncoded`、实际发送码率、`totalEncodeTime`、质量受限原因和主机资源占用测量真实问题。
自动全房间降档同样需要可信的聚合策略,放在测量后的下一阶段;本 PoC 提供手动档位和明确的受限原因,不伪装成已经解决自适应问题。
+2 -2
View File
@@ -14,13 +14,13 @@
- [ ] 简单房间:分享者获得纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。启用持久受保护房间时,房间码从 `1` 开始自增;其他模式继续使用随机临时房间码防止枚举。
- [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。
- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止和切换分享状态。
- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。
- [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。
- [ ] 一对多观看:一名分享者可向配置允许的多名观看者发送画面;默认上限为 8,可配置范围为 1 至 16。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑。
- [ ] 分享端 fanout 硬上限:分享端任何时刻最多维护两条下游媒体边,包括加入、断线、换父和恢复过程。第三名及后续观看者只能由通过验收的 peer-assisted 拓扑、部署者显式选择的可选 SFU 承载,或被明确拒绝;不能隐藏地回退为分享端第三条连接。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
- [ ] NAT 兜底:每条 parent-child 边独立进行 ICE;生产环境必须提供 STUN、TURN/UDP 与 TURN/TCP candidates,通过优先级偏好直连和 UDP,同时允许无法使用 UDP 的网络选择 TCP,不能因为某条边的网络与其他边不同而静默失败。
- [ ] 质量档位:至少提供 1080p60、720p60、720p30 三档目标,并依赖 WebRTC 拥塞控制及明确的手动档位避免持续排队;自定义动态 FPS 策略必须在统计证明有必要后再实现。
- [ ] 质量档位:提供 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档上限;分享期间可直接换档而不重新选择来源或重建健康连接。保留 `contentHint = "motion"` 并显式设置 `degradationPreference = "balanced"`;该偏好允许浏览器在帧率和分辨率之间权衡,不保证清晰度优先,具体行为必须实测。自定义动态 FPS 或画面检测策略必须在统计证明有必要后再实现。
- [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。
- [ ] 可观测性:采集候选类型、RTT、丢包、抖动、实际码率、帧率、编码耗时和质量受限原因。
- [ ] 访问控制:部署者可配置一个全站 `ACCESS_PASSWORD`;为空时网站公开,非空值必须为 1 至 128 个可见 ASCII 字符。配置时分享者和观看者都必须先登录。登录成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` cookie,建房 HTTP 和 WebSocket 都只使用该 cookie,不提供建房 Bearer 旁路;不引入账号、JWT、服务端 session Map 或 logout。
+35 -11
View File
@@ -6,11 +6,11 @@ export const QUALITY_PROFILES = {
frameRate: 60,
maxBitrate: 8_000_000,
},
"720p60": {
label: "720p 60",
width: 1280,
height: 720,
frameRate: 60,
"1080p30": {
label: "1080p 30",
width: 1920,
height: 1080,
frameRate: 30,
maxBitrate: 5_000_000,
},
"720p30": {
@@ -25,6 +25,14 @@ export const QUALITY_PROFILES = {
export type QualityProfileId = keyof typeof QUALITY_PROFILES;
export type QualityProfile = (typeof QUALITY_PROFILES)[QualityProfileId];
function captureConstraints(profile: QualityProfile): MediaTrackConstraints {
return {
width: { ideal: profile.width, max: profile.width },
height: { ideal: profile.height, max: profile.height },
frameRate: { ideal: profile.frameRate, max: profile.frameRate },
};
}
export async function captureDisplay(
profile: QualityProfile,
): Promise<MediaStream> {
@@ -33,11 +41,7 @@ export async function captureDisplay(
}
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: profile.width },
height: { ideal: profile.height },
frameRate: { ideal: profile.frameRate, max: profile.frameRate },
},
video: captureConstraints(profile),
audio: true,
});
@@ -50,6 +54,26 @@ export async function captureDisplay(
return stream;
}
export async function applyCaptureProfile(
stream: MediaStream,
profile: QualityProfile,
): Promise<void> {
const videoTrack = stream.getVideoTracks()[0];
if (!videoTrack) {
throw new Error("共享流缺少视频轨道");
}
await videoTrack.applyConstraints(captureConstraints(profile));
}
export function setVideoPaused(stream: MediaStream, paused: boolean): boolean {
const videoTrack = stream.getVideoTracks()[0];
if (!videoTrack) {
return false;
}
videoTrack.enabled = !paused;
return true;
}
export async function configureVideoSender(
sender: RTCRtpSender,
profile: QualityProfile,
@@ -60,6 +84,6 @@ export async function configureVideoSender(
}
parameters.encodings[0].maxBitrate = profile.maxBitrate;
parameters.encodings[0].maxFramerate = profile.frameRate;
parameters.degradationPreference = "maintain-framerate";
parameters.degradationPreference = "balanced";
await sender.setParameters(parameters);
}
+163 -35
View File
@@ -3,6 +3,8 @@ import {
Copy,
Hash,
MonitorUp,
Pause,
Play,
RefreshCw,
Square,
Users,
@@ -31,8 +33,10 @@ import {
} from "../lib/session";
import { SignalingClient } from "../lib/signaling";
import {
applyCaptureProfile,
captureDisplay,
QUALITY_PROFILES,
setVideoPaused,
type QualityProfileId,
} from "../media/quality";
import type {
@@ -120,6 +124,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const [notice, setNotice] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [switchingSource, setSwitchingSource] = useState(false);
const [changingQuality, setChangingQuality] = useState(false);
const [picturePaused, setPicturePaused] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
@@ -130,6 +136,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const generationRef = useRef(0);
const activeGenerationRef = useRef<number | null>(null);
const sourceSwitchRef = useRef<object | null>(null);
const qualityChangeRef = useRef<object | null>(null);
const qualityIdRef = useRef<QualityProfileId>("1080p60");
const picturePausedRef = useRef(false);
const retiringStreamRef = useRef<MediaStream | null>(null);
const viewers = useMemo(
@@ -148,6 +157,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
activeGenerationRef.current = null;
generationRef.current += 1;
sourceSwitchRef.current = null;
qualityChangeRef.current = null;
signalRef.current?.stop();
peersRef.current.forEach((peer) => peer.dispose());
peersRef.current.clear();
@@ -168,6 +178,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
function disposeResources(notifyServer: boolean): void {
sourceSwitchRef.current = null;
qualityChangeRef.current = null;
const signal = signalRef.current;
if (signal) {
if (notifyServer) {
@@ -193,6 +204,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
setPeerSnapshots(new Map());
setSignalStatus("offline");
setSwitchingSource(false);
setChangingQuality(false);
picturePausedRef.current = false;
setPicturePaused(false);
}
function forgetRoom(): void {
@@ -232,7 +246,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
isCurrentGeneration(generation) &&
streamRef.current === captured
) {
endSharing("屏幕分享已结束");
endSharing("已停止分享");
}
},
{ once: true },
@@ -247,6 +261,91 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
setSwitchingSource(false);
}
function commitQuality(id: QualityProfileId): void {
qualityIdRef.current = id;
setQualityId(id);
}
async function changeQuality(nextId: QualityProfileId): Promise<void> {
if (phase !== "live") {
commitQuality(nextId);
return;
}
const generation = activeGenerationRef.current;
const activeStream = streamRef.current;
if (
generation === null ||
!activeStream ||
!isCurrentGeneration(generation) ||
sourceSwitchRef.current ||
qualityChangeRef.current
) {
return;
}
const token = {};
qualityChangeRef.current = token;
setChangingQuality(true);
setNotice(null);
const profile = QUALITY_PROFILES[nextId];
try {
await applyCaptureProfile(activeStream, profile);
if (
!isCurrentGeneration(generation) ||
qualityChangeRef.current !== token ||
streamRef.current !== activeStream
) {
return;
}
commitQuality(nextId);
setDetails(captureDetails(activeStream));
const results = await Promise.all(
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
);
if (
isCurrentGeneration(generation) &&
qualityChangeRef.current === token
) {
const failed = results.filter((updated) => !updated).length;
setNotice(
failed > 0
? `画质已切换,${failed} 位观众未能应用新的发送参数`
: `画质已切换为 ${profile.label}`,
);
}
} catch (error) {
if (
isCurrentGeneration(generation) &&
qualityChangeRef.current === token
) {
setNotice(readableError(error));
}
} finally {
if (qualityChangeRef.current === token) {
qualityChangeRef.current = null;
setChangingQuality(false);
}
}
}
function togglePicturePause(): void {
const activeStream = streamRef.current;
if (phase !== "live" || !activeStream) {
return;
}
const nextPaused = !picturePausedRef.current;
if (!setVideoPaused(activeStream, nextPaused)) {
setNotice("当前分享没有可暂停的视频轨道");
return;
}
picturePausedRef.current = nextPaused;
setPicturePaused(nextPaused);
setNotice(nextPaused ? "画面已暂停,音频不受影响" : "画面已恢复");
}
function removePeer(peerId: string): void {
peersRef.current.get(peerId)?.dispose();
peersRef.current.delete(peerId);
@@ -286,7 +385,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
peerId,
iceConfig,
activeStream,
QUALITY_PROFILES[qualityId],
QUALITY_PROFILES[qualityIdRef.current],
{
sendSignal: (targetPeerId, payload) =>
isCurrentGeneration(generation) && signalRef.current === signal
@@ -518,7 +617,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
let captured: MediaStream;
try {
// This must remain the first awaited operation in the button gesture.
captured = await captureDisplay(QUALITY_PROFILES[qualityId]);
captured = await captureDisplay(QUALITY_PROFILES[qualityIdRef.current]);
} catch (error) {
if (!isCurrentGeneration(generation)) {
return;
@@ -657,7 +756,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
phase !== "live" ||
generation === null ||
!isCurrentGeneration(generation) ||
sourceSwitchRef.current
sourceSwitchRef.current ||
qualityChangeRef.current
) {
return;
}
@@ -670,7 +770,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
let captured: MediaStream;
try {
// Like initial capture, changing source must begin in this button gesture.
captured = await captureDisplay(QUALITY_PROFILES[qualityId]);
captured = await captureDisplay(QUALITY_PROFILES[qualityIdRef.current]);
} catch (error) {
if (
isCurrentGeneration(generation) &&
@@ -699,6 +799,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
}
retiringStreamRef.current = previousStream;
setVideoPaused(captured, picturePausedRef.current);
streamRef.current = captured;
setStream(captured);
setDetails(captureDetails(captured));
@@ -713,7 +814,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
peer,
replaced: await peer.replaceStream(
captured,
QUALITY_PROFILES[qualityId],
QUALITY_PROFILES[qualityIdRef.current],
),
};
} catch {
@@ -835,7 +936,22 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
<button
className="button button-secondary"
type="button"
disabled={switchingSource}
disabled={switchingSource || changingQuality}
onClick={togglePicturePause}
>
{picturePaused ? (
<Play size={16} fill="currentColor" aria-hidden="true" />
) : (
<Pause size={16} fill="currentColor" aria-hidden="true" />
)}
{picturePaused ? "恢复画面" : "暂停画面"}
</button>
)}
{phase === "live" && (
<button
className="button button-secondary"
type="button"
disabled={switchingSource || changingQuality}
onClick={() => void switchSource()}
>
<RefreshCw size={16} aria-hidden="true" />
@@ -847,7 +963,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
type="button"
onClick={() =>
endSharing(
phase === "starting" ? "启动已取消" : "屏幕分享已结束",
phase === "starting"
? "启动已取消"
: "已停止分享",
)
}
>
@@ -864,12 +982,16 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
) : (
<div className="stage-placeholder">
<MonitorUp size={36} strokeWidth={1.5} aria-hidden="true" />
<span>{phase === "ended" ? "分享已结束" : "未分享画面"}</span>
<span>{phase === "ended" ? "等待开始分享" : "未分享画面"}</span>
</div>
)}
{(phase === "starting" || switchingSource) && (
{(phase === "starting" || switchingSource || picturePaused) && (
<div className="stage-overlay" role="status">
{switchingSource ? "正在切换来源" : "正在连接"}
{switchingSource
? "正在切换来源"
: picturePaused
? "画面已暂停"
: "正在连接"}
</div>
)}
</div>
@@ -885,7 +1007,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{!details?.hasAudio && stream && (
<WarningBanner>当前来源没有可共享音频</WarningBanner>
)}
{room && !relayAvailable && (
{room && signalStatus === "connected" && !relayAvailable && (
<WarningBanner>TURN 未配置,严格网络可能无法连接</WarningBanner>
)}
{notice && (
@@ -894,28 +1016,32 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
</div>
)}
{phase !== "live" && (
<div className="setup-controls">
<fieldset className="control-group">
<legend>画质</legend>
<div className="segmented-control">
{(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map(
(id) => (
<button
key={id}
type="button"
className={qualityId === id ? "is-selected" : undefined}
aria-pressed={qualityId === id}
disabled={phase === "starting"}
onClick={() => setQualityId(id)}
>
{QUALITY_PROFILES[id].label}
</button>
),
)}
</div>
</fieldset>
<div className="setup-controls">
<fieldset className="control-group">
<legend>画质</legend>
<div className="segmented-control">
{(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map(
(id) => (
<button
key={id}
type="button"
className={qualityId === id ? "is-selected" : undefined}
aria-pressed={qualityId === id}
disabled={
phase === "starting" ||
switchingSource ||
changingQuality
}
onClick={() => void changeQuality(id)}
>
{QUALITY_PROFILES[id].label}
</button>
),
)}
</div>
</fieldset>
{phase !== "live" && (
<button
className="button button-primary start-button"
type="button"
@@ -925,12 +1051,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
<MonitorUp size={18} aria-hidden="true" />
{phase === "starting" ? "正在启动" : "开始分享"}
</button>
)}
{phase !== "live" && (
<a className="join-room-link" href="/join">
<Hash size={15} aria-hidden="true" />
输入房间码观看
</a>
</div>
)}
)}
</div>
{room && (
<div className="invite-bar">
<div className="invite-copy">
+3 -1
View File
@@ -534,7 +534,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
</div>
</div>
{!relayAvailable && signalStatus === "connected" && (
{!relayAvailable &&
signalStatus === "connected" &&
(hostOnline || peerSnapshot) && (
<WarningBanner>TURN 未配置,严格网络可能无法连接</WarningBanner>
)}
{peerSnapshot?.error && (
+10 -6
View File
@@ -724,6 +724,15 @@ h3 {
grid-template-columns: minmax(0, 1fr) 330px;
}
.section-heading {
flex-wrap: wrap;
}
.broadcast-actions {
width: 100%;
flex-wrap: wrap;
}
.setup-controls {
grid-template-columns: minmax(0, 1fr) auto;
}
@@ -777,18 +786,13 @@ h3 {
}
@media (max-width: 520px) {
.section-heading,
.viewer-title-row {
flex-wrap: wrap;
}
.broadcast-actions {
width: 100%;
}
.broadcast-actions .button {
min-width: 0;
flex: 1;
flex: 1 1 calc(50% - 4px);
}
.viewer-badges {
+22
View File
@@ -136,6 +136,28 @@ export class HostPeer {
return true;
}
async updateProfile(profile: QualityProfile): Promise<boolean> {
if (this.disposed || !this.videoSender) {
return false;
}
try {
await configureVideoSender(this.videoSender, profile);
} catch (error) {
this.setError(error, "调整画质失败");
return false;
}
if (this.disposed) {
return false;
}
this.profile = profile;
this.statsAccumulator.bytes = null;
this.statsAccumulator.frames = null;
this.statsAccumulator.timestamp = null;
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
}
async acceptSignal(payload: SignalPayload): Promise<void> {
if (this.disposed || payload.connectionId !== this.connectionId) {
return;
+58 -2
View File
@@ -8,7 +8,15 @@ const statsCallbacks: Array<() => void> = [];
class FakeSender {
failNextReplace = false;
readonly setParameters = vi.fn(async () => undefined);
failNextSetParameters = false;
readonly setParameters = vi.fn(
async (_parameters: RTCRtpSendParameters) => {
if (this.failNextSetParameters) {
this.failNextSetParameters = false;
throw new Error("setParameters failed");
}
},
);
readonly replaceTrack = vi.fn(async (track: MediaStreamTrack | null) => {
if (this.failNextReplace) {
this.failNextReplace = false;
@@ -264,7 +272,7 @@ describe("HostPeer source replacement", () => {
await expect(
peer.replaceStream(
createStream(createTrack("video", "next-video"), nextAudio),
QUALITY_PROFILES["720p60"],
QUALITY_PROFILES["1080p30"],
),
).resolves.toBe(true);
expect(connection.senders[1]?.track).toBe(nextAudio);
@@ -290,6 +298,54 @@ describe("HostPeer source replacement", () => {
expect(connection.senders[1]?.track).toBeNull();
});
it("updates quality parameters without replacing media tracks", async () => {
const video = createTrack("video", "video");
const audio = createTrack("audio", "audio");
const peer = createPeer(createStream(video, audio));
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
await expect(
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
).resolves.toBe(true);
expect(connection.senders[0]?.replaceTrack).not.toHaveBeenCalled();
expect(connection.senders[1]?.replaceTrack).not.toHaveBeenCalled();
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(2);
expect(
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
).toMatchObject({
degradationPreference: "balanced",
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
});
});
it("can retry the selected quality after a sender update fails", async () => {
const peer = createPeer(
createStream(
createTrack("video", "video"),
createTrack("audio", "audio"),
),
);
await expect(peer.start()).resolves.toBe(true);
const videoSender = FakePeerConnection.latest!.senders[0]!;
videoSender.failNextSetParameters = true;
await expect(
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
).resolves.toBe(false);
await expect(
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
).resolves.toBe(true);
expect(videoSender.setParameters).toHaveBeenCalledTimes(3);
expect(videoSender.setParameters.mock.calls.at(-1)?.[0]).toMatchObject({
degradationPreference: "balanced",
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
});
});
it("rolls the first sender back when the second replacement fails", async () => {
const oldVideo = createTrack("video", "old-video");
const oldAudio = createTrack("audio", "old-audio");
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyCaptureProfile,
captureDisplay,
configureVideoSender,
QUALITY_PROFILES,
setVideoPaused,
} from "../src/client/media/quality.ts";
function createVideoStream() {
const videoTrack = {
contentHint: "",
enabled: true,
applyConstraints: vi.fn(async () => undefined),
} as unknown as MediaStreamTrack;
const stream = {
getTracks: () => [videoTrack],
getVideoTracks: () => [videoTrack],
getAudioTracks: () => [],
} as unknown as MediaStream;
return { stream, videoTrack };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("realtime quality controls", () => {
it("bounds initial capture to the selected profile", async () => {
const { stream, videoTrack } = createVideoStream();
const getDisplayMedia = vi.fn(async () => stream);
vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } });
await expect(captureDisplay(QUALITY_PROFILES["1080p30"])).resolves.toBe(
stream,
);
expect(getDisplayMedia).toHaveBeenCalledWith({
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 },
},
audio: true,
});
expect(videoTrack.contentHint).toBe("motion");
});
it("changes capture constraints without selecting the source again", async () => {
const { stream, videoTrack } = createVideoStream();
await applyCaptureProfile(stream, QUALITY_PROFILES["720p30"]);
expect(videoTrack.applyConstraints).toHaveBeenCalledWith({
width: { ideal: 1280, max: 1280 },
height: { ideal: 720, max: 720 },
frameRate: { ideal: 30, max: 30 },
});
});
it("sets the screen-share sender degradation preference", async () => {
const parameters = {
encodings: [],
} as unknown as RTCRtpSendParameters;
const setParameters = vi.fn(async () => undefined);
const sender = {
getParameters: () => parameters,
setParameters,
} as unknown as RTCRtpSender;
await configureVideoSender(sender, QUALITY_PROFILES["1080p60"]);
expect(parameters.encodings[0]).toMatchObject({
maxBitrate: 8_000_000,
maxFramerate: 60,
});
expect(parameters.degradationPreference).toBe("balanced");
expect(setParameters).toHaveBeenCalledWith(parameters);
});
it("pauses only the video track and can resume it", () => {
const { stream, videoTrack } = createVideoStream();
expect(setVideoPaused(stream, true)).toBe(true);
expect(videoTrack.enabled).toBe(false);
expect(setVideoPaused(stream, false)).toBe(true);
expect(videoTrack.enabled).toBe(true);
});
});