chore: merge main into spike/hybrid-warm-fallback
# Conflicts: # docs/README.md # docs/project-memory.md # docs/research/low-server-media-routes.md # docs/status.md # src/client/pages/HostPage.tsx # src/server/signaling.ts # src/shared/protocol.ts # tests/protocol.test.ts # tests/server-signal.test.ts
This commit is contained in:
commit
a75c755d7c
38 files changed
+2133
-510
No files matched your search
+1
-1
@@ -16,6 +16,6 @@
|
||||
- [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.
|
||||
- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): proposed, experiment-only standard-WebRTC peer-assisted spike with hard fanout and failure gates.
|
||||
- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed automatic P2P/peer/SFU routing, token-free standby prewarm, transitions, and hard host-edge budget.
|
||||
- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed, default-off automatic P2P/peer/SFU preparation, token-free standby prewarm, commit, failback, and rollback with a hard host-edge budget.
|
||||
|
||||
Documentation and project memory are part of the product source of truth. Update them in the same change that alters the corresponding requirement or architecture.
|
||||
@@ -9,10 +9,10 @@ target. Automatic, viewer-transparent routing through direct P2P,
|
||||
peer-assisted media, and an enabled SFU fallback is a standing requirement and
|
||||
will be governed by a later ADR.
|
||||
|
||||
Draft ADR-0003 and Proposed ADR-0004 do not supersede this production baseline.
|
||||
They isolate an optional SFU implementation and a bounded peer-assisted
|
||||
experiment respectively. A later Accepted ADR is required before either changes
|
||||
the default topology.
|
||||
Historical ADR-0003's explicit whole-room SFU mode is rejected/superseded.
|
||||
Proposed ADR-0004 and ADR-0005 do not supersede this production baseline; they
|
||||
govern default-off peer-assisted and automatic optional-SFU experiments. A
|
||||
later Accepted ADR is required before either changes the production topology.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -28,7 +28,7 @@ Use separate control and media planes:
|
||||
- Only pairs that cannot connect directly use authenticated TURN. Production requires STUN plus TURN over UDP and TCP; TURN/TLS is an optional restrictive-network enhancement, using its standard TCP port 5349 by default. Port 443 is optional and requires a dedicated public IP or a validated layer-4/SNI route when HTTPS already owns that address and port.
|
||||
- Candidate selection is independent per pair. A room may simultaneously contain direct and relayed viewers without moving working peers onto the server.
|
||||
- The deployed MVP broadcaster creates one peer connection per viewer. Rooms default to eight viewers and deployments may configure a limit from 1 through 16. Eight is an admission default, not a validated media-performance promise. A newer product target caps host media fanout at two; the current implementation does not satisfy that target above two viewers, and Proposed ADR-0004 owns the isolated experiment rather than silently changing this accepted baseline.
|
||||
- An SFU is not the default path for the normal product envelope. Severe user-observed multi-viewer degradation triggered Draft ADR-0003/PR #12 as optional infrastructure, but it is not merged, deployed, or accepted while that PR remains Draft. If configured later, it may be selected automatically only after cheaper endpoint-carried routes cannot satisfy the route contract.
|
||||
- An SFU is not the default path for the normal product envelope. Closed PR #12's explicit whole-room mode is superseded by merged PR #17's default-off automatic fallback. When a deployment configures SFU capacity, ADR-0005 permits selecting it only after cheaper endpoint-carried routes fail; that experiment is not deployed or accepted for production.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ custom packet-forwarding protocol. Real multi-viewer use later degraded severely
|
||||
The product now also has a hard target that a broadcaster must never carry more
|
||||
than two outgoing media edges.
|
||||
|
||||
Draft PR #12 proposes an independently reversible deployment-level SFU under
|
||||
ADR-0003, but it is not merged or accepted. Making an SFU the normal path would
|
||||
move every viewer's egress cost to the server. TeamSpeak-style shared encoding
|
||||
Closed PR #12's explicit whole-room SFU proposal under ADR-0003 is historical
|
||||
and superseded. Merged PR #17 instead supplies ADR-0005's default-off automatic
|
||||
optional-SFU fallback. Making that fallback the normal path would still move
|
||||
every viewer's egress cost to the server. TeamSpeak-style shared encoding
|
||||
reduces host encode work but still sends one network copy to every viewer, so it
|
||||
cannot meet the new fanout target alone.
|
||||
|
||||
@@ -67,17 +68,23 @@ downstream `RTCPeerConnection`. The browser therefore decodes and re-encodes at
|
||||
every relay hop. Existing audio follows the same stream when capture provides
|
||||
it; the experiment does not invent a separate audio protocol.
|
||||
|
||||
The peer-assisted wire accepts exactly `1080p60`, `1080p30`, and `720p30` as
|
||||
room quality profile IDs. The signaling server holds the current ID in bounded,
|
||||
per-room memory only; it is not persisted. An authenticated peer-assisted
|
||||
snapshot includes the current value, the host reasserts its local choice before
|
||||
reconciling assigned children, and live changes are sent to all online viewers.
|
||||
The viewer relay keeps one desired profile for its current and future child.
|
||||
Sender setup, stream replacement, and profile changes are serialized inside
|
||||
each `HostPeer`, with the latest desired value winning. The ordinary P2P
|
||||
authenticated wire remains unchanged and profile-control messages are forbidden
|
||||
in that mode. This coordinates sender targets; it does not prove achieved
|
||||
resolution, bitrate, frame rate, or resource cost.
|
||||
The authoritative peer-assisted wire value is one exact `QualitySettings`
|
||||
object, not a profile ID. It accepts only 720p/1080p/1440p, integer 15-60 fps,
|
||||
integer 2-12 Mbps, and clarity/balanced/fluid preference, with missing, extra,
|
||||
or out-of-range fields rejected. The three presets remain UI recommendations,
|
||||
not protocol states. The server keeps the latest object in bounded per-room
|
||||
memory only; it is not persisted. Authentication carries it, the host reasserts
|
||||
its local value before reconciling children, and live changes reach online
|
||||
viewers without an acknowledgement protocol.
|
||||
|
||||
`HostPeer`, the current and future viewer relay sender, and an enabled SFU
|
||||
publisher consume that same last-wins setting. Initial sender setup, successful
|
||||
track replacement, and live changes use the same serialized mutation path and
|
||||
read back requested/applied bitrate, frame rate, scale, and preference. A
|
||||
rejected mutation rolls back or fails closed through the existing route
|
||||
controller. The ordinary P2P authenticated wire remains unchanged and rejects
|
||||
room-setting messages. These are sender ceilings and preferences, not proof of
|
||||
achieved resolution, bitrate, frame rate, or resource cost.
|
||||
|
||||
Encoded Transform, DataChannel media, WebCodecs rendering, dummy-sender byte
|
||||
replacement, custom congestion control, codec ladders, multiple trees, mobile
|
||||
@@ -102,9 +109,10 @@ viewing remains required.
|
||||
|
||||
## Acceptance Gate
|
||||
|
||||
Test 1, 3, 5, and 8 viewers for 30 minutes across the 1080p60, 1080p30, and
|
||||
720p30 profiles with controlled per-edge RTT at or below 40 ms and loss at or
|
||||
below 1%. Current desktop Chrome and Edge form the relay cohort; current Android
|
||||
Test 1, 3, 5, and 8 viewers for 30 minutes across the three recommended ceiling
|
||||
combinations, plus any advanced combination proposed for production, with
|
||||
controlled per-edge RTT at or below 40 ms and loss at or below 1%. Current
|
||||
desktop Chrome and Edge form the relay cohort; current Android
|
||||
Chrome and iOS Safari join last as leaf checks. The protocol has no
|
||||
relay-capability bit, so controlled join order is the only enforcement and this
|
||||
spike is not safe for arbitrary-user deployment.
|
||||
@@ -120,8 +128,8 @@ The proposal advances only if every condition holds:
|
||||
zero-copy forwarding;
|
||||
- excluding explicit TURN paths, the application server carries no media;
|
||||
- host and relay upload remain within 20% of `childCount * observedBitrate`;
|
||||
- authentication and live profile changes leave every current and future child
|
||||
sender targeting the same latest room profile without altering the P2P wire;
|
||||
- authentication and live setting changes leave every current and future child
|
||||
sender targeting the same latest room setting without altering the P2P wire;
|
||||
- first picture is at most 3 seconds, a 60 fps run does not remain below 50
|
||||
decoded fps for more than 5 seconds, and depth-four p95 glass-to-glass
|
||||
latency is at most 350 ms;
|
||||
@@ -180,11 +188,12 @@ Negative:
|
||||
|
||||
- ADR-0001 remains the accepted production baseline. This proposal narrows its
|
||||
peer-tree rejection only enough to run an isolated experiment.
|
||||
- Draft ADR-0003 in PR #12 remains the separate optional-SFU proposal. This
|
||||
experiment neither accepts/removes it nor implements the required automatic
|
||||
P2P/peer-assisted/SFU route controller.
|
||||
- ADR-0003/PR #12 is retained only as the rejected/superseded explicit
|
||||
whole-room SFU history. ADR-0005 and merged PR #17 own the default-off
|
||||
automatic optional-SFU fallback; this ADR does not accept that route for
|
||||
production.
|
||||
- If a later ADR accepts peer-assisted media, it must state exactly which parts
|
||||
of ADR-0001 and ADR-0003 it supersedes.
|
||||
of ADR-0001 and ADR-0005 it supersedes.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0005: Automatic Hybrid Media Routing
|
||||
|
||||
- Status: Proposed - Draft Implementation, Experiment Only
|
||||
- Status: Proposed - Default-Off Implementation, Experiment Only
|
||||
- Date: 2026-08-19
|
||||
|
||||
## Context
|
||||
@@ -24,8 +24,9 @@ relay have a downstream budget of at most two active media edges. The current
|
||||
browser relay remains stricter at one child.
|
||||
|
||||
ADR-0004 implements automatic peer assignment but not cross-mode fallback.
|
||||
Draft PR #12 implements a mutually exclusive process-wide `p2p|sfu` mode. That
|
||||
model cannot satisfy the required priority or minimize server egress.
|
||||
Closed PR #12 proposed a mutually exclusive process-wide `p2p|sfu` mode; this
|
||||
ADR and merged PR #17 supersede that model because it cannot satisfy the route
|
||||
priority or minimize server egress.
|
||||
|
||||
## Proposed Experiment
|
||||
|
||||
@@ -245,9 +246,9 @@ encrypted from the SFU operator; the deployment and UI must not claim otherwise.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Port only LiveKit dependencies, token issuance, deployment templates, and
|
||||
thin publisher/subscriber classes from Draft PR #12. Do not merge its
|
||||
mutually exclusive protocol or page branches.
|
||||
1. Reuse only the LiveKit dependencies, token issuance, deployment templates,
|
||||
and thin publisher/subscriber boundary developed in closed PR #12. Do not
|
||||
restore its mutually exclusive protocol or page branches.
|
||||
2. Split publisher lifecycle into connect, activate, deactivate, and profile
|
||||
update; make subscriber connections selective and inactive until assigned.
|
||||
3. Add versioned route state and pure invariant/property tests.
|
||||
@@ -301,9 +302,9 @@ Negative:
|
||||
- This proposal corrects the automatic-migration interpretation in ADR-0001
|
||||
without changing the currently deployed MVP.
|
||||
- ADR-0004 remains the bounded full-stream browser-relay experiment.
|
||||
- If accepted, this ADR supersedes Draft ADR-0003's process-wide explicit media
|
||||
mode with automatic hybrid fallback using the same optional LiveKit building
|
||||
blocks.
|
||||
- This experiment and merged PR #17 supersede ADR-0003/closed PR #12's
|
||||
process-wide explicit media mode with default-off automatic hybrid fallback.
|
||||
ADR-0003 remains historical rejected/superseded context.
|
||||
- Native shared encoding and two-tree striped distribution remain orthogonal
|
||||
data-plane experiments under separate decisions.
|
||||
|
||||
|
||||
+17
-15
@@ -6,45 +6,47 @@ Last updated: 2026-08-19
|
||||
|
||||
- Build Discord/KOOK/Oopz/TeamSpeak-like low-latency game screen sharing for one broadcaster and a small trusted friend group. Public or large broadcasts belong on OBS/Twitch-class services.
|
||||
- Viewers should join from a normal desktop or mobile browser. A packaged/native sender is a planned later stage for shared encoding and may also improve capture and audio without changing the Web viewer requirement.
|
||||
- Use a Photon-style central rendezvous service for access, rooms, signaling, deterministic topology, STUN, TURN fallback, and observability while clients carry media whenever practical.
|
||||
- Use a small central service for access, rooms, signaling, deterministic topology, STUN/TURN, and observability while clients carry media whenever practical.
|
||||
- Server bandwidth cost is a primary constraint. The ordered media ladder is direct P2P, then measured peer assistance, then an enabled user-operated or central SFU fallback. Route selection and failure recovery must be automatic and viewer-transparent, while remaining deterministic, budget-driven, and small enough to reason about. This has always been the product intent; earlier text prohibiting silent migration was an incorrect interpretation and is superseded.
|
||||
- Host and relay-capable endpoints each have a target budget of at most two downstream media edges. Every P2P edge independently uses direct ICE or authenticated TURN fallback. With two edges, the long-term native/encoded-object path should reuse one compatible encoded output; the current browser experiment is stricter at one viewer child and re-encodes.
|
||||
- Packet or layer striping across two peer trees is a recorded candidate for reducing endpoint upload toward one full-stream bitrate. It requires a separate bounded experiment for multi-parent assembly, loss recovery, synchronization, churn, and sub-second latency; it is not part of the current full-stream two-chain implementation.
|
||||
- Browser convenience does not override reachability or honest resource accounting. Shared encoding reduces compute, not each viewer's last-hop copy.
|
||||
- Shared encoding reduces compute, not each viewer's last-hop copy.
|
||||
- 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. 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`.
|
||||
- Keep desktop Chrome/Edge and the responsive Web viewer as the baseline. Validate Android Chrome and iOS Safari as leaves.
|
||||
- Use direct host P2P for one or two viewers. `main` carries ADR-0004's 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.
|
||||
- Keep the full-stream browser experiment bounded. If it needs a different media plane, mark that experiment failed and evaluate native RTP relay, encoded-object striping, SVC, or FEC in separate measured spikes rather than hiding the change inside ADR-0004.
|
||||
- Keep the browser experiment bounded. Evaluate native RTP relay, encoded-object striping, SVC, or FEC only in separate measured spikes.
|
||||
- Plan a separate native-sender ADR for TeamSpeak-style shared encoding regardless of the browser relay result. One compatible encoded output may feed at most two standard WebRTC packetizers to reduce host encode work, but each edge still consumes upload bandwidth. This work is not part of the current Draft and cannot rescue another failed browser-relay gate.
|
||||
- Keep user-operated mini-SFU deployment and central single-node SFU Draft PR #12 as optional infrastructure. When configured, the route controller may select them automatically as the last fallback; their fanout egress belongs to their operator and does not justify Redis or multi-node infrastructure.
|
||||
- Keep automatic routing behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make and never exceeds two active edges, and a binary per-session relay capability keeps detected mobile/iPad clients as leaves. Token-free authenticated standby prewarm now passes the local sub-second gate; public transport and load gates remain.
|
||||
- Keep user-operated or central single-node SFU capacity optional. When configured, `main`'s #17 route controller may select it only as the final fallback; its fanout egress belongs to its operator and does not justify Redis or multi-node infrastructure. Closed PR #12's explicit whole-room SFU mode is superseded, not a current option.
|
||||
- Keep `main`'s automatic routing default-off behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make and never exceeds two active edges, and a binary per-session relay capability keeps detected mobile/iPad clients as leaves. Draft PR #20's token-free standby prewarm passes the local sub-second gate; public transport and load gates remain.
|
||||
- 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 3/5/8 Mbps profiles as ceilings. Production users report severe degradation across all three; run one controlled 1/2/3-viewer capture before changing constants, then A/B clarity-first against `balanced`. Do not hide actual limits or add an automatic score/controller without evidence.
|
||||
- Treat every quality setting as a ceiling. The current Draft defaults to clarity-first and adds bounded manual controls plus sender readback; production remains `balanced`, and its reported degradation is still unclassified. Run the 1/2/3-viewer plus TURN matrix before changing ceilings or adding any automatic controller.
|
||||
- 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 repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn.
|
||||
- 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 profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
|
||||
- `main` includes PR #19's equal idle-stage share/join actions and default-closed technical details plus PR #21's bounded advanced quality settings and visible SFU sender warnings. These changes are not deployed.
|
||||
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
|
||||
- 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, standard remote-track relay, and synchronized 1080p60/1080p30/720p30 room profiles. 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.
|
||||
- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU for that share. Draft PR #20 advertises a non-secret standby URL only when configured and performs one token-free SDK import plus DNS/TLS/HTTP warmup per client; it creates no participant/media edge, and LiveKit remains dormant when unconfigured.
|
||||
- `main` contains deterministic peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is not deployed. Native shared encoding and custom encoded browser transport are not implemented.
|
||||
- `main` also contains ADR-0005's default-off route controller: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back for that share. LiveKit is dormant when unconfigured.
|
||||
- Draft PR #20 advertises a non-secret standby URL only when fallback is configured and performs one token-free SDK import plus DNS/TLS/HTTP warmup per client; it creates no participant or media edge and changes nothing when unconfigured.
|
||||
- Corrected Chrome 151/LiveKit 1.13.5 same-leaf/two-root cold/standby A/B reduced failure-to-active from 1.481 seconds to 200 ms and failure-to-render from 2.257 seconds to 319.7 ms. The leaf decoded/rendered 31 new frames and 25 ms sampling kept host edge peak two. It is localhost, headless, synthetic 720p30 video only; the roughly 86% result combines SDK download/parse and network prewarm and must not be extrapolated to public networks.
|
||||
- Draft PR #16 (`5b09f0a`) proves one Pion RTP write fans equivalent data to two transports with independent SSRCs. It has no encoder and proves neither physical shared encode nor browser readiness.
|
||||
- Chrome 151 short synthetic `1/3/5/8` runs kept host edges at two, relay edges at one, and all viewers decoding; a three-viewer relay close recovered in 5.32 seconds. This proves topology/control only, not quality, load, endurance, or silent partitions.
|
||||
- The peer-assisted authenticated snapshot and host-only update synchronize one bounded, non-persistent room profile. Online viewers receive changes; `ViewerRelay` uses the latest desired profile for current and future children, while `HostPeer` serializes sender setup, stream replacement, and last-wins profile updates. The ordinary P2P wire is unchanged. Real 1/3/5/8 quality, resource, and latency measurements remain pending.
|
||||
- Chromium 151 kept relay/sender/signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while the leaf decoded. Synthetic 640x360/30 proves control propagation, not full-resolution load.
|
||||
- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Initial, replaced, and updated senders share serialized configure/readback; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action. Successful replacement rollback retains the original failure warning.
|
||||
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one. This is control evidence, not a quality, load, public-network, or endurance claim.
|
||||
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
|
||||
- Draft PR #17 passes CI. Draft PR #20 passes CI, type checking, 20 Vitest files/259 tests, both builds, dependency audit, and repository hygiene. Production separately passed 108 tests and deployment checks.
|
||||
- The integrated PR #20 revision passes repository hygiene, type checking, 22 Vitest files/274 tests, both builds, and the existing Chrome evidence. Production separately passed 108 tests and deployment checks.
|
||||
|
||||
## Provisional Quality Targets
|
||||
|
||||
@@ -70,5 +72,5 @@ These are measurement gates, not performance claims.
|
||||
- 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`, `docs/research/low-server-media-routes.md`, `docs/research/advanced-peer-distribution.md`, and `docs/research/native-shared-encode-sender.md`
|
||||
- Architecture: ADR-0001, ADR-0002, proposed experiments ADR-0004/ADR-0005, and Draft PR #12's unaccepted ADR-0003
|
||||
- Architecture: ADR-0001, ADR-0002, proposed experiments ADR-0004/ADR-0005, and historical rejected/superseded ADR-0003
|
||||
- Deployment and maintenance: `docs/deployment.md`, `docs/maintenance.md`, `AGENTS.md`, and `.codex/`
|
||||
@@ -29,10 +29,10 @@ The smallest current plan is:
|
||||
disappear.
|
||||
|
||||
Any browser-spike failure other than isolated relay re-encoding closes that
|
||||
browser-relay route. It does not cancel the separate native sender plan. Draft
|
||||
SFU PR #12 remains unmerged and undeployed. ADR-0005 now owns a separate Draft
|
||||
implementation of the automatic cross-mode controller; it is also undeployed
|
||||
and has passed a corrected localhost Chrome/LiveKit functional smoke. Public
|
||||
browser-relay route. It does not cancel the separate native sender plan. Closed
|
||||
PR #12's explicit whole-room SFU mode is superseded. ADR-0005 and merged PR #17
|
||||
own the default-off automatic cross-mode controller; it remains undeployed.
|
||||
Corrected localhost Chrome/LiveKit functional recovery passed, while public
|
||||
transport, quality, load, and browser validation remain open.
|
||||
|
||||
## Token-Free SFU Standby Prewarm
|
||||
@@ -97,7 +97,7 @@ lost.
|
||||
| Native shared-encode host | Host targets one encode for at most two standard WebRTC edges | libwebrtc public-API proxy risk spike, with Pion as fallback | Planned separate sender phase; still pays per-edge upload |
|
||||
| Native volunteer encoded-RTP relay | Each volunteer forwards one encoded copy | Native install, RTP/RTCP forwarding, packaging, and opt-in relay policy | Conditional experiment only if relay re-encoding is the sole browser-spike failure |
|
||||
| User-operated mini-SFU | User's SFU emits viewer copies | Separate deployment and its egress bill; running it on the host does not reduce that host's uplink | Optional capacity; automatic final fallback when enabled |
|
||||
| Central single-node SFU | Service SFU emits viewer copies | Lowest endpoint relay burden; service pays approximately `N * B` egress | Optional capacity; automatic final fallback when enabled; Draft PR #12 only |
|
||||
| Central single-node SFU | Service SFU emits viewer copies | Lowest endpoint relay burden; service pays approximately `N * B` egress | Optional capacity; automatic final fallback through merged PR #17 when enabled |
|
||||
| SVC plus multiple trees | Peers emit striped layer copies across several trees | Layer scheduling, reassembly, redundancy, and more churn state | Separate conditional spike; target endpoint upload near `B` |
|
||||
| Network coding | Peers or servers emit coded blocks | Generations, buffering, decoding, integrity, and a custom media plane | Trace/FEC spike only; optimize loss recovery, not clean bandwidth |
|
||||
| MoQ | Publishers and MoQ relays emit object copies | New transport, packaging, player, relay, and auth stack | Optional central-fallback benchmark; still pays server egress |
|
||||
|
||||
@@ -183,41 +183,59 @@ Per-edge ICE remains independent. An edge may be direct or may use authenticated
|
||||
TURN, so peer assistance reduces normal server media traffic but cannot promise
|
||||
zero server traffic in restrictive networks.
|
||||
|
||||
## Implemented Quality Profile Coordination
|
||||
## Implemented Bounded Quality Coordination
|
||||
|
||||
The current Draft implementation coordinates one room profile across the
|
||||
peer-assisted tree without adding adaptation logic. Its strict ID set is
|
||||
`1080p60`, `1080p30`, and `720p30`, matching the existing capture and sender
|
||||
profiles. The signaling server stores the current ID in a room-count-bounded
|
||||
in-memory map, defaults to `1080p60`, includes it in peer-assisted authenticated
|
||||
snapshots, and broadcasts host changes to online viewers. The value is retained
|
||||
when sharing stops, removed when the room is abandoned or expires, and is not
|
||||
written to SQLite. The standard P2P authenticated variant is unchanged and
|
||||
profile-control messages are forbidden in that mode.
|
||||
The current Draft coordinates one strict `QualitySettings` object across the
|
||||
peer-assisted tree without adaptation logic. It accepts only 720p/1080p/1440p,
|
||||
integer 15-60 fps, integer 2-12 Mbps, and the three standard degradation
|
||||
preferences; missing, extra, or out-of-range fields fail schema validation.
|
||||
The three visible presets are recommendations rather than wire IDs. The server
|
||||
stores the latest complete object in a room-count-bounded in-memory map,
|
||||
defaults to 1080p60 at 8 Mbps with clarity priority, includes it in
|
||||
peer-assisted authenticated snapshots, and broadcasts host changes to online
|
||||
viewers. The value survives a stopped share, is removed with the room, and is
|
||||
not written to SQLite. Ordinary P2P authentication remains unchanged and
|
||||
setting-control messages are forbidden in that mode.
|
||||
|
||||
After peer-assisted authentication, the host reasserts its local selection
|
||||
before reconciling assigned children. A viewer records the snapshot or update
|
||||
before applying its assignment. `ViewerRelay` keeps a synchronously updated
|
||||
desired profile and serializes profile changes with stream replacement, so both
|
||||
desired setting and serializes setting changes with stream replacement, so both
|
||||
an existing child and a later replacement start from the latest target. Inside
|
||||
`HostPeer`, initial sender configuration, stream replacement, and profile
|
||||
updates share one mutation queue; operations read the latest desired profile at
|
||||
`HostPeer`, initial sender configuration, stream replacement, and setting
|
||||
updates share one mutation queue; operations read the latest desired setting at
|
||||
execution time, giving rapid changes last-wins behavior without versions or
|
||||
acknowledgements.
|
||||
|
||||
If the host changes quality while signaling cannot accept the room update, its
|
||||
current local senders still apply the setting but the UI reports that room sync
|
||||
is waiting for reconnect. The next successful host authentication reasserts the
|
||||
latest object; no acknowledgement state machine is added.
|
||||
|
||||
The optional SFU publisher follows the same boundary: first publication,
|
||||
successful track replacement, and live setting changes all configure the real
|
||||
`RTCRtpSender`, then retain requested/applied bitrate, frame rate, scale, and
|
||||
preference. Configuration rejection uses the existing rollback/fail-closed
|
||||
path rather than silently leaving the publication on unverified parameters.
|
||||
After initial activation or source replacement settles, the host reads and
|
||||
shows the route warning; a successful replacement rollback retains the original
|
||||
failure instead of clearing it with the restored sender readback.
|
||||
|
||||
This only removes the previous fixed-1080p60 relay envelope. Browser constraints
|
||||
and RTP sender parameters remain targets, so achieved bitrate, frame rate,
|
||||
resolution, encode work, and cross-hop quality still require the measurement
|
||||
matrix below. It does not add or imply shared encoding.
|
||||
|
||||
A controlled Chromium 151 loopback smoke used one host, three viewers, and a
|
||||
synthetic 640x360/30 source. The downstream relay kept the same peer, sender,
|
||||
and signaling generations while its configured ceilings changed from
|
||||
8 Mbps/60 fps to 5 Mbps/30 fps and then 3 Mbps/30 fps. Each real
|
||||
`setParameters()` call and both host `applyConstraints()` calls succeeded, and
|
||||
the leaf kept decoding. This verifies wire ordering, relay propagation, and
|
||||
connection preservation only. It does not verify 1080p output, visual quality,
|
||||
CPU/GPU cost, TURN, public networks, or endurance.
|
||||
A controlled Chrome 151 loopback smoke used one host, three viewers, and a
|
||||
synthetic 720p30 source. Balanced and clarity settings reached every
|
||||
participant; every baseline active outbound video sender displayed the matching
|
||||
requested/applied preference; peer-connection fingerprints stayed unchanged;
|
||||
and every viewer's decoded-frame and `requestVideoFrameCallback` counters grew
|
||||
after each change. The harness brought each viewer page to the foreground for
|
||||
its render check because Chrome throttles background frame callbacks. Host
|
||||
fanout stayed at two and relay fanout at one. This is control and continuity
|
||||
evidence only, not visual-quality, full-resolution, load, TURN, public-network,
|
||||
or endurance evidence.
|
||||
|
||||
Peer multicast research such as SplitStream demonstrates why load-balanced,
|
||||
failure-tolerant overlays normally introduce multiple trees and content
|
||||
@@ -280,9 +298,10 @@ existing screen-audio track when the browser provides one. SVC/simulcast,
|
||||
custom encoded transport, FEC changes, multi-tree striping, transcoding,
|
||||
background mobile relay, and automatic SFU migration are excluded.
|
||||
|
||||
Run 1, 3, 5, and 8 viewers for 30 minutes across the 1080p60, 1080p30, and
|
||||
720p30 profiles under controlled per-edge RTT at or below 40 ms and loss at or
|
||||
below 1%. Record topology generation and depth, selected candidate type, host
|
||||
Run 1, 3, 5, and 8 viewers for 30 minutes across the three recommended ceiling
|
||||
combinations, plus any advanced combination proposed for production, under
|
||||
controlled per-edge RTT at or below 40 ms and loss at or below 1%. Record
|
||||
topology generation and depth, selected candidate type, host
|
||||
and relay upload, packets lost, jitter, frames encoded/decoded/dropped, total
|
||||
encode/decode time, decoded FPS, `qualityLimitationReason`, first picture,
|
||||
reparent time, CPU, GPU, and glass-to-glass latency.
|
||||
|
||||
@@ -6,17 +6,16 @@
|
||||
|
||||
## 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.
|
||||
Screener should not optimize one quality dimension at the expense of the
|
||||
others. `maintain-framerate` may preserve motion by reducing resolution until
|
||||
game UI, maps, subtitles, and text become unreadable; `maintain-resolution`
|
||||
may instead lower frame rate. Neither preference overrides congestion control.
|
||||
|
||||
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 current bounded implementation retains the game-oriented `motion` hint,
|
||||
defaults to `maintain-resolution`, and offers explicit balanced and fluid
|
||||
choices. This is a readability-first default, not a quality guarantee: the
|
||||
WebRTC API describes a user-agent preference rather than its algorithm, so
|
||||
Chromium, Firefox, and Safari behavior still needs measured comparison.
|
||||
|
||||
The inspected Chromium/libwebrtc source chain makes a screen-only shortcut
|
||||
especially unsafe to assume for Screener. The JavaScript `motion` hint reaches
|
||||
@@ -102,28 +101,38 @@ resolution, frame rate, or bitrate.
|
||||
- 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.
|
||||
- The video track keeps `contentHint = "motion"`; recommended profiles default
|
||||
to `maintain-resolution`, with explicit `balanced` and
|
||||
`maintain-framerate` choices. None promises an emitted resolution or rate.
|
||||
- `maxBitrate` and `maxFramerate` are ceilings. They are neither minimums nor
|
||||
target guarantees, and the project does not use SDP bitrate hacks.
|
||||
- The folded advanced panel accepts only 720p/1080p/1440p, integer 15-60 fps,
|
||||
2-12 Mbps, and the three preferences. Audio stereo/bitrate is not exposed
|
||||
because standard sender controls cannot reliably guarantee the negotiated
|
||||
browser audio mode.
|
||||
- Every sender update derives from `getParameters()`, calls `setParameters()`,
|
||||
then reads requested/applied bitrate, frame rate, scale, and preference.
|
||||
Rejection or browser rewriting is visible rather than console-only.
|
||||
- One strict room setting is last-wins for current/future peer relays and the
|
||||
optional SFU publisher. Ordinary P2P keeps that state local and does not add
|
||||
it to the authenticated wire.
|
||||
- Pausing the picture disables the existing video track, producing black video
|
||||
without closing the room or media connection. Audio remains enabled.
|
||||
|
||||
The next bounded A/B changes only degradation preference: keep `motion`, compare
|
||||
the current `balanced` behavior with `maintain-resolution`, and expose the
|
||||
result as a default clarity-first choice plus optional balanced/fluid choices.
|
||||
It may trade frame rate for readability and is not a universal fix. The
|
||||
advanced panel remains bounded to resolution ceiling, frame-rate ceiling,
|
||||
bitrate ceiling, quality priority, and audio mode; it does not expose raw SDP or
|
||||
codec internals.
|
||||
The implementation deliberately stops at manual bounded controls. It adds no
|
||||
composite score, periodic adjustment, codec forcing, SDP bitrate manipulation,
|
||||
or scene detector. Three consecutive samples of one non-`none` native
|
||||
`qualityLimitationReason` produce one explanatory warning; a reason change or
|
||||
recovery resets it and never triggers a media action.
|
||||
|
||||
A Chromium 151 loopback smoke with one host, three viewers, and a synthetic
|
||||
640x360/30 source kept the same relay peer, sender, and signaling generations
|
||||
while applying 8 Mbps/60 fps, 5 Mbps/30 fps, and 3 Mbps/30 fps sender ceilings.
|
||||
The leaf continued decoding after both live switches. This verifies control
|
||||
propagation and connection preservation, not actual 1080p output, visual
|
||||
quality, CPU/GPU cost, public-network behavior, or sustained performance.
|
||||
A Chrome 151 loopback smoke with one host, three viewers, and synthetic 720p30
|
||||
video propagated balanced then clarity settings to every participant. Every
|
||||
baseline active outbound video sender displayed the matching requested/applied
|
||||
preference, peer-connection fingerprints stayed unchanged, and every viewer's
|
||||
decoded-frame and foreground `requestVideoFrameCallback` counters grew after
|
||||
each change. Host media edges peaked at two and relay edges at one. This verifies
|
||||
controls and continuity, not visual quality, full-resolution performance,
|
||||
CPU/GPU cost, public networks, or sustained behavior.
|
||||
|
||||
## Deliberate Non-Goals
|
||||
|
||||
@@ -166,6 +175,7 @@ profiles before adding an application-level adaptation controller.
|
||||
- [W3C Screen Capture](https://www.w3.org/TR/screen-capture/)
|
||||
- [W3C WebRTC](https://www.w3.org/TR/webrtc/)
|
||||
- [W3C WebRTC Statistics](https://www.w3.org/TR/webrtc-stats/)
|
||||
- [MDN `RTCRtpSender.setParameters()`](https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters)
|
||||
- [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)
|
||||
|
||||
@@ -120,7 +120,7 @@ IETF 对 mesh/SFU 的拓扑说明见 [RFC 7667](https://www.rfc-editor.org/rfc/r
|
||||
- 若一开始就有多条 `relay`,应提示服务器带宽正在增加。
|
||||
- 桌面和手机观看者使用同一个 Web 播放端;分享者不要求朋友安装完整客户端。
|
||||
|
||||
新的产品目标要求分享端 fanout 永远不超过二。Proposed ADR-0004 用一个可整体删除的实验验证第三名及后续 viewer 能否由客户端转发;Draft PR #12 则保留可选单节点 SFU 作为独立对照。自动跨模式兜底是产品要求,但当前两个 Draft 都未实现它;超过小房间上限时仍建议使用外部直播服务。观察项包括:
|
||||
新的产品目标要求分享端 fanout 永远不超过二。Proposed ADR-0004 用一个可整体删除的实验验证第三名及后续 viewer 能否由客户端转发;`main` 中已合并 PR #17 按 ADR-0005 提供默认关闭的自动可选 SFU 兜底。已关闭 PR #12 的显式整房 SFU 模式已被取代,不再作为当前方案;自动路由通过真实门槛前仍不可部署,超过小房间上限时仍建议使用外部直播服务。观察项包括:
|
||||
|
||||
- 正常工作负载持续超过实测可承载的 P2P 人数。
|
||||
- 第一名或多名观看者已使用 TURN,继续 P2P 会重复占用服务器上行。
|
||||
|
||||
+19
-17
@@ -8,11 +8,11 @@ The WebRTC proof of concept at `https://share.bonfire.icu` runs commit
|
||||
`5b2fb005f6f7` with live quality/source changes, video pause, protected SQLite
|
||||
rooms, sequential IDs, and reusable links.
|
||||
|
||||
Production still creates one host peer connection per viewer. Draft PR #13 adds
|
||||
default-off two-chain peer assistance with per-hop re-encoding; ADR-0005 adds
|
||||
optional automatic SFU fallback in Draft PR #17. Corrected recovery passes the
|
||||
local sub-second gate with token-free standby prewarm in Draft PR #20; neither
|
||||
PR is merged or deployed.
|
||||
Production still creates one host peer connection per viewer. `main` now
|
||||
contains the default-off two-chain peer assistance, optional automatic SFU
|
||||
fallback, simplified entry/diagnostics, and bounded quality settings from merged
|
||||
PRs #13, #17, #19, and #21; none is deployed. Draft PR #20's token-free standby
|
||||
prewarm passes the local sub-second recovery gate.
|
||||
|
||||
## Current Snapshot
|
||||
|
||||
@@ -23,35 +23,36 @@ PR is merged or deployed.
|
||||
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
|
||||
- Complete fallback configuration adds a non-secret standby URL to peer-assisted authentication. Host and viewers import the SDK and make one token-free DNS/TLS warmup; no configuration means no field, import, request, participant, or media edge.
|
||||
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
|
||||
- Peer-assisted profile state is bounded, memory-only, absent from ordinary P2P wire, and applied to current and future relay children.
|
||||
- `main` uses one strict, memory-only quality setting for current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first, exposes bounded manual ceilings, and keeps SFU initial/update/replacement warnings visible.
|
||||
- `main` also carries equal idle-stage share/join actions and default-closed technical details. These changes are not deployed.
|
||||
|
||||
## Verified Evidence
|
||||
|
||||
- Production passed 108 tests, both builds, public HTTPS/access/TURN checks, clean activation, and a database restart retaining room `1`.
|
||||
- Draft PR #13 now provides `npm run benchmark:peer-assisted`; the full check passes type checking, 13 Vitest files with 152 tests, and both production builds.
|
||||
- A short Chrome 151 synthetic `1/3/5/8` benchmark passed every topology check: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first decoded frame was about 1.05 seconds. Closing a first-level relay in the three-viewer run recovered in about 5.32 seconds without exceeding host fanout two.
|
||||
- A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding.
|
||||
- A localhost/headless Chrome 151 run with one host, three viewers, and synthetic 720p30 propagated balanced and clarity settings to every participant. Every active sender matched preference readback, peer fingerprints stayed stable, all viewers decoded/rendered new frames, and fanout stayed host two/relay one. This proves control continuity, not visual quality or performance.
|
||||
- Corrected Chrome 151/LiveKit 1.13.5 localhost/headless/video-only same-leaf A/B measured the cold path at 1.481 seconds to active and 2.257 seconds to render. Standby prepare arrived in 5 ms, active in 200 ms, and the same leaf rendered in 319.7 ms with 31 new decoded/frame-callback frames; 25 ms sampling kept host edge peak two. The roughly 86% result combines early SDK download/parse with token-free DNS/TLS/HTTP prewarm, creates no participant/media edge, and does not predict public-network performance.
|
||||
- Draft PR #17 passes CI. Draft PR [#20](https://github.com/TNTcraftHIM/Screener/pull/20) passes CI, type checking, 20 Vitest files/259 tests, both production builds, dependency audit, and repository hygiene.
|
||||
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
|
||||
- The integrated PR #20 revision passes repository hygiene, type checking, 22 Vitest files/274 tests, both production builds, and the existing Chrome evidence above.
|
||||
- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
|
||||
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
|
||||
- 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.
|
||||
|
||||
## Unverified Boundaries
|
||||
|
||||
- The short harness uses synthetic headless capture and proves topology, controls, stats collection, and recovery only. Full-resolution 30-minute runs, relay CPU/GPU, generational quality, controlled loss/RTT, depth latency, and actual game-capture behavior remain unverified.
|
||||
- Full-resolution 30-minute runs, relay CPU/GPU, generational quality, controlled loss/RTT, depth latency, and actual game capture remain unverified.
|
||||
- Android Chrome and iOS Safari remain unverified leaves. Runtime capability conservatively marks detected mobile/iPad clients as leaves; real UA/lifecycle behavior remains open.
|
||||
- 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.
|
||||
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this 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.
|
||||
- Production users report severe resolution/bitrate/FPS degradation across all profiles. A controlled 1/2/3-viewer and TURN sample must distinguish capture, per-edge CPU, bandwidth/path, and receiver limits before changing ceilings or codecs; clarity-first versus `balanced` is the first bounded A/B.
|
||||
- Production users report severe resolution/bitrate/FPS degradation across all profiles. The Draft now permits bounded clarity/balanced comparison, but a controlled 1/2/3-viewer and TURN sample must still distinguish capture, per-edge CPU, path, and receiver limits before any quality claim or automatic controller.
|
||||
- Browser relays do not share encoding. Encoded objects, custom congestion control, multiple trees, and network coding remain separate measured candidates.
|
||||
- The long-term endpoint budget is at most two downstream edges for both the host and relay-capable viewers, with one compatible encoded output reused across both edges where a native media engine can prove it. The current browser spike remains host capacity two/viewer capacity one and performs a new encode at each relay. Packet/layer striping and multi-parent assembly are recorded, not implemented.
|
||||
- The endpoint budget is host/relay at most two downstream edges; a native engine must prove shared encoding. The browser path remains host two/viewer one and re-encodes per relay. Striping and multi-parent assembly are only recorded.
|
||||
- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
|
||||
|
||||
## Next Milestone
|
||||
|
||||
Finish ADR-0005 before UI polish: validate standby gains across public
|
||||
transports, rollback, reconnect, controls, edge counts, egress, and load.
|
||||
Before enabling `main`'s experimental routes, validate standby gains across
|
||||
public transports, rollback, reconnect, controls, edge counts, egress, and load.
|
||||
The quality and routing stacks remain undeployed until those gates pass.
|
||||
|
||||
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
|
||||
resource, quality, latency, recovery, and browser/mobile-leaf matrix.
|
||||
@@ -65,7 +66,8 @@ separate experiments, not ways to relabel a failed browser route.
|
||||
|
||||
## Blockers And Decisions
|
||||
|
||||
Peer assistance and automatic routing remain blocked on ADR-0004/0005 gates.
|
||||
Production enablement of peer assistance and automatic routing remains blocked
|
||||
on ADR-0004/0005 gates.
|
||||
Native shared encode and multi-tree striping remain separate experiments.
|
||||
|
||||
- Whole-system versus selected-game audio for the first release.
|
||||
|
||||
+11
-7
@@ -133,9 +133,9 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate
|
||||
| 1080p30 | 1920x1080、30 fps | 5 Mbps/peer |
|
||||
| 720p30 | 1280x720、30 fps | 3 Mbps/peer |
|
||||
|
||||
浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际结果。视频 track 保留 `contentHint = "motion"`;sender 尝试设置 `maxBitrate`、`maxFramerate` 与 `degradationPreference = "balanced"`。`balanced` 只允许浏览器在帧率和分辨率之间权衡,不保证清晰度优先;具体 Chromium、Firefox 与 Safari 行为仍需实测。不支持 sender 参数时只记录警告。
|
||||
浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。视频 track 保留 `contentHint = "motion"`;待合并质量控制默认使用 `degradationPreference = "maintain-resolution"`,三个推荐档位都采用清晰优先。折叠高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。
|
||||
|
||||
生产反馈显示三个档位都可能被降到难以阅读的分辨率、帧率和码率。下一质量迭代先在同一高动态场景对 1、2、3 名观看者做一次 60 至 90 秒采样,并对 `maintain-resolution` 与当前 `balanced` 做 A/B;默认目标是清晰优先,高级区再提供清晰、平衡和流畅三种偏好。不得只提高 ceiling 或强制 codec。只有实际/target bitrate 长期顶住 ceiling、网络仍有余量且画质仍不足时,才增加更高的可选码率上限。
|
||||
每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。生产版本仍使用 `balanced`,用户报告的严重降级尚未完成受控分类;本改动只提供有界 clarity/balanced 控制,不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者和 TURN 路径。不得只提高 ceiling、强制 codec 或修改 SDP。
|
||||
|
||||
PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、`encoderImplementation` 和 `powerEfficientEncoder`,再依据目标机器测量决定是否优先 H.264。这样避免静态 codec 偏好意外触发软件编码。
|
||||
|
||||
@@ -143,7 +143,7 @@ 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。
|
||||
直播中切换质量不重新打开选源器:对当前视频 track 调用 `applyConstraints()`,再更新所有现有 sender 的参数;新加入、重建的 relay sender 和启用的 SFU publisher 使用同一权威设置。失败时保留当前分享并给出状态,不以重建整个房间补偿。临时暂停只禁用当前视频 track,使远端收到黑画面;音频、房间和连接保持不变,恢复时重新启用同一 track。浏览器标准 API 无法独立保证音频 stereo/bitrate 与最终协商 codec,因此当前高级区不暴露对应控件。
|
||||
|
||||
`frameRate` 约束和 sender 的 `maxFramerate` 都是目标或上限;浏览器仍控制实际捕获、重复帧处理、编码和拥塞降级。静态画面可能由浏览器减少实际编码数据,但标准没有保证每个实现以同一种方式节省采集或 GPU 开销。本 PoC 不添加画面差分、定时改约束或自定义动态 FPS 状态机,先用 `framesEncoded`、实际发送码率、`totalEncodeTime`、质量受限原因和主机资源占用测量真实问题。
|
||||
|
||||
@@ -171,7 +171,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
|
||||
|
||||
每两秒在本地采集一次 `getStats()`。首版不上传原始统计,也不展示候选地址。
|
||||
|
||||
质量诊断必须能按顺序区分:捕获源设置、sender 实际参数、CPU 或带宽 `qualityLimitationReason`、selected candidate/TURN 路径、outbound 与 inbound 差异,以及接收端 dropped/freeze。每次成功调用 `setParameters()` 后读回并记录实际 `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 与 degradation preference;参数拒绝不能只留在控制台。首轮使用浏览器 WebRTC internals 导出和一份脱敏 run manifest,不新增服务端遥测、综合评分器或自动调档状态机。
|
||||
质量诊断必须能按顺序区分:捕获源设置、sender 实际参数、CPU 或带宽 `qualityLimitationReason`、selected candidate/TURN 路径、outbound 与 inbound 差异,以及接收端 dropped/freeze。当前有界实现显示 sender 参数的 requested/applied 读回;同一个非 `none` 原生 `qualityLimitationReason` 连续出现三个采样周期后显示单条原因提示,原因变化、缺失或恢复为 `none` 即重新计数。这只是去抖后的观察,不组合评分、不触发调档或迁移。完整跨路径分类仍由后续真实矩阵验收;首轮使用浏览器 WebRTC internals 导出和一份脱敏 run manifest,不新增服务端遥测。
|
||||
|
||||
分享者逐观看者显示:
|
||||
|
||||
@@ -218,6 +218,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
|
||||
- SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败;
|
||||
- TURN HMAC 固定向量、TTL、生产环境缺少 STUN 或 TURN/UDP+TCP 覆盖时启动失败,并接受可选 TURN/TLS;
|
||||
- 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
|
||||
- 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、可选 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;
|
||||
- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态;
|
||||
- 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知;
|
||||
- TypeScript 类型检查、Vite production build 和仓库 hygiene。
|
||||
@@ -230,6 +231,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
|
||||
- Windows Chrome/Edge 的音轨有/无;
|
||||
- Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换;
|
||||
- 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。
|
||||
- 真实桌面 Chrome 对 balanced 与 clarity 做控制传播和连接身份 smoke;该项只证明直播中应用与不重建,不代替清晰度、负载或公网矩阵。
|
||||
|
||||
真实矩阵未完成前,状态文档必须把这些列为待验证,而不是把设计目标写成产品能力。
|
||||
|
||||
@@ -253,9 +255,9 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
|
||||
|
||||
所有边都继续使用标准 WebRTC。每名 viewer 播放收到的 remote `MediaStream`,并把其中的 remote tracks 加入给唯一 child 的新 `RTCPeerConnection`。该路径会逐跳解码和重编码;实验必须记录每层 encode time、CPU/GPU、`qualityLimitationReason`、帧率、分辨率和延迟,不能称为 zero-copy 或 shared encode。
|
||||
|
||||
Peer-assisted 信令只接受 `1080p60`、`1080p30`、`720p30` 三个 `qualityProfileId`。服务端用受房间容量限制的内存 Map 保存当前值,默认 `1080p60`,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。peer-assisted 鉴权快照携带当前值,host 鉴权后先重报本地选择再创建已分配的 child,直播中换档时通知所有在线 viewers,后加入者直接取得最新快照。普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送档位同步消息都会被拒绝。
|
||||
Peer-assisted 信令只接受一个严格对象:`resolution` 为 `720p | 1080p | 1440p`,`maxFramerate` 为 15 至 60 的整数,`maxBitrate` 为 2,000,000 至 12,000,000 的整数,`degradationPreference` 为 `maintain-resolution | balanced | maintain-framerate`,缺字段、多余字段和越界值都拒绝。服务端用受房间容量限制的内存 Map 保存完整设置,默认 1080p60、8 Mbps、清晰优先,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一完整设置;普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送同步消息都会被拒绝。启用 SFU 时 publisher 也使用这一设置,不另建一套质量协议。
|
||||
|
||||
Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired profile,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;`desiredProfile` 在入队前同步更新,已排队操作执行时读取最新值,使快速连续换档保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
|
||||
Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired settings,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;desired settings 在入队前同步更新,已排队操作执行时读取最新值,使快速连续修改保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
|
||||
|
||||
spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 Chrome/Edge relay、按测试顺序位于链尾的当前 Android Chrome 与 iOS Safari leaf、最多八名 viewer;捕获存在时沿同一标准 WebRTC 路径带上音频。Encoded Transform/DataChannel/WebCodecs 媒体、SVC/simulcast、多树、转码、codec ladder、综合评分、移动端后台 relay 和跨模式 SFU 迁移不在本次 spike 内实现。该范围限制不能解释为拒绝产品所需的自动兜底;验收与失败即放弃条件以 ADR-0004 为准,自动路由由后续独立 ADR 定义。
|
||||
|
||||
@@ -265,7 +267,7 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面
|
||||
|
||||
## 官方与参考资料
|
||||
|
||||
访问日期均为 2026-08-18:
|
||||
访问日期为 2026-08-18 至 2026-08-19:
|
||||
|
||||
- [Vite Getting Started](https://vite.dev/guide/)
|
||||
- [React: Build a React app from Scratch](https://react.dev/learn/build-a-react-app-from-scratch)
|
||||
@@ -282,6 +284,8 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面
|
||||
- [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00)
|
||||
- [Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)
|
||||
- [Node.js `crypto.createHmac()` and `crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html)
|
||||
- [W3C WAI: Labeling Controls](https://www.w3.org/WAI/tutorials/forms/labels/)
|
||||
- [W3C WCAG 2.2: Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced)
|
||||
|
||||
MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。
|
||||
|
||||
|
||||
+5
-5
@@ -21,7 +21,7 @@
|
||||
- [ ] 分享端 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(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档上限;分享期间可直接换档而不重新选择来源或重建健康连接。Peer-assisted 房间只允许这三个档位 ID,并把分享者当前选择同步给现有和后续 relay sender;普通 P2P 信令结构保持不变。当前 `contentHint = "motion"` 与 `balanced` 只是测量基线;目标默认策略为可读性优先,并在高级设置中提供“清晰优先 / 平衡 / 流畅优先”。任何策略都只是浏览器偏好,必须显示实际结果和限制原因。自定义动态 FPS 或画面检测只在统计证明必要后实现。
|
||||
- [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,默认采用清晰优先;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与可选 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,必须读回实际 sender 参数并显示参数拒绝、改写和持续受限原因。自定义动态 FPS、综合评分或周期调档只在统计证明必要后实现。
|
||||
- [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。
|
||||
- [ ] 可观测性:采集候选类型、RTT、丢包、抖动、实际码率、帧率、编码耗时和质量受限原因。
|
||||
- [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。
|
||||
@@ -30,12 +30,12 @@
|
||||
|
||||
### P1 - 重要功能
|
||||
|
||||
- [ ] 质量控制扩展:保留可立即理解的推荐档位作为默认入口,同时提供一个可选的高级设置区,让分享者在安全边界内实时调整分辨率、帧率、视频码率、质量优先级和音频模式而无需重选来源或重建健康连接。实际发送/接收质量持续明显低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一固定参数组合称为“完美平衡”。
|
||||
- [ ] Peer-assisted 媒体实验:分享端最多两个 child、每名 viewer 最多一个 child;信令服务只按深度和服务端加入序号确定性地形成两条 sticky 平衡链,不加入综合评分或主动重平衡。每个 viewer 将收到的标准 remote track 通过新的 `RTCPeerConnection` 转发,因此会逐跳解码和重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前档位,不写入 SQLite;host 鉴权后先重报档位,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。该功能在 ADR-0004 的实验门槛通过并由后续 ADR 正式接受前不得成为产品路径。
|
||||
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。音频 stereo/bitrate 在标准浏览器 API 和实际协商 codec 不能可靠保证前只保留为后续测量项,不提供伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
|
||||
- [ ] Peer-assisted 媒体实验:分享端最多两个 child、每名 viewer 最多一个 child;信令服务只按深度和服务端加入序号确定性地形成两条 sticky 平衡链,不加入综合评分或主动重平衡。每个 viewer 将收到的标准 remote track 通过新的 `RTCPeerConnection` 转发,因此会逐跳解码和重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。该功能在 ADR-0004 的实验门槛通过并由后续 ADR 正式接受前不得成为产品路径。
|
||||
- [ ] 原生 shared encode 分享端:后续独立的 Electron/原生分享端阶段必须保证一份兼容编码输出扇出到最多两套独立 WebRTC packetizer,并由单独 ADR 和测量约束。它降低分享端重复编码负载,但不消除每条出边各自的上传带宽。浏览器不保证跨 `RTCPeerConnection` 复用编码器,因此当前 Web spike 不实现、也不能宣称满足 shared encode 保证。
|
||||
- [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样最多维护两条下游媒体边,并应让一份兼容编码输出供两条边复用。普通浏览器 audience 继续作为 leaf 或使用当前会逐跳重编码的实验路径,不伪称 shared encode。
|
||||
- [ ] 分包/分层多树实验:把编码包、对象或可独立解码层拆到多个 peer 路径以分散上传,只能在独立 spike 中验证。必须保持亚秒级,处理多父重组、排序、丢包恢复、关键帧、同步和节点退出;未通过门槛前不进入产品路由控制器。
|
||||
- [ ] 可选 SFU:部署者可选择是否提供单节点 SFU 资源;配置后由路由控制器把它作为 P2P 与 peer-assisted 之后的自动兜底,而不要求用户选模式。它保持非默认、可逆,并承担每名观看者的服务器出口成本。Draft PR #12 仍在评审,不能视为已合并、已部署或推荐路径。
|
||||
- [ ] 可选 SFU:部署者可选择是否提供单节点 SFU 资源;配置后由 `main` 中 PR #17 的路由控制器把它作为 P2P 与 peer-assisted 之后的自动兜底,而不要求用户选模式。它保持默认关闭、可逆,并承担每名观看者的服务器出口成本。已关闭 PR #12 的显式整房 SFU 模式已被取代,不是当前选项;自动 fallback 仍须通过 ADR-0005 门槛才可部署。
|
||||
- [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。
|
||||
- [ ] Windows 应用音频:仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享。
|
||||
- [ ] 连接自检:在开始分享前验证信令、STUN、TURN/UDP、TURN/TCP 及部署实际配置的可选 TURN/TLS 可达性,并给出不暴露凭据的诊断结果。
|
||||
@@ -98,7 +98,7 @@
|
||||
- Given 一名分享者和一至两名观看者,When 运行直接 P2P,Then 分享端为每人建立独立媒体边且总 fanout 不超过二。
|
||||
- Given peer-assisted spike 分别运行 1、3、5、8 名观看者,When 用户加入、离开或 relay 失效,Then 服务端使用同一个确定性广度优先规则形成两条 sticky 链,只为新 viewer 或失去 parent 的 subtree root 分配 parent,不主动搬动其他节点;分享端在任何瞬间都不超过两个 child 且每名 viewer 不超过一个,没有可用 parent 时明确等待或拒绝,而不是增加分享端 fanout。
|
||||
- Given 一名分享者和八名观看者,When 在受控网络中运行已记录的质量档位,Then 记录每条边的拓扑深度、候选类型、码率、编码/解码负载、分享端与 relay 上行、首帧和玻璃到玻璃延迟;该条在真实矩阵完成前保持未验收。
|
||||
- Given peer-assisted host 鉴权或直播中换档,When 其选择三个受限档位之一,Then 当前和以后建立的 relay child sender 都使用最新目标档位,服务端只保留房间级内存状态并通知在线 viewers;普通 P2P 的鉴权 wire 不新增该字段,且不接受档位同步消息。
|
||||
- Given peer-assisted host 鉴权或直播中连续修改质量设置,When 服务端校验其分辨率、FPS、码率和偏好,Then 只有 host 的完整有界设置被接受,last-wins 地应用到当前和以后建立的 relay child sender 以及启用的 SFU publisher;越界、缺字段和多余字段被拒绝,普通 P2P 的鉴权 wire 不新增该字段且不接受同步消息。
|
||||
- Given 标准浏览器把收到的 remote track 直接加入下游 `RTCPeerConnection`,When 检查 relay 统计和 libwebrtc 路径,Then 明确记录新的 outbound encoder、编码耗时和 CPU/GPU 限制,不把它当作 encoded forwarding 或 shared encode 成果。
|
||||
- Given ADR-0004 的任一硬门槛只有在引入 Encoded Transform/DataChannel/WebCodecs 媒体、自定义拥塞控制、FEC/RTX、多树、综合评分、转码、codec ladder、浏览器 RTP 注入 hack 或放宽 host fanout 后才能满足,When 结束 bounded spike,Then 放弃并删除浏览器 relay 实验运行时代码,保留标准 P2P 和可选 SFU;后续 native shared encode sender 仍按独立 ADR 推进,但不能据此把失败的 relay 拓扑判为通过。
|
||||
- Given 新观看者会使预计总上行超过安全预算,When 其请求加入,Then 系统自动降低档位、拒绝该档位或建议改用外部直播服务,而不是持续堆积发送队列。
|
||||
|
||||
@@ -11,14 +11,16 @@ import {
|
||||
type ScreenerServer,
|
||||
} from "../src/server/app";
|
||||
import { loadConfig } from "../src/server/config";
|
||||
import {
|
||||
QUALITY_PROFILES,
|
||||
QUALITY_RESOLUTIONS,
|
||||
qualitySettingsEqual,
|
||||
type QualityProfileId,
|
||||
type QualitySettings,
|
||||
} from "../src/client/media/quality";
|
||||
|
||||
const PROFILE_SETTINGS = {
|
||||
"1080p60": { width: 1920, height: 1080, frameRate: 60 },
|
||||
"1080p30": { width: 1920, height: 1080, frameRate: 30 },
|
||||
"720p30": { width: 1280, height: 720, frameRate: 30 },
|
||||
} as const;
|
||||
|
||||
type ProfileId = keyof typeof PROFILE_SETTINGS;
|
||||
const PROFILE_SETTINGS = QUALITY_PROFILES;
|
||||
type ProfileId = QualityProfileId;
|
||||
type PageRole = "host" | "viewer";
|
||||
|
||||
export interface BenchmarkConfig {
|
||||
@@ -34,6 +36,7 @@ export interface BenchmarkConfig {
|
||||
outputPath: string | null;
|
||||
headless: boolean;
|
||||
noSandbox: boolean;
|
||||
qualityControlSmoke: boolean;
|
||||
}
|
||||
|
||||
interface MediaTotals {
|
||||
@@ -72,12 +75,13 @@ interface PageObservation {
|
||||
authenticatedAtEpochMs: number | null;
|
||||
authenticateSentAtEpochMs: number | null;
|
||||
signalingConnected: boolean;
|
||||
qualityProfileId: string | null;
|
||||
qualitySettings: QualitySettings | null;
|
||||
assignment: MediaAssignmentObservation;
|
||||
maxActiveOutboundMediaEdges: number;
|
||||
maxAssignedChildren: number;
|
||||
firstDecodedAtEpochMs: number | null;
|
||||
firstRenderedAtEpochMs: number | null;
|
||||
renderedFrames: number;
|
||||
connections: ConnectionObservation[];
|
||||
}
|
||||
|
||||
@@ -343,6 +347,16 @@ export function parseBenchmarkConfig(
|
||||
throw new Error("BENCHMARK_PROFILE must be 1080p60, 1080p30, or 720p30");
|
||||
}
|
||||
const viewerCounts = parseViewerCounts(environment.BENCHMARK_VIEWERS);
|
||||
const qualityControlSmoke = parseBoolean(
|
||||
environment.BENCHMARK_QUALITY_SMOKE,
|
||||
false,
|
||||
"BENCHMARK_QUALITY_SMOKE",
|
||||
);
|
||||
if (qualityControlSmoke && !viewerCounts.some((count) => count >= 3)) {
|
||||
throw new Error(
|
||||
"BENCHMARK_QUALITY_SMOKE requires a selected viewer count of at least 3",
|
||||
);
|
||||
}
|
||||
const recoveryText = environment.BENCHMARK_RECOVERY_VIEWERS?.trim();
|
||||
const recoveryViewerCount = recoveryText ? Number(recoveryText) : null;
|
||||
if (
|
||||
@@ -410,6 +424,7 @@ export function parseBenchmarkConfig(
|
||||
false,
|
||||
"BENCHMARK_CHROME_NO_SANDBOX",
|
||||
),
|
||||
qualityControlSmoke,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,7 +566,7 @@ export function summarizeSamples(samples: TimedSample[], viewerCount: number) {
|
||||
role: page.role,
|
||||
viewerIndex: page.viewerIndex,
|
||||
peerId: page.peerId,
|
||||
qualityProfileId: page.qualityProfileId,
|
||||
qualitySettings: page.qualitySettings,
|
||||
parentPeerId: page.assignment.parentPeerId,
|
||||
childPeerIds: page.assignment.childPeerIds,
|
||||
})),
|
||||
@@ -617,14 +632,18 @@ function buildRunChecks(
|
||||
expected: "<= 3000 ms after signaling authentication starts",
|
||||
},
|
||||
{
|
||||
name: "quality-profile-propagated",
|
||||
name: "quality-settings-propagated",
|
||||
passed: summary.finalTopology.every(
|
||||
(participant) => participant.qualityProfileId === profileId,
|
||||
(participant) =>
|
||||
JSON.stringify(participant.qualitySettings) ===
|
||||
JSON.stringify(PROFILE_SETTINGS[profileId]),
|
||||
),
|
||||
actual: summary.finalTopology.every(
|
||||
(participant) => participant.qualityProfileId === profileId,
|
||||
(participant) =>
|
||||
JSON.stringify(participant.qualitySettings) ===
|
||||
JSON.stringify(PROFILE_SETTINGS[profileId]),
|
||||
),
|
||||
expected: `${profileId} on every participant`,
|
||||
expected: `${profileId} quality settings on every participant`,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -654,12 +673,13 @@ export function buildBenchmarkInitScript(options: {
|
||||
authenticatedAtEpochMs: null,
|
||||
authenticateSentAtEpochMs: null,
|
||||
signalingConnected: false,
|
||||
qualityProfileId: null,
|
||||
qualitySettings: null,
|
||||
assignment: { parentPeerId: null, childPeerIds: [] },
|
||||
maxActiveOutboundMediaEdges: 0,
|
||||
maxAssignedChildren: 0,
|
||||
firstDecodedAtEpochMs: null,
|
||||
firstRenderedAtEpochMs: null,
|
||||
renderedFrames: 0,
|
||||
};
|
||||
const connections = [];
|
||||
const descriptions = [];
|
||||
@@ -710,8 +730,8 @@ export function buildBenchmarkInitScript(options: {
|
||||
state.role = message.role;
|
||||
state.roomId = message.roomId;
|
||||
state.authenticateSentAtEpochMs = Date.now();
|
||||
} else if (direction === "out" && message.type === "set-quality-profile") {
|
||||
state.qualityProfileId = message.qualityProfileId;
|
||||
} else if (direction === "out" && message.type === "set-quality-settings") {
|
||||
state.qualitySettings = message.qualitySettings;
|
||||
}
|
||||
if (direction === "in" && message.type === "authenticated") {
|
||||
socket.__screenerBenchmarkSignal = true;
|
||||
@@ -720,12 +740,12 @@ export function buildBenchmarkInitScript(options: {
|
||||
state.signalingConnected = true;
|
||||
if (message.mediaMode === "peer-assisted") {
|
||||
setAssignment(message.mediaAssignment);
|
||||
state.qualityProfileId = message.qualityProfileId;
|
||||
state.qualitySettings = message.qualitySettings;
|
||||
}
|
||||
} else if (direction === "in" && message.type === "media-assignment") {
|
||||
setAssignment(message.mediaAssignment);
|
||||
} else if (direction === "in" && message.type === "quality-profile") {
|
||||
state.qualityProfileId = message.qualityProfileId;
|
||||
} else if (direction === "in" && message.type === "quality-settings") {
|
||||
state.qualitySettings = message.qualitySettings;
|
||||
}
|
||||
if (message.type === "signal") recordDescription(message, direction);
|
||||
}
|
||||
@@ -842,6 +862,7 @@ export function buildBenchmarkInitScript(options: {
|
||||
const onFrame = (now) => {
|
||||
const at = performance.timeOrigin + now;
|
||||
if (state.firstRenderedAtEpochMs === null) state.firstRenderedAtEpochMs = at;
|
||||
state.renderedFrames += 1;
|
||||
video.requestVideoFrameCallback(onFrame);
|
||||
};
|
||||
video.requestVideoFrameCallback(onFrame);
|
||||
@@ -1228,7 +1249,7 @@ async function startHost(
|
||||
await waitForPage(
|
||||
cdp,
|
||||
page,
|
||||
"document.querySelector('.start-button')",
|
||||
"document.querySelector('.entry-actions button.entry-action')",
|
||||
15_000,
|
||||
"host controls",
|
||||
signal,
|
||||
@@ -1237,11 +1258,11 @@ async function startHost(
|
||||
cdp,
|
||||
page,
|
||||
`(() => {
|
||||
const profiles = document.querySelectorAll('.segmented-control button');
|
||||
const profiles = document.querySelectorAll('.quality-controls .segmented-control button');
|
||||
const profile = profiles[${profileIndex}];
|
||||
if (!(profile instanceof HTMLButtonElement)) throw new Error('Quality profile button missing');
|
||||
profile.click();
|
||||
const start = document.querySelector('.start-button');
|
||||
const start = document.querySelector('.entry-actions button.entry-action');
|
||||
if (!(start instanceof HTMLButtonElement)) throw new Error('Start button missing');
|
||||
start.click();
|
||||
return true;
|
||||
@@ -1302,6 +1323,219 @@ async function samplePages(
|
||||
};
|
||||
}
|
||||
|
||||
function peerConnectionFingerprint(pages: PageObservation[]): string {
|
||||
return pages
|
||||
.map((page) =>
|
||||
[
|
||||
page.label,
|
||||
...page.connections.map(
|
||||
(connection) =>
|
||||
`${connection.index}:${connection.connectionId ?? "?"}`,
|
||||
),
|
||||
].join("|"),
|
||||
)
|
||||
.sort()
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function applyQualityPreference(
|
||||
cdp: CdpConnection,
|
||||
hostPage: PageHandle,
|
||||
label: "平衡" | "清晰优先",
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const serializedLabel = JSON.stringify(label);
|
||||
await evaluate(
|
||||
cdp,
|
||||
hostPage,
|
||||
`(() => {
|
||||
const details = document.querySelector('.advanced-quality');
|
||||
if (!(details instanceof HTMLDetailsElement)) return false;
|
||||
details.open = true;
|
||||
const button = Array.from(document.querySelectorAll('.quality-priority button'))
|
||||
.find((item) => item.textContent?.trim() === ${serializedLabel});
|
||||
if (!(button instanceof HTMLButtonElement)) return false;
|
||||
button.click();
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
await waitForPage(
|
||||
cdp,
|
||||
hostPage,
|
||||
`Array.from(document.querySelectorAll('.quality-priority button.is-selected')).some((item) => item.textContent?.trim() === ${serializedLabel})`,
|
||||
5_000,
|
||||
`${label} advanced quality selection`,
|
||||
signal,
|
||||
);
|
||||
await evaluate(
|
||||
cdp,
|
||||
hostPage,
|
||||
`(() => {
|
||||
const button = Array.from(document.querySelectorAll('.advanced-quality-grid > button'))
|
||||
.find((item) => item.textContent?.includes('应用视频设置'));
|
||||
if (!(button instanceof HTMLButtonElement)) return false;
|
||||
button.click();
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runQualityControlSmoke(
|
||||
cdp: CdpConnection,
|
||||
pages: PageHandle[],
|
||||
initialSettings: QualitySettings,
|
||||
signal: AbortSignal,
|
||||
): Promise<{
|
||||
settingsPropagated: boolean;
|
||||
peerConnectionsStable: boolean;
|
||||
senderReadbacksMatched: boolean;
|
||||
viewersAdvanced: boolean;
|
||||
}> {
|
||||
const hostPage = pages[0];
|
||||
if (!hostPage) {
|
||||
throw new Error("Quality control smoke requires a host page");
|
||||
}
|
||||
const baseline = await Promise.all(pages.map((page) => quickSnapshot(cdp, page)));
|
||||
const baselineFingerprint = peerConnectionFingerprint(baseline);
|
||||
const balanced = {
|
||||
...initialSettings,
|
||||
degradationPreference: "balanced",
|
||||
} as const satisfies QualitySettings;
|
||||
|
||||
const waitForSettings = async (settings: QualitySettings): Promise<boolean> => {
|
||||
const serializedSettings = JSON.stringify(JSON.stringify(settings));
|
||||
await Promise.all(
|
||||
pages.map((page) =>
|
||||
waitForPage(
|
||||
cdp,
|
||||
page,
|
||||
`JSON.stringify(globalThis.__SCREENER_BENCHMARK__.snapshot().qualitySettings) === ${serializedSettings}`,
|
||||
10_000,
|
||||
`${settings.degradationPreference} quality propagation`,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
const snapshots = await Promise.all(
|
||||
pages.map((page) => quickSnapshot(cdp, page)),
|
||||
);
|
||||
return snapshots.every(
|
||||
(page) =>
|
||||
page.qualitySettings !== null &&
|
||||
qualitySettingsEqual(page.qualitySettings, settings),
|
||||
);
|
||||
};
|
||||
const activeSenderCounts = new Map(
|
||||
baseline.map((page) => [page.label, activeVideoEdgeCount(page, "send")]),
|
||||
);
|
||||
const sendingPages = pages.filter(
|
||||
(page) => (activeSenderCounts.get(page.label) ?? 0) > 0,
|
||||
);
|
||||
await Promise.all(
|
||||
sendingPages.map((page) =>
|
||||
evaluate(
|
||||
cdp,
|
||||
page,
|
||||
`(() => {
|
||||
const toggle = document.querySelector('.connection-details-toggle input');
|
||||
if (!(toggle instanceof HTMLInputElement)) return false;
|
||||
if (!toggle.checked) toggle.click();
|
||||
return true;
|
||||
})()`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const waitForReadback = async (
|
||||
label: "平衡" | "清晰",
|
||||
): Promise<boolean> => {
|
||||
const matched = await Promise.all(
|
||||
sendingPages.map(async (page) => {
|
||||
const expectedCount = activeSenderCounts.get(page.label) ?? 0;
|
||||
const expected = JSON.stringify(`${label} / ${label}`);
|
||||
const predicate = `(() => {
|
||||
const values = Array.from(document.querySelectorAll('.metric'))
|
||||
.filter((metric) => metric.querySelector('dt')?.textContent?.trim() === '请求 / 应用优先级')
|
||||
.map((metric) => metric.querySelector('dd')?.textContent?.trim());
|
||||
return values.length === ${expectedCount} && values.every((value) => value === ${expected});
|
||||
})()`;
|
||||
await waitForPage(
|
||||
cdp,
|
||||
page,
|
||||
predicate,
|
||||
10_000,
|
||||
`${label} sender parameter readback`,
|
||||
signal,
|
||||
);
|
||||
return evaluate<boolean>(cdp, page, `Boolean(${predicate})`);
|
||||
}),
|
||||
);
|
||||
return (
|
||||
sendingPages.length > 0 &&
|
||||
matched.length === sendingPages.length &&
|
||||
matched.every(Boolean)
|
||||
);
|
||||
};
|
||||
const viewerPages = pages.slice(1);
|
||||
const waitForViewerProgress = async (): Promise<boolean> => {
|
||||
for (const page of viewerPages) {
|
||||
await cdp.call("Page.bringToFront", {}, page.sessionId);
|
||||
const before = [await progressSample(cdp, page)];
|
||||
const deadline = Date.now() + 10_000;
|
||||
let advanced = false;
|
||||
while (Date.now() < deadline) {
|
||||
const after = [await progressSample(cdp, page)];
|
||||
if (everyViewerAdvanced(before, after)) {
|
||||
advanced = true;
|
||||
break;
|
||||
}
|
||||
await delay(100, signal);
|
||||
}
|
||||
if (!advanced) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return viewerPages.length > 0;
|
||||
};
|
||||
|
||||
const runStep = async (
|
||||
label: "平衡" | "清晰优先",
|
||||
readbackLabel: "平衡" | "清晰",
|
||||
settings: QualitySettings,
|
||||
): Promise<{
|
||||
settingsPropagated: boolean;
|
||||
senderReadbacksMatched: boolean;
|
||||
viewersAdvanced: boolean;
|
||||
}> => {
|
||||
await cdp.call("Page.bringToFront", {}, hostPage.sessionId);
|
||||
await applyQualityPreference(cdp, hostPage, label, signal);
|
||||
const settingsPropagated = await waitForSettings(settings);
|
||||
const senderReadbacksMatched = await waitForReadback(readbackLabel);
|
||||
const viewersAdvanced = await waitForViewerProgress();
|
||||
return {
|
||||
settingsPropagated,
|
||||
senderReadbacksMatched,
|
||||
viewersAdvanced,
|
||||
};
|
||||
};
|
||||
|
||||
const balancedResult = await runStep("平衡", "平衡", balanced);
|
||||
const clarityResult = await runStep("清晰优先", "清晰", initialSettings);
|
||||
|
||||
const final = await Promise.all(pages.map((page) => quickSnapshot(cdp, page)));
|
||||
|
||||
return {
|
||||
settingsPropagated:
|
||||
balancedResult.settingsPropagated && clarityResult.settingsPropagated,
|
||||
peerConnectionsStable:
|
||||
peerConnectionFingerprint(final) === baselineFingerprint,
|
||||
senderReadbacksMatched:
|
||||
balancedResult.senderReadbacksMatched &&
|
||||
clarityResult.senderReadbacksMatched,
|
||||
viewersAdvanced:
|
||||
balancedResult.viewersAdvanced && clarityResult.viewersAdvanced,
|
||||
};
|
||||
}
|
||||
|
||||
function totalDecodedFrames(page: PageObservation): number {
|
||||
return page.connections.reduce(
|
||||
(total, connection) => total + (connection.receiveTotals?.framesTotal ?? 0),
|
||||
@@ -1309,6 +1543,25 @@ function totalDecodedFrames(page: PageObservation): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function everyViewerAdvanced(
|
||||
before: PageObservation[],
|
||||
after: PageObservation[],
|
||||
): boolean {
|
||||
const baselineViewers = before.filter((page) => page.role === "viewer");
|
||||
const currentByLabel = new Map(after.map((page) => [page.label, page]));
|
||||
return (
|
||||
baselineViewers.length > 0 &&
|
||||
baselineViewers.every((baseline) => {
|
||||
const current = currentByLabel.get(baseline.label);
|
||||
return (
|
||||
current !== undefined &&
|
||||
totalDecodedFrames(current) > totalDecodedFrames(baseline) &&
|
||||
current.renderedFrames > baseline.renderedFrames
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function descendantsOf(
|
||||
rootPeerId: string,
|
||||
pages: PageObservation[],
|
||||
@@ -1450,10 +1703,11 @@ async function runCase(
|
||||
let recovery: RecoveryResult = { triggered: false };
|
||||
try {
|
||||
const capture = PROFILE_SETTINGS[config.profileId];
|
||||
const captureResolution = QUALITY_RESOLUTIONS[capture.resolution];
|
||||
const commonInit = {
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
frameRate: capture.frameRate,
|
||||
width: captureResolution.width,
|
||||
height: captureResolution.height,
|
||||
frameRate: capture.maxFramerate,
|
||||
};
|
||||
const hostPage = await createPage(cdp, baseUrl, {
|
||||
...commonInit,
|
||||
@@ -1505,6 +1759,38 @@ async function runCase(
|
||||
|
||||
const summary = summarizeSamples(samples, viewerCount);
|
||||
const checks = buildRunChecks(summary, viewerCount, config.profileId);
|
||||
if (config.qualityControlSmoke && viewerCount >= 3) {
|
||||
const qualityControl = await runQualityControlSmoke(
|
||||
cdp,
|
||||
pages,
|
||||
capture,
|
||||
signal,
|
||||
);
|
||||
checks.push({
|
||||
name: "quality-control-propagation",
|
||||
passed: qualityControl.settingsPropagated,
|
||||
actual: qualityControl.settingsPropagated,
|
||||
expected: "balanced and clarity settings reach every participant",
|
||||
});
|
||||
checks.push({
|
||||
name: "quality-control-no-peer-rebuild",
|
||||
passed: qualityControl.peerConnectionsStable,
|
||||
actual: qualityControl.peerConnectionsStable,
|
||||
expected: "peer connection identities stay unchanged",
|
||||
});
|
||||
checks.push({
|
||||
name: "quality-control-sender-readback",
|
||||
passed: qualityControl.senderReadbacksMatched,
|
||||
actual: qualityControl.senderReadbacksMatched,
|
||||
expected: "every baseline active video sender displays matching preference readback",
|
||||
});
|
||||
checks.push({
|
||||
name: "quality-control-viewer-progress",
|
||||
passed: qualityControl.viewersAdvanced,
|
||||
actual: qualityControl.viewersAdvanced,
|
||||
expected: "every viewer decodes and renders frames after both setting changes",
|
||||
});
|
||||
}
|
||||
if (config.recoveryViewerCount === viewerCount) {
|
||||
recovery = await runRecovery(
|
||||
cdp,
|
||||
@@ -1589,6 +1875,7 @@ export async function main(): Promise<number> {
|
||||
}
|
||||
|
||||
const profile = PROFILE_SETTINGS[config.profileId];
|
||||
const profileResolution = QUALITY_RESOLUTIONS[profile.resolution];
|
||||
const report: BenchmarkReport = {
|
||||
schemaVersion: 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -1612,14 +1899,15 @@ export async function main(): Promise<number> {
|
||||
recoveryTimeoutMs: config.recoveryTimeoutMs,
|
||||
headless: config.headless,
|
||||
noSandbox: config.noSandbox,
|
||||
qualityControlSmoke: config.qualityControlSmoke,
|
||||
chromeExecutable: basename(config.chromePath),
|
||||
output: config.outputPath ?? "stdout",
|
||||
},
|
||||
capture: {
|
||||
kind: "deterministic-canvas",
|
||||
width: profile.width,
|
||||
height: profile.height,
|
||||
frameRate: profile.frameRate,
|
||||
width: profileResolution.width,
|
||||
height: profileResolution.height,
|
||||
frameRate: profile.maxFramerate,
|
||||
audio: false,
|
||||
},
|
||||
limitations: [
|
||||
|
||||
@@ -10,7 +10,7 @@ export function AppHeader({ status }: { status: ReactNode }) {
|
||||
</span>
|
||||
<span>Screener</span>
|
||||
</a>
|
||||
<div className="header-status">{status}</div>
|
||||
{status && <div className="header-status">{status}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
interface ConnectionDetailsToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConnectionDetailsToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: ConnectionDetailsToggleProps) {
|
||||
return (
|
||||
<label
|
||||
className="connection-details-toggle"
|
||||
title="展开或隐藏连接的技术指标"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<span>显示连接详情</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionMetrics } from "../types";
|
||||
import type { VideoSenderParameterReadback } from "../media/quality";
|
||||
|
||||
function readableNumber(value: number | null, digits = 0): string {
|
||||
return value === null || !Number.isFinite(value) ? "未知" : value.toFixed(digits);
|
||||
@@ -14,6 +15,25 @@ function qualityReason(value: string | null): string {
|
||||
return value ? (labels[value] ?? value) : "未知";
|
||||
}
|
||||
|
||||
function requestedApplied(
|
||||
requested: number | string | null,
|
||||
applied: number | string | null,
|
||||
format: (value: number | string) => string,
|
||||
): string {
|
||||
const requestedText = requested === null ? "?" : format(requested);
|
||||
const appliedText = applied === null ? "未读回" : format(applied);
|
||||
return `${requestedText} / ${appliedText}`;
|
||||
}
|
||||
|
||||
function preferenceLabel(value: number | string): string {
|
||||
const labels: Record<string, string> = {
|
||||
"maintain-resolution": "清晰",
|
||||
balanced: "平衡",
|
||||
"maintain-framerate": "流畅",
|
||||
};
|
||||
return labels[String(value)] ?? String(value);
|
||||
}
|
||||
|
||||
function Metric({ label, value, title }: { label: string; value: string; title?: string }) {
|
||||
return (
|
||||
<div className="metric" title={title}>
|
||||
@@ -26,9 +46,11 @@ function Metric({ label, value, title }: { label: string; value: string; title?:
|
||||
export function StatsGrid({
|
||||
metrics,
|
||||
direction,
|
||||
senderParameters,
|
||||
}: {
|
||||
metrics: ConnectionMetrics;
|
||||
direction: "send" | "receive";
|
||||
senderParameters?: VideoSenderParameterReadback | null;
|
||||
}) {
|
||||
const encoder = metrics.encoderImplementation
|
||||
? `${metrics.encoderImplementation}${
|
||||
@@ -82,6 +104,42 @@ export function StatsGrid({
|
||||
value={`${readableNumber(metrics.intervalEncodeMs, 1)} ms`}
|
||||
/>
|
||||
<Metric label="质量状态" value={qualityReason(metrics.qualityLimitationReason)} />
|
||||
{senderParameters && (
|
||||
<>
|
||||
<Metric
|
||||
label="请求 / 应用码率"
|
||||
value={requestedApplied(
|
||||
senderParameters.requested.maxBitrate,
|
||||
senderParameters.applied.maxBitrate,
|
||||
(value) => `${(Number(value) / 1_000_000).toFixed(1)} Mbps`,
|
||||
)}
|
||||
/>
|
||||
<Metric
|
||||
label="请求 / 应用帧率"
|
||||
value={requestedApplied(
|
||||
senderParameters.requested.maxFramerate,
|
||||
senderParameters.applied.maxFramerate,
|
||||
(value) => `${Number(value).toFixed(0)} fps`,
|
||||
)}
|
||||
/>
|
||||
<Metric
|
||||
label="请求 / 应用缩放"
|
||||
value={requestedApplied(
|
||||
senderParameters.requested.scaleResolutionDownBy,
|
||||
senderParameters.applied.scaleResolutionDownBy,
|
||||
(value) => `${Number(value).toFixed(2)}x`,
|
||||
)}
|
||||
/>
|
||||
<Metric
|
||||
label="请求 / 应用优先级"
|
||||
value={requestedApplied(
|
||||
senderParameters.requested.degradationPreference,
|
||||
senderParameters.applied.degradationPreference,
|
||||
preferenceLabel,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PeerStatusBadge({
|
||||
state: RTCPeerConnectionState | "waiting";
|
||||
}) {
|
||||
const labels: Record<RTCPeerConnectionState | "waiting", BadgeProps> = {
|
||||
waiting: { tone: "neutral", label: "等待画面" },
|
||||
waiting: { tone: "neutral", label: "等待开始分享" },
|
||||
new: { tone: "neutral", label: "准备中" },
|
||||
connecting: { tone: "neutral", label: "正在连接" },
|
||||
connected: { tone: "good", label: "已连接" },
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PeerSnapshot } from "../types";
|
||||
|
||||
export function qualityLimitationSummary(
|
||||
snapshots: readonly PeerSnapshot[],
|
||||
): string | null {
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.qualityWarning) {
|
||||
return snapshot.qualityWarning;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { SfuPublisher, type SfuConnectionConfig } from "../sfu/publisher";
|
||||
import type { QualityProfile } from "./quality";
|
||||
import type {
|
||||
QualityProfile,
|
||||
VideoSenderParameterReadback,
|
||||
} from "./quality";
|
||||
import {
|
||||
MediaRouteTransition,
|
||||
type RouteOperationToken,
|
||||
@@ -19,6 +22,8 @@ interface HostPublisherTransport {
|
||||
deactivate(): Promise<boolean>;
|
||||
replaceStream(stream: MediaStream): Promise<boolean>;
|
||||
updateProfile(profile: QualityProfile): Promise<boolean>;
|
||||
getQualityWarning?(): string | null;
|
||||
getSenderParameters?(): VideoSenderParameterReadback | null;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -86,11 +91,21 @@ export class HostSfuRoute {
|
||||
return result;
|
||||
}
|
||||
|
||||
async acceptAndWait(
|
||||
update: RouteUpdateInput,
|
||||
acknowledge = true,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, acknowledge);
|
||||
await this.transitionTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
async resyncAuthoritative(
|
||||
update: RouteUpdateInput,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, false);
|
||||
if (result !== "stale" || this.closed) {
|
||||
await this.transitionTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -217,6 +232,18 @@ export class HostSfuRoute {
|
||||
: Promise.resolve(true);
|
||||
}
|
||||
|
||||
getQualityWarning(): string | null {
|
||||
return this.active?.active
|
||||
? (this.active.publisher.getQualityWarning?.() ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
getSenderParameters(): VideoSenderParameterReadback | null {
|
||||
return this.active?.active
|
||||
? (this.active.publisher.getSenderParameters?.() ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
replaceStream(stream: MediaStream): Promise<boolean> {
|
||||
return this.active
|
||||
? this.active.publisher.replaceStream(stream).catch(() => false)
|
||||
|
||||
+167
-33
@@ -1,47 +1,110 @@
|
||||
import type { QualityProfileId } from "../../shared/protocol";
|
||||
import {
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
type DegradationPreference,
|
||||
type QualityProfileId,
|
||||
type QualityResolution,
|
||||
type QualitySettings,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export type { QualityProfileId } from "../../shared/protocol";
|
||||
export type {
|
||||
DegradationPreference,
|
||||
QualityProfileId,
|
||||
QualityResolution,
|
||||
QualitySettings,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export type QualityProfile = QualitySettings;
|
||||
|
||||
export const QUALITY_PROFILES = {
|
||||
"1080p60": {
|
||||
label: "1080p 60",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
},
|
||||
"1080p60": DEFAULT_QUALITY_SETTINGS,
|
||||
"1080p30": {
|
||||
label: "1080p 30",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 30,
|
||||
resolution: "1080p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
},
|
||||
"720p30": {
|
||||
label: "720p 30",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
},
|
||||
} as const satisfies Record<QualityProfileId, QualitySettings>;
|
||||
|
||||
export const QUALITY_PROFILE_LABELS = {
|
||||
"1080p60": "1080p 60",
|
||||
"1080p30": "1080p 30",
|
||||
"720p30": "720p 30",
|
||||
} as const satisfies Record<QualityProfileId, string>;
|
||||
|
||||
export const QUALITY_RESOLUTIONS = {
|
||||
"720p": { width: 1280, height: 720, label: "720p" },
|
||||
"1080p": { width: 1920, height: 1080, label: "1080p" },
|
||||
"1440p": { width: 2560, height: 1440, label: "1440p" },
|
||||
} as const satisfies Record<
|
||||
QualityProfileId,
|
||||
{
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
maxBitrate: number;
|
||||
}
|
||||
QualityResolution,
|
||||
{ width: number; height: number; label: string }
|
||||
>;
|
||||
|
||||
export type QualityProfile = (typeof QUALITY_PROFILES)[QualityProfileId];
|
||||
export const DEGRADATION_PREFERENCE_LABELS = {
|
||||
"maintain-resolution": "清晰优先",
|
||||
balanced: "平衡",
|
||||
"maintain-framerate": "流畅优先",
|
||||
} as const satisfies Record<DegradationPreference, string>;
|
||||
|
||||
export interface VideoSenderParameterValues {
|
||||
maxBitrate: number | null;
|
||||
maxFramerate: number | null;
|
||||
scaleResolutionDownBy: number | null;
|
||||
degradationPreference: RTCDegradationPreference | null;
|
||||
}
|
||||
|
||||
export interface VideoSenderParameterReadback {
|
||||
requested: VideoSenderParameterValues;
|
||||
applied: VideoSenderParameterValues;
|
||||
mismatches: Array<keyof VideoSenderParameterValues>;
|
||||
}
|
||||
|
||||
export function qualitySettingsEqual(
|
||||
left: QualitySettings,
|
||||
right: QualitySettings,
|
||||
): boolean {
|
||||
return (
|
||||
left.resolution === right.resolution &&
|
||||
left.maxFramerate === right.maxFramerate &&
|
||||
left.maxBitrate === right.maxBitrate &&
|
||||
left.degradationPreference === right.degradationPreference
|
||||
);
|
||||
}
|
||||
|
||||
export function matchingQualityProfileId(
|
||||
settings: QualitySettings,
|
||||
): QualityProfileId | null {
|
||||
for (const id of Object.keys(QUALITY_PROFILES) as QualityProfileId[]) {
|
||||
if (qualitySettingsEqual(settings, QUALITY_PROFILES[id])) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function qualitySettingsLabel(settings: QualitySettings): string {
|
||||
const profileId = matchingQualityProfileId(settings);
|
||||
if (profileId) {
|
||||
return QUALITY_PROFILE_LABELS[profileId];
|
||||
}
|
||||
return `${QUALITY_RESOLUTIONS[settings.resolution].label} ${settings.maxFramerate} · ${(settings.maxBitrate / 1_000_000).toFixed(1)} Mbps · ${DEGRADATION_PREFERENCE_LABELS[settings.degradationPreference]}`;
|
||||
}
|
||||
|
||||
function captureConstraints(profile: QualityProfile): MediaTrackConstraints {
|
||||
const resolution = QUALITY_RESOLUTIONS[profile.resolution];
|
||||
return {
|
||||
width: { ideal: profile.width, max: profile.width },
|
||||
height: { ideal: profile.height, max: profile.height },
|
||||
frameRate: { ideal: profile.frameRate, max: profile.frameRate },
|
||||
width: { ideal: resolution.width, max: resolution.width },
|
||||
height: { ideal: resolution.height, max: resolution.height },
|
||||
frameRate: {
|
||||
ideal: profile.maxFramerate,
|
||||
max: profile.maxFramerate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,16 +149,87 @@ export function setVideoPaused(stream: MediaStream, paused: boolean): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function requestedScaleResolutionDownBy(
|
||||
sender: RTCRtpSender,
|
||||
profile: QualityProfile,
|
||||
): number {
|
||||
const source =
|
||||
sender.track && typeof sender.track.getSettings === "function"
|
||||
? sender.track.getSettings()
|
||||
: undefined;
|
||||
const ceiling = QUALITY_RESOLUTIONS[profile.resolution];
|
||||
if (!source?.width || !source.height) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(
|
||||
1,
|
||||
source.width / ceiling.width,
|
||||
source.height / ceiling.height,
|
||||
);
|
||||
}
|
||||
|
||||
function readVideoSenderParameters(
|
||||
parameters: RTCRtpSendParameters,
|
||||
): VideoSenderParameterValues {
|
||||
const encoding = parameters.encodings[0];
|
||||
return {
|
||||
maxBitrate: encoding?.maxBitrate ?? null,
|
||||
maxFramerate: encoding?.maxFramerate ?? null,
|
||||
scaleResolutionDownBy: encoding?.scaleResolutionDownBy ?? null,
|
||||
degradationPreference: parameters.degradationPreference ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function sameParameter(
|
||||
key: keyof VideoSenderParameterValues,
|
||||
requested: number | RTCDegradationPreference | null,
|
||||
applied: number | RTCDegradationPreference | null,
|
||||
): boolean {
|
||||
if (typeof requested === "number" && typeof applied === "number") {
|
||||
return key === "scaleResolutionDownBy"
|
||||
? Math.abs(requested - applied) < 0.01
|
||||
: requested === applied;
|
||||
}
|
||||
return requested === applied;
|
||||
}
|
||||
|
||||
export async function configureVideoSender(
|
||||
sender: RTCRtpSender,
|
||||
profile: QualityProfile,
|
||||
): Promise<void> {
|
||||
): Promise<VideoSenderParameterReadback> {
|
||||
const parameters = sender.getParameters();
|
||||
if (parameters.encodings.length === 0) {
|
||||
parameters.encodings = [{}];
|
||||
}
|
||||
parameters.encodings[0].maxBitrate = profile.maxBitrate;
|
||||
parameters.encodings[0].maxFramerate = profile.frameRate;
|
||||
parameters.degradationPreference = "balanced";
|
||||
parameters.encodings[0]!.maxBitrate = profile.maxBitrate;
|
||||
parameters.encodings[0]!.maxFramerate = profile.maxFramerate;
|
||||
parameters.encodings[0]!.scaleResolutionDownBy =
|
||||
requestedScaleResolutionDownBy(sender, profile);
|
||||
parameters.degradationPreference = profile.degradationPreference;
|
||||
|
||||
const requested = readVideoSenderParameters(parameters);
|
||||
await sender.setParameters(parameters);
|
||||
const applied = readVideoSenderParameters(sender.getParameters());
|
||||
const mismatches = (
|
||||
Object.keys(requested) as Array<keyof VideoSenderParameterValues>
|
||||
).filter((key) => !sameParameter(key, requested[key], applied[key]));
|
||||
|
||||
return { requested, applied, mismatches };
|
||||
}
|
||||
|
||||
const PARAMETER_LABELS = {
|
||||
maxBitrate: "码率上限",
|
||||
maxFramerate: "帧率上限",
|
||||
scaleResolutionDownBy: "分辨率缩放",
|
||||
degradationPreference: "质量优先级",
|
||||
} as const satisfies Record<keyof VideoSenderParameterValues, string>;
|
||||
|
||||
export function senderParameterWarning(
|
||||
readback: VideoSenderParameterReadback,
|
||||
): string | null {
|
||||
return readback.mismatches.length > 0
|
||||
? `浏览器未完整接受${readback.mismatches
|
||||
.map((key) => PARAMETER_LABELS[key])
|
||||
.join("、")}`
|
||||
: null;
|
||||
}
|
||||
+319
-89
@@ -11,12 +11,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
type CreateRoomResponse,
|
||||
type IceConfig,
|
||||
type ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { AppHeader } from "../components/AppHeader";
|
||||
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
|
||||
import { qualityLimitationSummary } from "../components/connection-details";
|
||||
import {
|
||||
PathBadge,
|
||||
PeerStatusBadge,
|
||||
@@ -37,9 +39,16 @@ import { SignalingClient } from "../lib/signaling";
|
||||
import {
|
||||
applyCaptureProfile,
|
||||
captureDisplay,
|
||||
DEGRADATION_PREFERENCE_LABELS,
|
||||
matchingQualityProfileId,
|
||||
QUALITY_PROFILES,
|
||||
QUALITY_PROFILE_LABELS,
|
||||
QUALITY_RESOLUTIONS,
|
||||
qualitySettingsLabel,
|
||||
setVideoPaused,
|
||||
type DegradationPreference,
|
||||
type QualityProfileId,
|
||||
type QualitySettings,
|
||||
} from "../media/quality";
|
||||
import { HostSfuRoute } from "../media/host-sfu-route";
|
||||
import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer";
|
||||
@@ -52,6 +61,7 @@ import {
|
||||
limitMediaAssignment,
|
||||
MAX_HOST_MEDIA_CHILDREN,
|
||||
} from "../webrtc/media-assignment";
|
||||
import { sourceSwitchNotice } from "./host-page-notices";
|
||||
|
||||
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
|
||||
|
||||
@@ -114,8 +124,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
() => new URLSearchParams(window.location.search).get("relay") === "1",
|
||||
[],
|
||||
);
|
||||
const [qualityId, setQualityId] = useState<QualityProfileId>(
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
const [qualitySettings, setQualitySettings] = useState<QualitySettings>(
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
);
|
||||
const [advancedQuality, setAdvancedQuality] = useState<QualitySettings>(
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
);
|
||||
const shareGenerationRef = useRef<string | null>(null);
|
||||
const [phase, setPhase] = useState<HostPhase>("idle");
|
||||
@@ -134,6 +147,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const [switchingSource, setSwitchingSource] = useState(false);
|
||||
const [changingQuality, setChangingQuality] = useState(false);
|
||||
const [picturePaused, setPicturePaused] = useState(false);
|
||||
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
@@ -145,7 +159,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const activeGenerationRef = useRef<number | null>(null);
|
||||
const sourceSwitchRef = useRef<object | null>(null);
|
||||
const qualityChangeRef = useRef<object | null>(null);
|
||||
const qualityIdRef = useRef<QualityProfileId>(DEFAULT_QUALITY_PROFILE_ID);
|
||||
const qualitySettingsRef = useRef<QualitySettings>(DEFAULT_QUALITY_SETTINGS);
|
||||
const picturePausedRef = useRef(false);
|
||||
const retiringStreamRef = useRef<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
@@ -155,6 +169,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
() => Array.from(peerSnapshots.values()),
|
||||
[peerSnapshots],
|
||||
);
|
||||
const selectedQualityProfileId = useMemo(
|
||||
() => matchingQualityProfileId(qualitySettings),
|
||||
[qualitySettings],
|
||||
);
|
||||
const qualityLimitation = useMemo(
|
||||
() => qualityLimitationSummary(viewers),
|
||||
[viewers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
@@ -197,7 +219,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
let route: HostSfuRoute;
|
||||
route = new HostSfuRoute({
|
||||
getStream: () => streamRef.current,
|
||||
getProfile: () => QUALITY_PROFILES[qualityIdRef.current],
|
||||
getProfile: () => qualitySettingsRef.current,
|
||||
reconcileChildren: (childPeerIds) => {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
@@ -231,6 +253,21 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
sfuStandbyPrewarmerRef.current.setUrl(url);
|
||||
}
|
||||
|
||||
function showHostSfuQualityWarning(
|
||||
route: HostSfuRoute,
|
||||
generation: number,
|
||||
): void {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
hostSfuRouteRef.current === route
|
||||
) {
|
||||
const warning = route.getQualityWarning();
|
||||
if (warning) {
|
||||
setNotice(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disposeResources(notifyServer: boolean): void {
|
||||
sourceSwitchRef.current = null;
|
||||
qualityChangeRef.current = null;
|
||||
@@ -323,14 +360,15 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setSwitchingSource(false);
|
||||
}
|
||||
|
||||
function commitQuality(id: QualityProfileId): void {
|
||||
qualityIdRef.current = id;
|
||||
setQualityId(id);
|
||||
function commitQuality(settings: QualitySettings): void {
|
||||
qualitySettingsRef.current = settings;
|
||||
setQualitySettings(settings);
|
||||
setAdvancedQuality(settings);
|
||||
}
|
||||
|
||||
async function changeQuality(nextId: QualityProfileId): Promise<void> {
|
||||
async function changeQuality(nextProfile: QualitySettings): Promise<void> {
|
||||
if (phase !== "live") {
|
||||
commitQuality(nextId);
|
||||
commitQuality(nextProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,10 +388,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
qualityChangeRef.current = token;
|
||||
setChangingQuality(true);
|
||||
setNotice(null);
|
||||
const profile = QUALITY_PROFILES[nextId];
|
||||
|
||||
try {
|
||||
await applyCaptureProfile(activeStream, profile);
|
||||
await applyCaptureProfile(activeStream, nextProfile);
|
||||
if (
|
||||
!isCurrentGeneration(generation) ||
|
||||
qualityChangeRef.current !== token ||
|
||||
@@ -362,29 +399,45 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
commitQuality(nextId);
|
||||
commitQuality(nextProfile);
|
||||
setDetails(captureDetails(activeStream));
|
||||
if (peerAssistedRef.current) {
|
||||
const roomSettingsSent =
|
||||
!peerAssistedRef.current ||
|
||||
signalRef.current?.send({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: nextId,
|
||||
});
|
||||
}
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: nextProfile,
|
||||
}) === true;
|
||||
const activeSfuRoute = hostSfuRouteRef.current;
|
||||
const [results, sfuUpdated] = await Promise.all([
|
||||
Promise.all(
|
||||
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
|
||||
[...peersRef.current.values()].map((peer) =>
|
||||
peer.updateProfile(nextProfile),
|
||||
),
|
||||
),
|
||||
hostSfuRouteRef.current?.updateProfile(profile) ?? Promise.resolve(true),
|
||||
activeSfuRoute?.updateProfile(nextProfile) ??
|
||||
Promise.resolve(true),
|
||||
]);
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
qualityChangeRef.current === token
|
||||
) {
|
||||
const failed = results.filter((updated) => !updated).length;
|
||||
setNotice(
|
||||
const sfuWarning =
|
||||
hostSfuRouteRef.current === activeSfuRoute
|
||||
? (activeSfuRoute?.getQualityWarning() ?? null)
|
||||
: null;
|
||||
const connectionWarning =
|
||||
failed > 0 || !sfuUpdated
|
||||
? "画质已切换,但部分观看连接未能应用新参数"
|
||||
: `画质已切换为 ${profile.label}`,
|
||||
: null;
|
||||
const syncWarning = roomSettingsSent
|
||||
? null
|
||||
: "房间画质同步将在信令重连后继续";
|
||||
const warning = [sfuWarning ?? connectionWarning, syncWarning]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
setNotice(
|
||||
warning || `画质已切换为 ${qualitySettingsLabel(nextProfile)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -456,7 +509,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
peerId,
|
||||
iceConfig,
|
||||
activeStream,
|
||||
QUALITY_PROFILES[qualityIdRef.current],
|
||||
qualitySettingsRef.current,
|
||||
{
|
||||
sendSignal: (targetPeerId, payload) =>
|
||||
isCurrentGeneration(generation) && signalRef.current === signal
|
||||
@@ -582,16 +635,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
) {
|
||||
peerAssistedRef.current = true;
|
||||
signalRef.current?.send({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: qualityIdRef.current,
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: qualitySettingsRef.current,
|
||||
});
|
||||
void ensureHostSfuRoute(generation).resyncAuthoritative(
|
||||
{
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.resyncAuthoritative({
|
||||
revision: message.routeRevision,
|
||||
phase: "active",
|
||||
assignment: message.routeAssignment,
|
||||
},
|
||||
);
|
||||
})
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
return;
|
||||
}
|
||||
peerAssistedRef.current = false;
|
||||
@@ -606,13 +660,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
if (peerAssistedRef.current) {
|
||||
ensureHostSfuRoute(generation).accept(message);
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.acceptAndWait(message)
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "sfu-config") {
|
||||
if (peerAssistedRef.current) {
|
||||
void ensureHostSfuRoute(generation).acceptConfig(message);
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.acceptConfig(message)
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -713,7 +773,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[qualityIdRef.current]);
|
||||
captured = await captureDisplay(qualitySettingsRef.current);
|
||||
} catch (error) {
|
||||
if (!isCurrentGeneration(generation)) {
|
||||
return;
|
||||
@@ -868,7 +928,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[qualityIdRef.current]);
|
||||
captured = await captureDisplay(qualitySettingsRef.current);
|
||||
} catch (error) {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
@@ -937,6 +997,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
failedPeerIds.push(peerId);
|
||||
}
|
||||
}
|
||||
const sfuWarning =
|
||||
activeSfuRoute && hostSfuRouteRef.current === activeSfuRoute
|
||||
? activeSfuRoute.getQualityWarning()
|
||||
: null;
|
||||
if (
|
||||
!sfuReplaced &&
|
||||
activeSfuRoute &&
|
||||
@@ -977,9 +1041,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
sourceSwitchRef.current === token
|
||||
) {
|
||||
setNotice(
|
||||
failedPeerIds.length > 0
|
||||
? "分享来源已切换,部分观看者正在重新连接"
|
||||
: "分享来源已切换",
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: failedPeerIds.length,
|
||||
sfuReplaced,
|
||||
sfuWarning,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -1018,7 +1084,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<AppHeader status={<SignalStatusBadge state={signalStatus} />} />
|
||||
<AppHeader
|
||||
status={
|
||||
showConnectionDetails ? (
|
||||
<SignalStatusBadge state={signalStatus} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="host-workspace">
|
||||
<section className="broadcast-area" aria-labelledby="broadcast-heading">
|
||||
@@ -1029,13 +1101,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
{phase === "live"
|
||||
? `${viewers.length}/${maxViewers ?? "-"} 人正在观看`
|
||||
: phase === "starting"
|
||||
? "正在建立房间"
|
||||
: room
|
||||
? "房间已就绪"
|
||||
: "尚未开始"}
|
||||
? "正在连接"
|
||||
: phase === "ended" && room
|
||||
? "已停止分享"
|
||||
: room
|
||||
? "房间已就绪"
|
||||
: "尚未开始"}
|
||||
</p>
|
||||
</div>
|
||||
{forceRelay && <span className="diagnostic-badge">强制中继</span>}
|
||||
{showConnectionDetails && forceRelay && (
|
||||
<span className="diagnostic-badge">强制中继</span>
|
||||
)}
|
||||
{(phase === "live" || phase === "starting") && (
|
||||
<div className="broadcast-actions">
|
||||
{phase === "live" && (
|
||||
@@ -1082,13 +1158,46 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="video-stage local-stage">
|
||||
<div
|
||||
className="video-stage local-stage"
|
||||
role="group"
|
||||
aria-label="分享或加入房间"
|
||||
>
|
||||
{stream ? (
|
||||
<video ref={videoRef} autoPlay muted playsInline />
|
||||
) : phase === "idle" ||
|
||||
phase === "ended" ||
|
||||
phase === "error" ? (
|
||||
<div className="stage-placeholder stage-entry">
|
||||
{phase === "ended" && room && (
|
||||
<span className="stage-status">已停止分享</span>
|
||||
)}
|
||||
<div className="entry-actions">
|
||||
<button
|
||||
className="entry-action"
|
||||
type="button"
|
||||
title="开始分享屏幕"
|
||||
onClick={() => void startSharing()}
|
||||
>
|
||||
<MonitorUp size={18} aria-hidden="true" />
|
||||
开始分享
|
||||
</button>
|
||||
<span className="entry-divider" aria-hidden="true">
|
||||
或
|
||||
</span>
|
||||
<a
|
||||
className="entry-action"
|
||||
href="/join"
|
||||
title="输入房间码加入观看"
|
||||
>
|
||||
<Hash size={18} aria-hidden="true" />
|
||||
加入房间
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stage-placeholder">
|
||||
<MonitorUp size={36} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>{phase === "ended" ? "等待开始分享" : "未分享画面"}</span>
|
||||
</div>
|
||||
)}
|
||||
{(phase === "starting" || switchingSource || picturePaused) && (
|
||||
@@ -1102,7 +1211,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{details && stream && (
|
||||
{showConnectionDetails && details && stream && (
|
||||
<div className="capture-strip" aria-label="实际捕获参数">
|
||||
<span>{details.resolution}</span>
|
||||
<span>{details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"}</span>
|
||||
@@ -1113,57 +1222,170 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
{!details?.hasAudio && stream && (
|
||||
<WarningBanner>当前来源没有可共享音频</WarningBanner>
|
||||
)}
|
||||
{room && signalStatus === "connected" && !relayAvailable && (
|
||||
{showConnectionDetails &&
|
||||
room &&
|
||||
signalStatus === "connected" &&
|
||||
!relayAvailable && (
|
||||
<WarningBanner>TURN 未配置,严格网络可能无法连接</WarningBanner>
|
||||
)}
|
||||
{qualityLimitation && (
|
||||
<WarningBanner>{qualityLimitation}</WarningBanner>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="notice" role="status" aria-live="polite">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<ConnectionDetailsToggle
|
||||
checked={showConnectionDetails}
|
||||
onChange={setShowConnectionDetails}
|
||||
/>
|
||||
|
||||
{phase !== "live" && (
|
||||
<button
|
||||
className="button button-primary start-button"
|
||||
type="button"
|
||||
disabled={phase === "starting"}
|
||||
onClick={() => void startSharing()}
|
||||
>
|
||||
<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 className="setup-controls">
|
||||
<div className="quality-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={
|
||||
selectedQualityProfileId === id
|
||||
? "is-selected"
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={selectedQualityProfileId === id}
|
||||
disabled={
|
||||
phase === "starting" ||
|
||||
switchingSource ||
|
||||
changingQuality
|
||||
}
|
||||
onClick={() => void changeQuality(QUALITY_PROFILES[id])}
|
||||
>
|
||||
{QUALITY_PROFILE_LABELS[id]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<details className="advanced-quality">
|
||||
<summary>高级视频设置</summary>
|
||||
<div className="advanced-quality-grid">
|
||||
<label>
|
||||
<span>分辨率上限</span>
|
||||
<select
|
||||
value={advancedQuality.resolution}
|
||||
disabled={changingQuality}
|
||||
onChange={(event) =>
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
resolution: event.target
|
||||
.value as QualitySettings["resolution"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{Object.entries(QUALITY_RESOLUTIONS).map(
|
||||
([resolution, option]) => (
|
||||
<option key={resolution} value={resolution}>
|
||||
{option.label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>帧率上限</span>
|
||||
<div className="range-control">
|
||||
<input
|
||||
type="range"
|
||||
min="15"
|
||||
max="60"
|
||||
step="5"
|
||||
value={advancedQuality.maxFramerate}
|
||||
disabled={changingQuality}
|
||||
onChange={(event) =>
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
maxFramerate: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<output>{advancedQuality.maxFramerate} fps</output>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>视频码率上限</span>
|
||||
<div className="range-control">
|
||||
<input
|
||||
type="range"
|
||||
min="2000000"
|
||||
max="12000000"
|
||||
step="500000"
|
||||
value={advancedQuality.maxBitrate}
|
||||
disabled={changingQuality}
|
||||
onChange={(event) =>
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
maxBitrate: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<output>
|
||||
{(advancedQuality.maxBitrate / 1_000_000).toFixed(1)} Mbps
|
||||
</output>
|
||||
</div>
|
||||
</label>
|
||||
<fieldset className="control-group quality-priority">
|
||||
<legend>质量优先级</legend>
|
||||
<div className="segmented-control">
|
||||
{(
|
||||
Object.keys(
|
||||
DEGRADATION_PREFERENCE_LABELS,
|
||||
) as DegradationPreference[]
|
||||
).map((preference) => (
|
||||
<button
|
||||
key={preference}
|
||||
type="button"
|
||||
className={
|
||||
advancedQuality.degradationPreference === preference
|
||||
? "is-selected"
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={
|
||||
advancedQuality.degradationPreference === preference
|
||||
}
|
||||
disabled={changingQuality}
|
||||
onClick={() =>
|
||||
setAdvancedQuality((current) => ({
|
||||
...current,
|
||||
degradationPreference: preference,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{DEGRADATION_PREFERENCE_LABELS[preference]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<button
|
||||
className="button button-secondary"
|
||||
type="button"
|
||||
disabled={
|
||||
phase === "starting" ||
|
||||
switchingSource ||
|
||||
changingQuality
|
||||
}
|
||||
onClick={() => void changeQuality(advancedQuality)}
|
||||
>
|
||||
{changingQuality ? "正在应用" : "应用视频设置"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{room && (
|
||||
<div className="invite-bar">
|
||||
@@ -1205,9 +1427,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<h3>朋友 {index + 1}</h3>
|
||||
<PeerStatusBadge state={viewer.connectionState} />
|
||||
</div>
|
||||
<PathBadge path={viewer.metrics.path} />
|
||||
{showConnectionDetails && (
|
||||
<PathBadge path={viewer.metrics.path} />
|
||||
)}
|
||||
</div>
|
||||
<StatsGrid metrics={viewer.metrics} direction="send" />
|
||||
{showConnectionDetails && (
|
||||
<StatsGrid
|
||||
metrics={viewer.metrics}
|
||||
direction="send"
|
||||
senderParameters={viewer.senderParameters}
|
||||
/>
|
||||
)}
|
||||
{viewer.error && <p className="inline-error">{viewer.error}</p>}
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
type IceConfig,
|
||||
type MediaAssignment,
|
||||
type ServerMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { AppHeader } from "../components/AppHeader";
|
||||
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
|
||||
import { qualityLimitationSummary } from "../components/connection-details";
|
||||
import {
|
||||
PathBadge,
|
||||
PeerStatusBadge,
|
||||
@@ -23,10 +25,7 @@ import {
|
||||
import { StatsGrid } from "../components/StatsGrid";
|
||||
import { getStableClientId } from "../lib/session";
|
||||
import { SignalingClient } from "../lib/signaling";
|
||||
import {
|
||||
QUALITY_PROFILES,
|
||||
type QualityProfileId,
|
||||
} from "../media/quality";
|
||||
import type { QualitySettings } from "../media/quality";
|
||||
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
|
||||
import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer";
|
||||
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
|
||||
@@ -56,7 +55,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
);
|
||||
const [signalStatus, setSignalStatus] =
|
||||
useState<SignalConnectionState>("offline");
|
||||
const [statusText, setStatusText] = useState("正在进入房间");
|
||||
const [statusText, setStatusText] = useState("正在连接");
|
||||
const [hostOnline, setHostOnline] = useState(false);
|
||||
const [relayAvailable, setRelayAvailable] = useState(false);
|
||||
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
|
||||
@@ -64,6 +63,17 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
const [relaySnapshot, setRelaySnapshot] = useState<PeerSnapshot | null>(null);
|
||||
const [playbackBlocked, setPlaybackBlocked] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
|
||||
|
||||
const qualityLimitation = useMemo(
|
||||
() =>
|
||||
qualityLimitationSummary(
|
||||
[peerSnapshot, relaySnapshot].filter(
|
||||
(snapshot): snapshot is PeerSnapshot => snapshot !== null,
|
||||
),
|
||||
),
|
||||
[peerSnapshot, relaySnapshot],
|
||||
);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const peerRef = useRef<ViewerPeer | null>(null);
|
||||
@@ -73,7 +83,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
let currentIceConfig: IceConfig | null = null;
|
||||
let currentHostOnline = false;
|
||||
let peerAssisted = false;
|
||||
let currentQualityProfileId: QualityProfileId = DEFAULT_QUALITY_PROFILE_ID;
|
||||
let currentQualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS;
|
||||
let currentAssignment: MediaAssignment = {
|
||||
parentPeerId: null,
|
||||
childPeerIds: [],
|
||||
@@ -150,7 +160,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
viewerRelay = new ViewerRelay(
|
||||
currentIceConfig,
|
||||
QUALITY_PROFILES[currentQualityProfileId],
|
||||
currentQualitySettings,
|
||||
{
|
||||
sendSignal: (targetPeerId, payload) =>
|
||||
active &&
|
||||
@@ -313,11 +323,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
|
||||
if (previousParentId !== nextAssignment.parentPeerId) {
|
||||
clearUpstreamState();
|
||||
setStatusText(
|
||||
nextAssignment.parentPeerId
|
||||
? "正在切换媒体来源"
|
||||
: "等待可用的媒体来源",
|
||||
);
|
||||
setStatusText("正在恢复连接");
|
||||
}
|
||||
ensureViewerRelay()?.setChild(nextAssignment.childPeerIds[0] ?? null);
|
||||
}
|
||||
@@ -361,7 +367,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
snapshot.connectionState === "failed"
|
||||
) {
|
||||
clearPeerState();
|
||||
setStatusText("等待分享者再次开始");
|
||||
setStatusText("等待开始分享");
|
||||
return;
|
||||
}
|
||||
setPeerSnapshot(snapshot);
|
||||
@@ -371,7 +377,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
snapshot.connectionState === "failed" ||
|
||||
snapshot.connectionState === "disconnected"
|
||||
) {
|
||||
setStatusText("正在恢复媒体连接");
|
||||
setStatusText("正在恢复连接");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -410,11 +416,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
currentHostOnline = message.hostOnline;
|
||||
setRelayAvailable(message.iceConfig.relayAvailable);
|
||||
setHostOnline(message.hostOnline);
|
||||
if (nextPeerAssisted && "qualityProfileId" in message) {
|
||||
currentQualityProfileId = message.qualityProfileId;
|
||||
void viewerRelay?.updateProfile(
|
||||
QUALITY_PROFILES[currentQualityProfileId],
|
||||
);
|
||||
if (nextPeerAssisted && "qualitySettings" in message) {
|
||||
currentQualitySettings = message.qualitySettings;
|
||||
void viewerRelay?.updateProfile(currentQualitySettings);
|
||||
await ensureViewerSfuRoute().resyncAuthoritative(
|
||||
{
|
||||
revision: message.routeRevision,
|
||||
@@ -433,9 +437,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
) {
|
||||
clearPeerState();
|
||||
}
|
||||
setStatusText(
|
||||
message.hostOnline ? "等待分享画面" : "等待分享者开始分享",
|
||||
);
|
||||
setStatusText(message.hostOnline ? "正在连接" : "等待开始分享");
|
||||
const peer = peerRef.current;
|
||||
peer?.updateIceConfig(message.iceConfig);
|
||||
viewerRelay?.updateIceConfig(message.iceConfig);
|
||||
@@ -481,12 +483,10 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "quality-profile") {
|
||||
if (message.type === "quality-settings") {
|
||||
if (peerAssisted) {
|
||||
currentQualityProfileId = message.qualityProfileId;
|
||||
void viewerRelay?.updateProfile(
|
||||
QUALITY_PROFILES[currentQualityProfileId],
|
||||
);
|
||||
currentQualitySettings = message.qualitySettings;
|
||||
void viewerRelay?.updateProfile(currentQualitySettings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -516,7 +516,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
}
|
||||
const peer = ensurePeer();
|
||||
if (!peer) {
|
||||
setStatusText("尚未收到可用的 ICE 配置");
|
||||
setStatusText("正在连接");
|
||||
return;
|
||||
}
|
||||
await peer.acceptSignal(message.fromPeerId, message.payload);
|
||||
@@ -543,9 +543,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
currentHostOnline = message.online;
|
||||
setHostOnline(message.online);
|
||||
if (!message.online && !peerRef.current?.isConnected()) {
|
||||
setStatusText("等待分享者开始分享");
|
||||
setStatusText("等待开始分享");
|
||||
} else if (message.online && !peerRef.current?.isConnected()) {
|
||||
setStatusText("等待分享画面");
|
||||
setStatusText("正在连接");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -555,21 +555,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setHostOnline(false);
|
||||
setStatusText("分享已停止,等待分享者再次开始");
|
||||
setStatusText("等待开始分享");
|
||||
return;
|
||||
}
|
||||
if (message.type === "room-closed") {
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束");
|
||||
setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭");
|
||||
signal.stop();
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) {
|
||||
clearPeerState();
|
||||
setStatusText("等待分享者再次开始");
|
||||
setStatusText("等待开始分享");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -661,15 +661,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
|
||||
function retryConnection(): void {
|
||||
if (!peerRef.current?.requestRecovery()) {
|
||||
setStatusText(hostOnline ? "等待分享画面" : "等待分享者开始分享");
|
||||
setStatusText(hostOnline ? "正在连接" : "等待开始分享");
|
||||
} else {
|
||||
setStatusText("正在恢复媒体连接");
|
||||
setStatusText("正在恢复连接");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell viewer-shell">
|
||||
<AppHeader status={<SignalStatusBadge state={signalStatus} />} />
|
||||
<AppHeader
|
||||
status={
|
||||
showConnectionDetails ? (
|
||||
<SignalStatusBadge state={signalStatus} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="viewer-workspace">
|
||||
<div className="viewer-title-row">
|
||||
@@ -678,9 +684,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
<p className="section-meta">房间 {roomId}</p>
|
||||
</div>
|
||||
<div className="viewer-badges">
|
||||
{forceRelay && <span className="diagnostic-badge">强制中继</span>}
|
||||
{showConnectionDetails && forceRelay && (
|
||||
<span className="diagnostic-badge">强制中继</span>
|
||||
)}
|
||||
<PeerStatusBadge state={peerSnapshot?.connectionState ?? "waiting"} />
|
||||
<PathBadge path={peerSnapshot?.metrics.path ?? "unknown"} />
|
||||
{showConnectionDetails && (
|
||||
<PathBadge path={peerSnapshot?.metrics.path ?? "unknown"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -747,7 +757,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!relayAvailable &&
|
||||
<ConnectionDetailsToggle
|
||||
checked={showConnectionDetails}
|
||||
onChange={setShowConnectionDetails}
|
||||
/>
|
||||
|
||||
{showConnectionDetails &&
|
||||
!relayAvailable &&
|
||||
signalStatus === "connected" &&
|
||||
(hostOnline || peerSnapshot) && (
|
||||
<WarningBanner>TURN 未配置,严格网络可能无法连接</WarningBanner>
|
||||
@@ -757,16 +773,28 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
{peerSnapshot.error}
|
||||
</div>
|
||||
)}
|
||||
{peerSnapshot && (
|
||||
{relaySnapshot?.error && (
|
||||
<div className="notice notice-error" role="status">
|
||||
{relaySnapshot.error}
|
||||
</div>
|
||||
)}
|
||||
{qualityLimitation && (
|
||||
<WarningBanner>{qualityLimitation}</WarningBanner>
|
||||
)}
|
||||
{showConnectionDetails && peerSnapshot && (
|
||||
<section className="viewer-stats" aria-labelledby="stats-heading">
|
||||
<h2 id="stats-heading">连接数据</h2>
|
||||
<StatsGrid metrics={peerSnapshot.metrics} direction="receive" />
|
||||
</section>
|
||||
)}
|
||||
{relaySnapshot && (
|
||||
{showConnectionDetails && relaySnapshot && (
|
||||
<section className="viewer-stats" aria-labelledby="relay-stats-heading">
|
||||
<h2 id="relay-stats-heading">转发数据</h2>
|
||||
<StatsGrid metrics={relaySnapshot.metrics} direction="send" />
|
||||
<StatsGrid
|
||||
metrics={relaySnapshot.metrics}
|
||||
direction="send"
|
||||
senderParameters={relaySnapshot.senderParameters}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export function sourceSwitchNotice({
|
||||
failedPeerCount,
|
||||
sfuReplaced,
|
||||
sfuWarning,
|
||||
}: {
|
||||
failedPeerCount: number;
|
||||
sfuReplaced: boolean;
|
||||
sfuWarning: string | null;
|
||||
}): string {
|
||||
const peerWarning =
|
||||
failedPeerCount > 0 ? "部分观看者正在重新连接" : null;
|
||||
if (!sfuReplaced) {
|
||||
return [
|
||||
sfuWarning ?? "SFU 分享来源未切换成功,正在恢复观看连接",
|
||||
peerWarning,
|
||||
]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
}
|
||||
if (sfuWarning) {
|
||||
return [sfuWarning, peerWarning]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
}
|
||||
return peerWarning
|
||||
? `分享来源已切换,但${peerWarning}`
|
||||
: "分享来源已切换";
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
|
||||
import {
|
||||
configureVideoSender,
|
||||
senderParameterWarning,
|
||||
type QualityProfile,
|
||||
type VideoSenderParameterReadback,
|
||||
} from "../media/quality";
|
||||
|
||||
export interface SfuConnectionConfig {
|
||||
@@ -40,6 +42,8 @@ export class SfuPublisher {
|
||||
private video: PublishedTrack | null = null;
|
||||
private audio: PublishedTrack | null = null;
|
||||
private profile: QualityProfile | null = null;
|
||||
private senderParameters: VideoSenderParameterReadback | null = null;
|
||||
private qualityWarning: string | null = null;
|
||||
private state: PublisherState = "idle";
|
||||
private generation = 0;
|
||||
private operationTail: Promise<void> = Promise.resolve();
|
||||
@@ -119,6 +123,10 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const senderParameters = await configurePublishedVideo(video, profile);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let audio: PublishedTrack | null = null;
|
||||
if (audioTrack) {
|
||||
@@ -136,6 +144,7 @@ export class SfuPublisher {
|
||||
this.video = video;
|
||||
this.audio = audio;
|
||||
this.profile = profile;
|
||||
this.retainSenderParameters(senderParameters);
|
||||
this.state = "active";
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -183,6 +192,8 @@ export class SfuPublisher {
|
||||
this.video = null;
|
||||
this.audio = null;
|
||||
this.profile = null;
|
||||
this.senderParameters = null;
|
||||
this.qualityWarning = null;
|
||||
this.state = "prepared";
|
||||
return true;
|
||||
});
|
||||
@@ -197,7 +208,8 @@ export class SfuPublisher {
|
||||
}
|
||||
const sdk = this.sdk;
|
||||
const previousVideo = this.video;
|
||||
if (!sdk || !previousVideo) {
|
||||
const profile = this.profile;
|
||||
if (!sdk || !previousVideo || !profile) {
|
||||
throw new Error("SFU publisher has no active video publication");
|
||||
}
|
||||
|
||||
@@ -239,15 +251,27 @@ export class SfuPublisher {
|
||||
this.audio = nextAudio;
|
||||
}
|
||||
|
||||
const senderParameters = await configurePublishedVideo(
|
||||
previousVideo,
|
||||
profile,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
previousVideo.rawTrack = nextVideoTrack;
|
||||
if (previousAudio && nextAudioTrack) {
|
||||
previousAudio.rawTrack = nextAudioTrack;
|
||||
}
|
||||
this.retainSenderParameters(senderParameters);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const failureWarning =
|
||||
error instanceof Error && error.message
|
||||
? `切换 SFU 分享来源失败:${error.message}`
|
||||
: "切换 SFU 分享来源失败";
|
||||
|
||||
// A rejected publish/unpublish may have changed server state without
|
||||
// returning enough ownership information to undo it safely.
|
||||
@@ -271,6 +295,15 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const senderParameters = await configurePublishedVideo(
|
||||
previousVideo,
|
||||
profile,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.senderParameters = senderParameters;
|
||||
this.qualityWarning = failureWarning;
|
||||
}
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
@@ -297,21 +330,28 @@ export class SfuPublisher {
|
||||
}
|
||||
|
||||
try {
|
||||
await configurePublishedVideo(video, profile);
|
||||
const readback = await configurePublishedVideo(video, profile);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.profile = profile;
|
||||
this.retainSenderParameters(readback);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const failureWarning =
|
||||
error instanceof Error && error.message
|
||||
? `应用 SFU 发送参数失败:${error.message}`
|
||||
: "应用 SFU 发送参数失败";
|
||||
try {
|
||||
await configurePublishedVideo(video, previousProfile);
|
||||
const readback = await configurePublishedVideo(video, previousProfile);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.senderParameters = readback;
|
||||
this.qualityWarning = failureWarning;
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
if (this.owns(room, generation)) {
|
||||
@@ -323,6 +363,14 @@ export class SfuPublisher {
|
||||
});
|
||||
}
|
||||
|
||||
getQualityWarning(): string | null {
|
||||
return this.qualityWarning;
|
||||
}
|
||||
|
||||
getSenderParameters(): VideoSenderParameterReadback | null {
|
||||
return this.senderParameters;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const room = this.invalidate();
|
||||
if (room) {
|
||||
@@ -378,6 +426,8 @@ export class SfuPublisher {
|
||||
this.video = null;
|
||||
this.audio = null;
|
||||
this.profile = null;
|
||||
this.senderParameters = null;
|
||||
this.qualityWarning = null;
|
||||
return room;
|
||||
}
|
||||
|
||||
@@ -397,6 +447,13 @@ export class SfuPublisher {
|
||||
this.terminalNotified = true;
|
||||
this.events.onDisconnected?.();
|
||||
}
|
||||
|
||||
private retainSenderParameters(
|
||||
readback: VideoSenderParameterReadback,
|
||||
): void {
|
||||
this.senderParameters = readback;
|
||||
this.qualityWarning = senderParameterWarning(readback);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishTrack(
|
||||
@@ -437,17 +494,18 @@ async function unpublishTrack(room: Room, published: PublishedTrack): Promise<vo
|
||||
async function configurePublishedVideo(
|
||||
published: PublishedTrack,
|
||||
profile: QualityProfile,
|
||||
): Promise<void> {
|
||||
): Promise<VideoSenderParameterReadback> {
|
||||
const videoTrack = published.publication.videoTrack;
|
||||
const sender = videoTrack?.sender;
|
||||
if (!sender) {
|
||||
throw new Error("SFU video publication has no RTP sender");
|
||||
}
|
||||
await configureVideoSender(sender, profile);
|
||||
const readback = await configureVideoSender(sender, profile);
|
||||
videoTrack.publishOptions = {
|
||||
...videoTrack.publishOptions,
|
||||
...videoPublishOptions(profile),
|
||||
};
|
||||
return readback;
|
||||
}
|
||||
|
||||
function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
|
||||
@@ -455,9 +513,9 @@ function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
|
||||
simulcast: false,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: profile.maxBitrate,
|
||||
maxFramerate: profile.frameRate,
|
||||
maxFramerate: profile.maxFramerate,
|
||||
},
|
||||
degradationPreference: "balanced",
|
||||
degradationPreference: profile.degradationPreference,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+165
-28
@@ -44,7 +44,8 @@ body {
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -55,6 +56,7 @@ button {
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--green);
|
||||
outline-offset: 2px;
|
||||
@@ -252,6 +254,70 @@ h3 {
|
||||
background: rgba(5, 7, 6, 0.72);
|
||||
}
|
||||
|
||||
.stage-entry {
|
||||
gap: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stage-status {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr);
|
||||
width: min(520px, calc(100% - 20px));
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.entry-action {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #326645;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: #14271b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entry-action svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.entry-action:hover {
|
||||
border-color: var(--green-strong);
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.entry-divider {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(3px, 1fr) auto minmax(3px, 1fr);
|
||||
gap: 3px;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
color: var(--faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.entry-divider::before,
|
||||
.entry-divider::after {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--border-strong);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.capture-strip {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
@@ -291,28 +357,109 @@ h3 {
|
||||
}
|
||||
|
||||
.setup-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(310px, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.join-room-link {
|
||||
display: inline-flex;
|
||||
grid-column: 1 / -1;
|
||||
width: fit-content;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
.quality-controls {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.join-room-link:hover {
|
||||
.advanced-quality {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.advanced-quality summary {
|
||||
width: fit-content;
|
||||
padding-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.advanced-quality summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.advanced-quality-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 18px;
|
||||
align-items: end;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.advanced-quality-grid > label > span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advanced-quality-grid select {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.range-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 92px;
|
||||
min-height: 40px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.range-control input {
|
||||
width: 100%;
|
||||
accent-color: var(--green);
|
||||
}
|
||||
|
||||
.range-control output {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.quality-priority {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.advanced-quality-grid > .button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.connection-details-toggle {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
max-width: 100%;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connection-details-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.connection-details-toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--green-strong);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
@@ -733,14 +880,13 @@ h3 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setup-controls {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
.advanced-quality-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
.quality-priority {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.viewer-stats .stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -767,15 +913,6 @@ h3 {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.setup-controls {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.remote-stage {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface PeerSnapshot {
|
||||
iceConnectionState: RTCIceConnectionState;
|
||||
metrics: ConnectionMetrics;
|
||||
error: string | null;
|
||||
senderParameters?: import("./media/quality").VideoSenderParameterReadback | null;
|
||||
qualityWarning?: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_METRICS: ConnectionMetrics = {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { IceConfig, SignalPayload } from "../../shared/protocol";
|
||||
import { createOpaqueId } from "../lib/opaque-id";
|
||||
import type { QualityProfile } from "../media/quality";
|
||||
import { configureVideoSender } from "../media/quality";
|
||||
import {
|
||||
configureVideoSender,
|
||||
senderParameterWarning,
|
||||
} from "../media/quality";
|
||||
import {
|
||||
EMPTY_METRICS,
|
||||
type PeerSnapshot,
|
||||
@@ -32,6 +35,9 @@ export class HostPeer {
|
||||
private audioSender: RTCRtpSender | null = null;
|
||||
private statsTimer: number | null = null;
|
||||
private statsInFlight = false;
|
||||
private senderWarning: string | null = null;
|
||||
private limitationReason: string | null = null;
|
||||
private limitationSamples = 0;
|
||||
private disposed = false;
|
||||
private negotiating = false;
|
||||
private senderMutationTail: Promise<void> = Promise.resolve();
|
||||
@@ -56,6 +62,8 @@ export class HostPeer {
|
||||
iceConnectionState: this.connection.iceConnectionState,
|
||||
metrics: { ...EMPTY_METRICS },
|
||||
error: null,
|
||||
senderParameters: null,
|
||||
qualityWarning: null,
|
||||
};
|
||||
this.bindConnectionEvents();
|
||||
}
|
||||
@@ -75,16 +83,12 @@ export class HostPeer {
|
||||
direction: "sendonly",
|
||||
streams: [this.stream],
|
||||
}).sender;
|
||||
try {
|
||||
await this.enqueueSenderMutation(async () => {
|
||||
if (this.disposed || !this.videoSender) {
|
||||
return;
|
||||
}
|
||||
await configureVideoSender(this.videoSender, this.desiredProfile);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Browser rejected preferred sender parameters", error);
|
||||
}
|
||||
await this.enqueueSenderMutation(async () => {
|
||||
if (this.disposed || !this.videoSender) {
|
||||
return false;
|
||||
}
|
||||
return this.configureSender(this.videoSender);
|
||||
});
|
||||
if (!(await this.createOffer(false)) || this.disposed) {
|
||||
return false;
|
||||
}
|
||||
@@ -129,11 +133,7 @@ export class HostPeer {
|
||||
}
|
||||
this.stream = nextStream;
|
||||
this.statsAccumulator = createStatsAccumulator();
|
||||
try {
|
||||
await configureVideoSender(videoSender, this.desiredProfile);
|
||||
} catch (error) {
|
||||
console.warn("Browser rejected preferred sender parameters", error);
|
||||
}
|
||||
await this.configureSender(videoSender);
|
||||
this.snapshot = { ...this.snapshot, error: null };
|
||||
this.emit();
|
||||
return true;
|
||||
@@ -150,16 +150,15 @@ export class HostPeer {
|
||||
if (this.disposed || !videoSender) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await configureVideoSender(videoSender, this.desiredProfile);
|
||||
} catch (error) {
|
||||
this.setError(error, "调整画质失败");
|
||||
if (!(await this.configureSender(videoSender))) {
|
||||
return false;
|
||||
}
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
}
|
||||
this.statsAccumulator = createStatsAccumulator();
|
||||
this.limitationReason = null;
|
||||
this.limitationSamples = 0;
|
||||
this.snapshot = { ...this.snapshot, error: null };
|
||||
this.emit();
|
||||
return true;
|
||||
@@ -325,7 +324,13 @@ export class HostPeer {
|
||||
if (this.disposed || this.statsAccumulator !== statsAccumulator) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = { ...this.snapshot, metrics };
|
||||
this.updateLimitationWarning(metrics.qualityLimitationReason);
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
metrics,
|
||||
qualityWarning:
|
||||
this.senderWarning ?? this.persistentLimitationWarning(),
|
||||
};
|
||||
this.emit();
|
||||
} catch {
|
||||
// Stats are observational and must never disrupt a healthy media path.
|
||||
@@ -340,6 +345,73 @@ export class HostPeer {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private async configureSender(sender: RTCRtpSender): Promise<boolean> {
|
||||
try {
|
||||
const senderParameters = await configureVideoSender(
|
||||
sender,
|
||||
this.desiredProfile,
|
||||
);
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
}
|
||||
this.senderWarning = senderParameterWarning(senderParameters);
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
senderParameters,
|
||||
qualityWarning:
|
||||
this.senderWarning ?? this.persistentLimitationWarning(),
|
||||
};
|
||||
this.emit();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
}
|
||||
this.senderWarning =
|
||||
error instanceof Error && error.message
|
||||
? `应用发送参数失败:${error.message}`
|
||||
: "应用发送参数失败";
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
qualityWarning: this.senderWarning,
|
||||
};
|
||||
this.emit();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private updateLimitationWarning(reason: string | null): void {
|
||||
if (!reason || reason === "none") {
|
||||
this.limitationReason = null;
|
||||
this.limitationSamples = 0;
|
||||
return;
|
||||
}
|
||||
if (reason === this.limitationReason) {
|
||||
this.limitationSamples += 1;
|
||||
return;
|
||||
}
|
||||
this.limitationReason = reason;
|
||||
this.limitationSamples = 1;
|
||||
}
|
||||
|
||||
private persistentLimitationWarning(): string | null {
|
||||
if (this.limitationSamples < 3) {
|
||||
return null;
|
||||
}
|
||||
switch (this.limitationReason) {
|
||||
case "bandwidth":
|
||||
return "持续受带宽限制,浏览器正在降低画面质量";
|
||||
case "cpu":
|
||||
return "持续受编码性能限制,浏览器正在降低画面质量";
|
||||
case "other":
|
||||
return "浏览器持续报告其他画质限制";
|
||||
default:
|
||||
return this.limitationReason
|
||||
? `浏览器持续报告画质限制:${this.limitationReason}`
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
|
||||
+14
-17
@@ -5,11 +5,11 @@ import type { Duplex } from "node:stream";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
|
||||
import {
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
MAX_SIGNAL_BYTES,
|
||||
decodeClientMessage,
|
||||
type ClientMessage,
|
||||
type QualityProfileId,
|
||||
type QualitySettings,
|
||||
type Role,
|
||||
type ServerMessage,
|
||||
} from "../shared/protocol.js";
|
||||
@@ -69,10 +69,7 @@ export class SignalingServer {
|
||||
private readonly socketsBySessionId = new Map<string, WebSocket>();
|
||||
private readonly viewerGraceTimers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly connectionIdsByViewer = new Map<string, string>();
|
||||
private readonly qualityProfileIdsByRoom = new Map<
|
||||
string,
|
||||
QualityProfileId
|
||||
>();
|
||||
private readonly qualitySettingsByRoom = new Map<string, QualitySettings>();
|
||||
private readonly shareGenerationsByRoom = new Map<string, string>();
|
||||
private readonly hybridMediaRouter?: HybridMediaRouter;
|
||||
private readonly now: () => number;
|
||||
@@ -385,9 +382,9 @@ export class SignalingServer {
|
||||
mediaAssignment: hybridState.mediaAssignment,
|
||||
routeRevision: hybridState.routeRevision,
|
||||
routeAssignment: hybridState.routeAssignment,
|
||||
qualityProfileId:
|
||||
this.qualityProfileIdsByRoom.get(participant.roomId) ??
|
||||
DEFAULT_QUALITY_PROFILE_ID,
|
||||
qualitySettings:
|
||||
this.qualitySettingsByRoom.get(participant.roomId) ??
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
...(this.options.sfuFallback
|
||||
? { sfuStandbyUrl: this.options.sfuFallback.url }
|
||||
: {}),
|
||||
@@ -476,25 +473,25 @@ export class SignalingServer {
|
||||
),
|
||||
});
|
||||
return;
|
||||
case "set-quality-profile":
|
||||
case "set-quality-settings":
|
||||
if (!this.options.peerAssistedMedia || authenticated.role !== "host") {
|
||||
this.sendError(
|
||||
socket,
|
||||
"FORBIDDEN",
|
||||
"Only a peer-assisted host may set the quality profile",
|
||||
"Only a peer-assisted host may set quality settings",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.qualityProfileIdsByRoom.set(
|
||||
this.qualitySettingsByRoom.set(
|
||||
authenticated.roomId,
|
||||
message.qualityProfileId,
|
||||
{ ...message.qualitySettings },
|
||||
);
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(
|
||||
authenticated.roomId,
|
||||
)) {
|
||||
this.sendToSession(viewer.sessionId, {
|
||||
type: "quality-profile",
|
||||
qualityProfileId: message.qualityProfileId,
|
||||
type: "quality-settings",
|
||||
qualitySettings: message.qualitySettings,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -844,7 +841,7 @@ export class SignalingServer {
|
||||
this.clearRoomGraceTimers(roomId);
|
||||
this.clearRoomConnectionIds(roomId);
|
||||
this.hybridMediaRouter?.deleteRoom(roomId);
|
||||
this.qualityProfileIdsByRoom.delete(roomId);
|
||||
this.qualitySettingsByRoom.delete(roomId);
|
||||
this.shareGenerationsByRoom.delete(roomId);
|
||||
for (const sessionId of abandoned.sessionIds) {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
@@ -861,7 +858,7 @@ export class SignalingServer {
|
||||
this.clearRoomGraceTimers(expired.roomId);
|
||||
this.clearRoomConnectionIds(expired.roomId);
|
||||
this.hybridMediaRouter?.deleteRoom(expired.roomId);
|
||||
this.qualityProfileIdsByRoom.delete(expired.roomId);
|
||||
this.qualitySettingsByRoom.delete(expired.roomId);
|
||||
this.shareGenerationsByRoom.delete(expired.roomId);
|
||||
for (const sessionId of expired.sessionIds) {
|
||||
const socket = this.socketsBySessionId.get(sessionId);
|
||||
|
||||
+33
-5
@@ -48,6 +48,34 @@ export const qualityProfileIdSchema = z.enum([
|
||||
export type QualityProfileId = z.infer<typeof qualityProfileIdSchema>;
|
||||
export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60";
|
||||
|
||||
export const qualityResolutionSchema = z.enum(["720p", "1080p", "1440p"]);
|
||||
export type QualityResolution = z.infer<typeof qualityResolutionSchema>;
|
||||
|
||||
export const degradationPreferenceSchema = z.enum([
|
||||
"maintain-resolution",
|
||||
"balanced",
|
||||
"maintain-framerate",
|
||||
]);
|
||||
export type DegradationPreference = z.infer<
|
||||
typeof degradationPreferenceSchema
|
||||
>;
|
||||
|
||||
export const qualitySettingsSchema = z
|
||||
.object({
|
||||
resolution: qualityResolutionSchema,
|
||||
maxFramerate: z.number().int().min(15).max(60),
|
||||
maxBitrate: z.number().int().min(2_000_000).max(12_000_000),
|
||||
degradationPreference: degradationPreferenceSchema,
|
||||
})
|
||||
.strict();
|
||||
export type QualitySettings = z.infer<typeof qualitySettingsSchema>;
|
||||
export const DEFAULT_QUALITY_SETTINGS = {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
} as const satisfies QualitySettings;
|
||||
|
||||
export const relayDownstreamEdgesSchema = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
@@ -194,8 +222,8 @@ export const clientMessageSchema = z.union([
|
||||
z.object({ type: z.literal("refresh-ice") }).strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("set-quality-profile"),
|
||||
qualityProfileId: qualityProfileIdSchema,
|
||||
type: z.literal("set-quality-settings"),
|
||||
qualitySettings: qualitySettingsSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
@@ -270,7 +298,7 @@ const authenticatedMessageSchema = z.union([
|
||||
mediaAssignment: mediaAssignmentSchema,
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
routeAssignment: participantRouteAssignmentSchema,
|
||||
qualityProfileId: qualityProfileIdSchema,
|
||||
qualitySettings: qualitySettingsSchema,
|
||||
sfuStandbyUrl: liveKitWebSocketUrlSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
@@ -329,8 +357,8 @@ export const serverMessageSchema = z.union([
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("quality-profile"),
|
||||
qualityProfileId: qualityProfileIdSchema,
|
||||
type: z.literal("quality-settings"),
|
||||
qualitySettings: qualitySettingsSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { qualityLimitationSummary } from "../src/client/components/connection-details.ts";
|
||||
import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
|
||||
|
||||
function snapshot(qualityWarning: string | null): PeerSnapshot {
|
||||
return {
|
||||
peerId: "viewer-1",
|
||||
connectionId: "connection-1",
|
||||
connectionState: "connected",
|
||||
iceConnectionState: "connected",
|
||||
metrics: { ...EMPTY_METRICS, qualityLimitationReason: "bandwidth" },
|
||||
error: null,
|
||||
qualityWarning,
|
||||
};
|
||||
}
|
||||
|
||||
describe("progressive connection details", () => {
|
||||
it("keeps one debounced quality warning outside the details panel", () => {
|
||||
expect(
|
||||
qualityLimitationSummary([
|
||||
snapshot(null),
|
||||
snapshot("持续受带宽限制,浏览器正在降低画面质量"),
|
||||
snapshot("持续受编码性能限制,浏览器正在降低画面质量"),
|
||||
]),
|
||||
).toBe("持续受带宽限制,浏览器正在降低画面质量");
|
||||
expect(qualityLimitationSummary([snapshot(null)])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sourceSwitchNotice } from "../src/client/pages/host-page-notices.ts";
|
||||
|
||||
describe("sourceSwitchNotice", () => {
|
||||
it("does not report success when the SFU source replacement failed", () => {
|
||||
expect(
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: 0,
|
||||
sfuReplaced: false,
|
||||
sfuWarning: null,
|
||||
}),
|
||||
).toBe("SFU 分享来源未切换成功,正在恢复观看连接");
|
||||
});
|
||||
|
||||
it("surfaces an SFU sender warning after a successful replacement", () => {
|
||||
expect(
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: 1,
|
||||
sfuReplaced: true,
|
||||
sfuWarning: "浏览器改写了 SFU 发送参数",
|
||||
}),
|
||||
).toBe("浏览器改写了 SFU 发送参数;部分观看者正在重新连接");
|
||||
});
|
||||
|
||||
it("reports success only when every active route replacement succeeded", () => {
|
||||
expect(
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: 0,
|
||||
sfuReplaced: true,
|
||||
sfuWarning: null,
|
||||
}),
|
||||
).toBe("分享来源已切换");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ class FakeSender {
|
||||
failNextSetParameters = false;
|
||||
deferNextSetParameters = false;
|
||||
readonly appliedMaxBitrates: Array<number | undefined> = [];
|
||||
private parameters = { encodings: [{}] } as RTCRtpSendParameters;
|
||||
private releaseSetParameters: (() => void) | null = null;
|
||||
readonly setParameters = vi.fn(
|
||||
async (parameters: RTCRtpSendParameters) => {
|
||||
@@ -26,6 +27,7 @@ class FakeSender {
|
||||
});
|
||||
}
|
||||
this.appliedMaxBitrates.push(parameters.encodings[0]?.maxBitrate);
|
||||
this.parameters = parameters;
|
||||
},
|
||||
);
|
||||
readonly replaceTrack = vi.fn(async (track: MediaStreamTrack | null) => {
|
||||
@@ -39,7 +41,7 @@ class FakeSender {
|
||||
constructor(public track: MediaStreamTrack | null) {}
|
||||
|
||||
getParameters(): RTCRtpSendParameters {
|
||||
return { encodings: [{}] } as RTCRtpSendParameters;
|
||||
return this.parameters;
|
||||
}
|
||||
|
||||
releaseDeferredSetParameters(): void {
|
||||
@@ -331,7 +333,7 @@ describe("HostPeer source replacement", () => {
|
||||
expect(
|
||||
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
||||
).toMatchObject({
|
||||
degradationPreference: "balanced",
|
||||
degradationPreference: "maintain-resolution",
|
||||
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
|
||||
});
|
||||
});
|
||||
@@ -362,27 +364,29 @@ describe("HostPeer source replacement", () => {
|
||||
});
|
||||
|
||||
it("continues queued profile updates after initial configuration rejects", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
try {
|
||||
const peer = createPeer(
|
||||
createStream(
|
||||
createTrack("video", "video"),
|
||||
createTrack("audio", "audio"),
|
||||
),
|
||||
);
|
||||
const updates: PeerSnapshot[] = [];
|
||||
const peer = createPeer(
|
||||
createStream(
|
||||
createTrack("video", "video"),
|
||||
createTrack("audio", "audio"),
|
||||
),
|
||||
(snapshot) => updates.push(snapshot),
|
||||
);
|
||||
|
||||
const starting = peer.start();
|
||||
const videoSender = FakePeerConnection.latest!.senders[0]!;
|
||||
videoSender.failNextSetParameters = true;
|
||||
const updating = peer.updateProfile(QUALITY_PROFILES["1080p60"]);
|
||||
const starting = peer.start();
|
||||
const videoSender = FakePeerConnection.latest!.senders[0]!;
|
||||
videoSender.failNextSetParameters = true;
|
||||
const updating = peer.updateProfile(QUALITY_PROFILES["1080p60"]);
|
||||
|
||||
await expect(starting).resolves.toBe(true);
|
||||
await expect(updating).resolves.toBe(true);
|
||||
expect(videoSender.setParameters).toHaveBeenCalledTimes(2);
|
||||
expect(videoSender.appliedMaxBitrates).toEqual([8_000_000]);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
await expect(starting).resolves.toBe(true);
|
||||
expect(
|
||||
updates.some((snapshot) =>
|
||||
snapshot.qualityWarning?.startsWith("应用发送参数失败"),
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(updating).resolves.toBe(true);
|
||||
expect(videoSender.setParameters).toHaveBeenCalledTimes(2);
|
||||
expect(videoSender.appliedMaxBitrates).toEqual([8_000_000]);
|
||||
});
|
||||
|
||||
it("can retry the selected quality after a sender update fails", async () => {
|
||||
@@ -406,7 +410,7 @@ describe("HostPeer source replacement", () => {
|
||||
|
||||
expect(videoSender.setParameters).toHaveBeenCalledTimes(3);
|
||||
expect(videoSender.setParameters.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
degradationPreference: "balanced",
|
||||
degradationPreference: "maintain-resolution",
|
||||
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
|
||||
});
|
||||
});
|
||||
@@ -484,6 +488,59 @@ describe("HostPeer source replacement", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("explains a sustained browser quality limitation without changing settings", async () => {
|
||||
const updates: PeerSnapshot[] = [];
|
||||
const peer = createPeer(
|
||||
createStream(createTrack("video", "video"), null),
|
||||
(snapshot) => updates.push(snapshot),
|
||||
);
|
||||
await expect(peer.start()).resolves.toBe(true);
|
||||
const connection = FakePeerConnection.latest!;
|
||||
const sender = connection.senders[0]!;
|
||||
connection.statsReports.push(
|
||||
sendStatsReport({
|
||||
bytesSent: 1_000_000,
|
||||
framesEncoded: 30,
|
||||
timestamp: 1_000,
|
||||
qualityLimitationReason: "bandwidth",
|
||||
}),
|
||||
sendStatsReport({
|
||||
bytesSent: 1_500_000,
|
||||
framesEncoded: 60,
|
||||
timestamp: 2_000,
|
||||
qualityLimitationReason: "bandwidth",
|
||||
}),
|
||||
sendStatsReport({
|
||||
bytesSent: 2_000_000,
|
||||
framesEncoded: 90,
|
||||
timestamp: 3_000,
|
||||
qualityLimitationReason: "bandwidth",
|
||||
}),
|
||||
sendStatsReport({
|
||||
bytesSent: 2_500_000,
|
||||
framesEncoded: 120,
|
||||
timestamp: 4_000,
|
||||
qualityLimitationReason: "none",
|
||||
}),
|
||||
);
|
||||
|
||||
const sample = async (): Promise<void> => {
|
||||
const updateCount = updates.length;
|
||||
statsCallbacks[0]!();
|
||||
await vi.waitFor(() => expect(updates.length).toBeGreaterThan(updateCount));
|
||||
};
|
||||
await sample();
|
||||
await sample();
|
||||
expect(updates.at(-1)?.qualityWarning).toBeNull();
|
||||
await sample();
|
||||
expect(updates.at(-1)?.qualityWarning).toContain("持续受带宽限制");
|
||||
expect(sender.setParameters).toHaveBeenCalledOnce();
|
||||
|
||||
await sample();
|
||||
expect(updates.at(-1)?.qualityWarning).toBeNull();
|
||||
expect(sender.setParameters).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rolls the first sender back when the second replacement fails", async () => {
|
||||
const oldVideo = createTrack("video", "old-video");
|
||||
const oldAudio = createTrack("audio", "old-audio");
|
||||
@@ -770,6 +827,12 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
});
|
||||
|
||||
it("applies the latest profile to the current and future child", async () => {
|
||||
const customSettings = {
|
||||
resolution: "1440p",
|
||||
maxFramerate: 45,
|
||||
maxBitrate: 10_500_000,
|
||||
degradationPreference: "balanced",
|
||||
} as const;
|
||||
const relay = new ViewerRelay(
|
||||
{ iceServers: [], expiresAt: null, relayAvailable: false },
|
||||
QUALITY_PROFILES["1080p60"],
|
||||
@@ -785,12 +848,13 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
const firstConnection = FakePeerConnection.latest!;
|
||||
|
||||
await expect(
|
||||
relay.updateProfile(QUALITY_PROFILES["720p30"]),
|
||||
relay.updateProfile(customSettings),
|
||||
).resolves.toBe(true);
|
||||
expect(
|
||||
firstConnection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
||||
).toMatchObject({
|
||||
encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
|
||||
degradationPreference: "balanced",
|
||||
encodings: [{ maxBitrate: 10_500_000, maxFramerate: 45 }],
|
||||
});
|
||||
|
||||
relay.setChild("second-profile-child");
|
||||
@@ -804,7 +868,8 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
expect(
|
||||
secondConnection.senders[0]?.setParameters.mock.calls[0]?.[0],
|
||||
).toMatchObject({
|
||||
encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
|
||||
degradationPreference: "balanced",
|
||||
encodings: [{ maxBitrate: 10_500_000, maxFramerate: 45 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,19 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeVideoEdgeCount,
|
||||
buildBenchmarkInitScript,
|
||||
everyViewerAdvanced,
|
||||
parseBenchmarkConfig,
|
||||
parseViewerCounts,
|
||||
summarizeSamples,
|
||||
} from "../scripts/peer-assisted-benchmark";
|
||||
|
||||
const lowQualitySettings = {
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
} as const;
|
||||
|
||||
function page(
|
||||
role: "host" | "viewer",
|
||||
label: string,
|
||||
@@ -23,7 +31,7 @@ function page(
|
||||
authenticatedAtEpochMs: 1_000,
|
||||
authenticateSentAtEpochMs: 900,
|
||||
signalingConnected: true,
|
||||
qualityProfileId: "720p30",
|
||||
qualitySettings: lowQualitySettings,
|
||||
maxActiveOutboundMediaEdges: sendEdges,
|
||||
maxAssignedChildren: sendEdges,
|
||||
assignment: {
|
||||
@@ -32,6 +40,7 @@ function page(
|
||||
},
|
||||
firstDecodedAtEpochMs: role === "viewer" ? 1_500 : null,
|
||||
firstRenderedAtEpochMs: role === "viewer" ? 1_550 : null,
|
||||
renderedFrames: role === "viewer" ? 10 : 0,
|
||||
connections: [
|
||||
...Array.from({ length: sendEdges }, (_, index) => ({
|
||||
index,
|
||||
@@ -87,6 +96,23 @@ describe("peer-assisted benchmark configuration", () => {
|
||||
).toThrow(/selected viewer count/);
|
||||
});
|
||||
|
||||
it("requires a relay-sized case for the optional quality control smoke", () => {
|
||||
expect(() =>
|
||||
parseBenchmarkConfig({
|
||||
CHROME_PATH: "chrome",
|
||||
BENCHMARK_VIEWERS: "1",
|
||||
BENCHMARK_QUALITY_SMOKE: "1",
|
||||
}),
|
||||
).toThrow(/at least 3/);
|
||||
expect(
|
||||
parseBenchmarkConfig({
|
||||
CHROME_PATH: "chrome",
|
||||
BENCHMARK_VIEWERS: "3",
|
||||
BENCHMARK_QUALITY_SMOKE: "1",
|
||||
}).qualityControlSmoke,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writes a report file by default and reserves '-' for stdout", () => {
|
||||
expect(parseBenchmarkConfig({ CHROME_PATH: "chrome" }).outputPath).toBe(
|
||||
"benchmark-results/peer-assisted.json",
|
||||
@@ -131,7 +157,9 @@ describe("peer-assisted benchmark observations", () => {
|
||||
expect(summary.maxFirstDecodedAfterAuthenticateMs).toBe(600);
|
||||
expect(
|
||||
summary.finalTopology.every(
|
||||
(entry) => entry.qualityProfileId === "720p30",
|
||||
(entry) =>
|
||||
JSON.stringify(entry.qualitySettings) ===
|
||||
JSON.stringify(lowQualitySettings),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -166,6 +194,27 @@ describe("peer-assisted benchmark observations", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires every viewer to decode and render after a quality change", () => {
|
||||
const before = [
|
||||
page("host", "host", 2, 0),
|
||||
page("viewer", "viewer-1", 1, 1),
|
||||
page("viewer", "viewer-2", 0, 1),
|
||||
];
|
||||
const after = structuredClone(before);
|
||||
for (const viewer of after.filter((entry) => entry.role === "viewer")) {
|
||||
viewer.connections.at(-1)!.receiveTotals!.framesTotal += 1;
|
||||
viewer.renderedFrames += 1;
|
||||
}
|
||||
expect(everyViewerAdvanced(before, after)).toBe(true);
|
||||
|
||||
after[2]!.renderedFrames = before[2]!.renderedFrames;
|
||||
expect(everyViewerAdvanced(before, after)).toBe(false);
|
||||
after[2]!.renderedFrames += 1;
|
||||
after[1]!.connections.at(-1)!.receiveTotals!.framesTotal =
|
||||
before[1]!.connections.at(-1)!.receiveTotals!.framesTotal;
|
||||
expect(everyViewerAdvanced(before, after)).toBe(false);
|
||||
});
|
||||
|
||||
it("injects the requested deterministic capture dimensions", () => {
|
||||
const source = buildBenchmarkInitScript({
|
||||
label: "host-1",
|
||||
@@ -180,6 +229,7 @@ describe("peer-assisted benchmark observations", () => {
|
||||
expect(source).toContain('"frameRate":30');
|
||||
expect(source).toContain("getDisplayMedia");
|
||||
expect(source).toContain("collectConnectionMetrics");
|
||||
expect(source).toContain("set-quality-profile");
|
||||
expect(source).toContain("set-quality-settings");
|
||||
expect(source).toContain("renderedFrames");
|
||||
});
|
||||
});
|
||||
+43
-20
@@ -12,6 +12,12 @@ import {
|
||||
|
||||
const token = "a".repeat(43);
|
||||
const roomId = "123456789012";
|
||||
const qualitySettings = {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
} as const;
|
||||
|
||||
describe("client signaling protocol", () => {
|
||||
it("accepts a bounded authentication message", () => {
|
||||
@@ -152,24 +158,36 @@ describe("client signaling protocol", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts only a bounded quality profile update", () => {
|
||||
it("accepts only strict, bounded quality settings", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "1080p30",
|
||||
type: "set-quality-settings",
|
||||
qualitySettings,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
for (const invalid of [
|
||||
{ ...qualitySettings, resolution: "2160p" },
|
||||
{ ...qualitySettings, maxFramerate: 14 },
|
||||
{ ...qualitySettings, maxFramerate: 61 },
|
||||
{ ...qualitySettings, maxFramerate: 30.5 },
|
||||
{ ...qualitySettings, maxBitrate: 1_999_999 },
|
||||
{ ...qualitySettings, maxBitrate: 12_000_001 },
|
||||
{ ...qualitySettings, maxBitrate: 5_000_000.5 },
|
||||
{ ...qualitySettings, degradationPreference: "automatic" },
|
||||
{ ...qualitySettings, codec: "video/VP9" },
|
||||
]) {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: invalid,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
}
|
||||
const { maxFramerate: _missing, ...missingField } = qualitySettings;
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "1440p60",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "1080p30",
|
||||
bitrate: 5_000_000,
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: missingField,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -307,7 +325,7 @@ describe("server signaling protocol", () => {
|
||||
childPeerIds: ["viewer_12345678", "viewer_87654321"],
|
||||
sfuPublicationGeneration: null,
|
||||
},
|
||||
qualityProfileId: "1080p60",
|
||||
qualitySettings,
|
||||
};
|
||||
|
||||
expect(serverMessageSchema.safeParse(peerAssisted).success).toBe(true);
|
||||
@@ -329,7 +347,7 @@ describe("server signaling protocol", () => {
|
||||
mediaMode: "peer-assisted",
|
||||
routeRevision: 0,
|
||||
routeAssignment: peerAssisted.routeAssignment,
|
||||
qualityProfileId: "1080p60",
|
||||
qualitySettings,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
@@ -357,13 +375,13 @@ describe("server signaling protocol", () => {
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...peerAssisted,
|
||||
qualityProfileId: "1440p60",
|
||||
qualitySettings: { ...qualitySettings, maxBitrate: 20_000_000 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...authenticatedMessage(8),
|
||||
qualityProfileId: "1080p60",
|
||||
qualitySettings,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
@@ -380,14 +398,19 @@ describe("server signaling protocol", () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "quality-profile",
|
||||
qualityProfileId: "720p30",
|
||||
type: "quality-settings",
|
||||
qualitySettings: {
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "balanced",
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "quality-profile",
|
||||
qualityProfileId: "1440p60",
|
||||
type: "quality-settings",
|
||||
qualitySettings: { ...qualitySettings, maxFramerate: 0 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
+81
-17
@@ -5,6 +5,7 @@ import {
|
||||
captureDisplay,
|
||||
configureVideoSender,
|
||||
QUALITY_PROFILES,
|
||||
senderParameterWarning,
|
||||
setVideoPaused,
|
||||
} from "../src/client/media/quality.ts";
|
||||
|
||||
@@ -13,6 +14,7 @@ function createVideoStream() {
|
||||
contentHint: "",
|
||||
enabled: true,
|
||||
applyConstraints: vi.fn(async () => undefined),
|
||||
getSettings: () => ({ width: 1920, height: 1080, frameRate: 60 }),
|
||||
} as unknown as MediaStreamTrack;
|
||||
const stream = {
|
||||
getTracks: () => [videoTrack],
|
||||
@@ -47,36 +49,98 @@ describe("realtime quality controls", () => {
|
||||
expect(videoTrack.contentHint).toBe("motion");
|
||||
});
|
||||
|
||||
it("changes capture constraints without selecting the source again", async () => {
|
||||
it("changes custom capture ceilings without selecting the source again", async () => {
|
||||
const { stream, videoTrack } = createVideoStream();
|
||||
|
||||
await applyCaptureProfile(stream, QUALITY_PROFILES["720p30"]);
|
||||
await applyCaptureProfile(stream, {
|
||||
resolution: "1440p",
|
||||
maxFramerate: 45,
|
||||
maxBitrate: 9_500_000,
|
||||
degradationPreference: "balanced",
|
||||
});
|
||||
|
||||
expect(videoTrack.applyConstraints).toHaveBeenCalledWith({
|
||||
width: { ideal: 1280, max: 1280 },
|
||||
height: { ideal: 720, max: 720 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
width: { ideal: 2560, max: 2560 },
|
||||
height: { ideal: 1440, max: 1440 },
|
||||
frameRate: { ideal: 45, max: 45 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets the screen-share sender degradation preference", async () => {
|
||||
const parameters = {
|
||||
encodings: [],
|
||||
} as unknown as RTCRtpSendParameters;
|
||||
const setParameters = vi.fn(async () => undefined);
|
||||
it("defaults every recommended profile to clarity-first degradation", () => {
|
||||
expect(
|
||||
Object.values(QUALITY_PROFILES).every(
|
||||
(profile) =>
|
||||
profile.degradationPreference === "maintain-resolution",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads back every requested sender control after setParameters", async () => {
|
||||
let applied = { encodings: [] } as unknown as RTCRtpSendParameters;
|
||||
const setParameters = vi.fn(async (parameters: RTCRtpSendParameters) => {
|
||||
applied = parameters;
|
||||
});
|
||||
const sender = {
|
||||
getParameters: () => parameters,
|
||||
track: {
|
||||
getSettings: () => ({ width: 2560, height: 1440 }),
|
||||
},
|
||||
getParameters: () => applied,
|
||||
setParameters,
|
||||
} as unknown as RTCRtpSender;
|
||||
|
||||
await configureVideoSender(sender, QUALITY_PROFILES["1080p60"]);
|
||||
const readback = await configureVideoSender(
|
||||
sender,
|
||||
QUALITY_PROFILES["1080p60"],
|
||||
);
|
||||
|
||||
expect(parameters.encodings[0]).toMatchObject({
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
expect(readback).toEqual({
|
||||
requested: {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 4 / 3,
|
||||
degradationPreference: "maintain-resolution",
|
||||
},
|
||||
applied: {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 4 / 3,
|
||||
degradationPreference: "maintain-resolution",
|
||||
},
|
||||
mismatches: [],
|
||||
});
|
||||
expect(parameters.degradationPreference).toBe("balanced");
|
||||
expect(setParameters).toHaveBeenCalledWith(parameters);
|
||||
expect(senderParameterWarning(readback)).toBeNull();
|
||||
expect(setParameters).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports fields the browser does not retain", async () => {
|
||||
const before = { encodings: [{}] } as RTCRtpSendParameters;
|
||||
const after = {
|
||||
encodings: [{ maxBitrate: 8_000_000 }],
|
||||
degradationPreference: "balanced",
|
||||
} as RTCRtpSendParameters;
|
||||
const getParameters = vi
|
||||
.fn<() => RTCRtpSendParameters>()
|
||||
.mockReturnValueOnce(before)
|
||||
.mockReturnValue(after);
|
||||
const sender = {
|
||||
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
|
||||
getParameters,
|
||||
setParameters: vi.fn(async () => undefined),
|
||||
} as unknown as RTCRtpSender;
|
||||
|
||||
const readback = await configureVideoSender(
|
||||
sender,
|
||||
QUALITY_PROFILES["1080p60"],
|
||||
);
|
||||
|
||||
expect(readback.mismatches).toEqual([
|
||||
"maxFramerate",
|
||||
"scaleResolutionDownBy",
|
||||
"degradationPreference",
|
||||
]);
|
||||
expect(senderParameterWarning(readback)).toContain("帧率上限");
|
||||
expect(senderParameterWarning(readback)).toContain("分辨率缩放");
|
||||
expect(senderParameterWarning(readback)).toContain("质量优先级");
|
||||
});
|
||||
|
||||
it("pauses only the video track and can resume it", () => {
|
||||
|
||||
@@ -187,6 +187,32 @@ describe("MediaRouteTransition", () => {
|
||||
});
|
||||
|
||||
describe("HostSfuRoute", () => {
|
||||
it("settles authoritative activation before exposing its quality warning", async () => {
|
||||
const publisher = {
|
||||
...createFakePublisher([], "publisher"),
|
||||
getQualityWarning: vi.fn(() => "SFU sender parameters were rewritten"),
|
||||
};
|
||||
const route = new HostSfuRoute({
|
||||
getStream: () => ({}) as MediaStream,
|
||||
getProfile: () => QUALITY_PROFILES["720p30"],
|
||||
reconcileChildren: () => undefined,
|
||||
send: () => true,
|
||||
createPublisher: () => publisher,
|
||||
});
|
||||
const assignment = hostAssignment("generation-a");
|
||||
|
||||
route.accept({ revision: 1, phase: "prepare", assignment });
|
||||
await route.acceptConfig(sfuConfig(1));
|
||||
await expect(
|
||||
route.resyncAuthoritative({ revision: 1, phase: "active", assignment }),
|
||||
).resolves.toBe("accepted");
|
||||
|
||||
expect(publisher.activate).toHaveBeenCalledOnce();
|
||||
expect(route.getQualityWarning()).toBe(
|
||||
"SFU sender parameters were rewritten",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a publisher prepare failure without requesting refresh", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const publisher = createFakePublisher([], "publisher");
|
||||
|
||||
+52
-24
@@ -21,6 +21,24 @@ import { SignalingServer } from "../src/server/signaling.ts";
|
||||
import type { SfuTokenIssuer } from "../src/server/livekit-token.ts";
|
||||
|
||||
const allowedOrigin = "http://allowed.test";
|
||||
const defaultQualitySettings = {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
} as const;
|
||||
const balancedQualitySettings = {
|
||||
resolution: "1080p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
degradationPreference: "balanced",
|
||||
} as const;
|
||||
const lowQualitySettings = {
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
} as const;
|
||||
let runningServer: ScreenerServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -674,30 +692,40 @@ describe("WebSocket signaling", () => {
|
||||
expect(authenticated.hostOnline).toBe(false);
|
||||
});
|
||||
|
||||
it("synchronizes a bounded quality profile across a peer-assisted room", async () => {
|
||||
it("synchronizes strict last-wins quality settings across a peer-assisted room", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: true });
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
const hostAuth = peerAssisted(
|
||||
await authenticate(host, harness.room, "host", "quality-host"),
|
||||
);
|
||||
expect(hostAuth.qualityProfileId).toBe("1080p60");
|
||||
expect(hostAuth.qualitySettings).toEqual(defaultQualitySettings);
|
||||
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const viewerAuth = peerAssisted(
|
||||
await authenticate(viewer, harness.room, "viewer", "quality-viewer"),
|
||||
);
|
||||
expect(viewerAuth.qualityProfileId).toBe("1080p60");
|
||||
expect(viewerAuth.qualitySettings).toEqual(defaultQualitySettings);
|
||||
await host.inbox.next("media-assignment");
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "1080p30",
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: balancedQualitySettings,
|
||||
}),
|
||||
);
|
||||
expect(await viewer.inbox.next("quality-profile")).toEqual({
|
||||
type: "quality-profile",
|
||||
qualityProfileId: "1080p30",
|
||||
expect(await viewer.inbox.next("quality-settings")).toEqual({
|
||||
type: "quality-settings",
|
||||
qualitySettings: balancedQualitySettings,
|
||||
});
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: lowQualitySettings,
|
||||
}),
|
||||
);
|
||||
expect(await viewer.inbox.next("quality-settings")).toEqual({
|
||||
type: "quality-settings",
|
||||
qualitySettings: lowQualitySettings,
|
||||
});
|
||||
|
||||
const lateViewer = await openClient(harness.webSocketUrl);
|
||||
@@ -709,13 +737,13 @@ describe("WebSocket signaling", () => {
|
||||
"quality-viewer-late",
|
||||
),
|
||||
);
|
||||
expect(lateViewerAuth.qualityProfileId).toBe("1080p30");
|
||||
expect(lateViewerAuth.qualitySettings).toEqual(lowQualitySettings);
|
||||
await host.inbox.next("media-assignment");
|
||||
|
||||
viewer.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "720p30",
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: defaultQualitySettings,
|
||||
}),
|
||||
);
|
||||
expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN");
|
||||
@@ -806,7 +834,7 @@ describe("WebSocket signaling", () => {
|
||||
"p2p-quality-host",
|
||||
);
|
||||
expect("mediaMode" in hostAuth).toBe(false);
|
||||
expect("qualityProfileId" in hostAuth).toBe(false);
|
||||
expect("qualitySettings" in hostAuth).toBe(false);
|
||||
expect("sfuStandbyUrl" in hostAuth).toBe(false);
|
||||
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
@@ -817,21 +845,21 @@ describe("WebSocket signaling", () => {
|
||||
"p2p-quality-viewer",
|
||||
);
|
||||
expect("mediaMode" in viewerAuth).toBe(false);
|
||||
expect("qualityProfileId" in viewerAuth).toBe(false);
|
||||
expect("qualitySettings" in viewerAuth).toBe(false);
|
||||
expect("sfuStandbyUrl" in viewerAuth).toBe(false);
|
||||
await host.inbox.next("peer-joined");
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "1080p30",
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: balancedQualitySettings,
|
||||
}),
|
||||
);
|
||||
expect((await host.inbox.next("error")).code).toBe("FORBIDDEN");
|
||||
viewer.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "720p30",
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: lowQualitySettings,
|
||||
}),
|
||||
);
|
||||
expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN");
|
||||
@@ -2438,13 +2466,13 @@ describe("WebSocket signaling", () => {
|
||||
|
||||
host.socket.send(
|
||||
JSON.stringify({
|
||||
type: "set-quality-profile",
|
||||
qualityProfileId: "720p30",
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: lowQualitySettings,
|
||||
}),
|
||||
);
|
||||
expect(await viewer.inbox.next("quality-profile")).toEqual({
|
||||
type: "quality-profile",
|
||||
qualityProfileId: "720p30",
|
||||
expect(await viewer.inbox.next("quality-settings")).toEqual({
|
||||
type: "quality-settings",
|
||||
qualitySettings: lowQualitySettings,
|
||||
});
|
||||
|
||||
host.socket.send(
|
||||
@@ -2487,7 +2515,7 @@ describe("WebSocket signaling", () => {
|
||||
parentPeerId: hostAuth.peerId,
|
||||
childPeerIds: [],
|
||||
},
|
||||
qualityProfileId: "720p30",
|
||||
qualitySettings: lowQualitySettings,
|
||||
});
|
||||
|
||||
const resumedHost = await openClient(harness.webSocketUrl);
|
||||
@@ -2505,7 +2533,7 @@ describe("WebSocket signaling", () => {
|
||||
parentPeerId: null,
|
||||
childPeerIds: [viewerAuth.peerId],
|
||||
},
|
||||
qualityProfileId: "720p30",
|
||||
qualitySettings: lowQualitySettings,
|
||||
});
|
||||
expect(await reconnectedViewer.inbox.next("host-status")).toMatchObject({
|
||||
online: true,
|
||||
|
||||
+139
-9
@@ -18,6 +18,11 @@ const livekit = vi.hoisted(() => {
|
||||
readonly getParameters = vi.fn(() => this.parameters);
|
||||
readonly setParameters = vi.fn(
|
||||
async (parameters: RTCRtpSendParameters): Promise<void> => {
|
||||
const failure = state.nextSenderParameterError;
|
||||
if (failure) {
|
||||
state.nextSenderParameterError = null;
|
||||
throw failure;
|
||||
}
|
||||
this.parameters = parameters;
|
||||
},
|
||||
);
|
||||
@@ -124,9 +129,11 @@ const livekit = vi.hoisted(() => {
|
||||
const state: {
|
||||
rooms: FakeRoom[];
|
||||
connectGate: Promise<void> | null;
|
||||
nextSenderParameterError: Error | null;
|
||||
} = {
|
||||
rooms: [],
|
||||
connectGate: null,
|
||||
nextSenderParameterError: null,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -182,11 +189,10 @@ const connection = {
|
||||
};
|
||||
|
||||
const qualityProfile = {
|
||||
label: "1080p 60",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 60,
|
||||
resolution: "1080p",
|
||||
maxFramerate: 60,
|
||||
maxBitrate: 8_000_000,
|
||||
degradationPreference: "maintain-resolution",
|
||||
} as const;
|
||||
|
||||
function track(kind: "video" | "audio", id: string): MediaStreamTrack {
|
||||
@@ -211,6 +217,7 @@ function deferred(): {
|
||||
beforeEach(() => {
|
||||
livekit.state.rooms.length = 0;
|
||||
livekit.state.connectGate = null;
|
||||
livekit.state.nextSenderParameterError = null;
|
||||
vi.stubGlobal("MediaStream", FakeMediaStream);
|
||||
});
|
||||
|
||||
@@ -247,6 +254,7 @@ describe("SfuPublisher", () => {
|
||||
).resolves.toBe(true);
|
||||
|
||||
const room = livekit.state.rooms[0];
|
||||
const sender = room.localParticipant.publications[0].track.sender;
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, video, {
|
||||
source: Track.Source.ScreenShare,
|
||||
simulcast: false,
|
||||
@@ -254,18 +262,49 @@ describe("SfuPublisher", () => {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
},
|
||||
degradationPreference: "balanced",
|
||||
degradationPreference: "maintain-resolution",
|
||||
});
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
dtx: false,
|
||||
});
|
||||
expect(sender.setParameters).toHaveBeenCalledOnce();
|
||||
expect(publisher.getSenderParameters()).toEqual({
|
||||
requested: {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 1,
|
||||
degradationPreference: "maintain-resolution",
|
||||
},
|
||||
applied: {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 1,
|
||||
degradationPreference: "maintain-resolution",
|
||||
},
|
||||
mismatches: [],
|
||||
});
|
||||
|
||||
await expect(publisher.deactivate()).resolves.toBe(true);
|
||||
expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(2);
|
||||
expect(room.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when initial sender configuration is rejected", async () => {
|
||||
const disconnected = vi.fn();
|
||||
const publisher = new SfuPublisher({ onDisconnected: disconnected });
|
||||
await publisher.connect(connection);
|
||||
livekit.state.nextSenderParameterError = new Error("parameters rejected");
|
||||
|
||||
await expect(
|
||||
publisher.activate(stream(track("video", "video-1")), qualityProfile),
|
||||
).rejects.toThrow("parameters rejected");
|
||||
|
||||
expect(livekit.state.rooms[0].disconnect).toHaveBeenCalledWith(false);
|
||||
expect(disconnected).toHaveBeenCalledOnce();
|
||||
expect(publisher.getSenderParameters()).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the active sender profile without republishing", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
@@ -275,11 +314,10 @@ describe("SfuPublisher", () => {
|
||||
|
||||
await expect(
|
||||
publisher.updateProfile({
|
||||
label: "720p 30",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "balanced",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
@@ -295,6 +333,57 @@ describe("SfuPublisher", () => {
|
||||
}),
|
||||
);
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenCalledOnce();
|
||||
expect(publisher.getQualityWarning()).toBeNull();
|
||||
});
|
||||
|
||||
it("retains a visible warning when the SFU sender rewrites a parameter", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
|
||||
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
|
||||
.sender;
|
||||
sender.setParameters.mockImplementationOnce(async (parameters) => {
|
||||
sender.parameters = {
|
||||
...parameters,
|
||||
encodings: parameters.encodings.map((encoding) => ({
|
||||
...encoding,
|
||||
maxBitrate: 2_000_000,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
publisher.updateProfile({
|
||||
resolution: "1080p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
degradationPreference: "balanced",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(publisher.getQualityWarning()).toContain("码率上限");
|
||||
});
|
||||
|
||||
it("retains a visible warning when SFU sender parameters are rejected", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
|
||||
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
|
||||
.sender;
|
||||
sender.setParameters.mockRejectedValueOnce(new Error("unsupported"));
|
||||
|
||||
await expect(
|
||||
publisher.updateProfile({
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "balanced",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(publisher.getQualityWarning()).toBe(
|
||||
"应用 SFU 发送参数失败:unsupported",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the previous video after a partially applied replacement fails", async () => {
|
||||
@@ -316,6 +405,47 @@ describe("SfuPublisher", () => {
|
||||
expect(localTrack.currentTrack).toBe(previousVideo);
|
||||
});
|
||||
|
||||
it("reapplies and retains the current sender settings after replacing video", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
|
||||
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
|
||||
|
||||
await expect(
|
||||
publisher.replaceStream(stream(track("video", "video-2"))),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(2);
|
||||
expect(publisher.getSenderParameters()?.requested).toMatchObject({
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
degradationPreference: "maintain-resolution",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores sender settings when replacement configuration is rejected", async () => {
|
||||
const previousVideo = track("video", "video-1");
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(previousVideo), qualityProfile);
|
||||
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
|
||||
localTrack.sender.setParameters.mockRejectedValueOnce(
|
||||
new Error("replacement parameters rejected"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
publisher.replaceStream(stream(track("video", "video-2"))),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(localTrack.replaceTrack).toHaveBeenCalledTimes(2);
|
||||
expect(localTrack.currentTrack).toBe(previousVideo);
|
||||
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(3);
|
||||
expect(publisher.getSenderParameters()?.applied.maxBitrate).toBe(8_000_000);
|
||||
expect(publisher.getQualityWarning()).toBe(
|
||||
"切换 SFU 分享来源失败:replacement parameters rejected",
|
||||
);
|
||||
});
|
||||
|
||||
it("disconnects fail-closed when replacement rollback also fails", async () => {
|
||||
const disconnected = vi.fn();
|
||||
const previousVideo = track("video", "video-1");
|
||||
|
||||
Reference in new issue
Block a user