fix(sfu): publish a single high representation (#248)
This commit is contained in:
1 parent
3cc858477b
commit
d39f4359cd
11 files changed
+46
-387
No files matched your search
@@ -33,14 +33,14 @@ Last updated: 2026-08-25
|
||||
- Current source and production admit up to `20` Viewers and implement the uniform `1/2/3` endpoint cap, exact prepare candidate, first-decoded-frame commit, strict rollback revision, decoded-progress stall, a two-stage direct/SFU operation deadline, and one event-driven room controller. A short-lived exact-generation decoded-frame observer proves only the pending candidate inside the existing total deadline; the periodic stats sampler remains responsible for active-path diagnostics and stalls. Ordinary peer ICE is STUN-only and the sole application fallback is SFU/UDP; stale TURN environment keys fail startup. SFU admission uses exact publication and Viewer-subscription handles with explicit no-default ingress/egress capacities and a listener-fenced dedicated LiveKit owner. Browser SFU publisher/subscriber connects use fresh empty ICE-server options; the owner physically verified deployed SFU fallback media after release `c4962f5`, closing the prior core-route hold.
|
||||
- Source and production use random four-digit memory rooms, dormant leases, exact-Host resume, restart loss, local preference replay, orthogonal grants/code entry, and no SQLite runtime. V12 uses a 22-character room-incarnation grant, `ROOM_NOT_FOUND`, no diagnostic-file export, and a compact UI showing the complete shorter URL.
|
||||
- Exact production artifacts, services, ports, and any change-scoped infrastructure recovery boundary are owned by [deployment](./deployment.md) and indexed by [status](./status.md).
|
||||
- Source and production require v12, fixed VP8, and no video hint. Their SFU publisher still sends ordered `q,h` simulcast and is not yet aligned with accepted single `HIGH`. Current Pause/Resume, live audio ceilings, exact-sender metrics, relay/SFU audio continuity, typed Viewer presentation, and quality profiles match the product truths above.
|
||||
- Source and production require v12, fixed VP8, and no video hint. Current source publishes exactly one SFU `HIGH` encoding without simulcast or a subscriber layer selector; production still sends ordered `q,h` until the next application cutover. Current Pause/Resume, live audio ceilings, exact-sender metrics, relay/SFU audio continuity, typed Viewer presentation, and quality profiles match the product truths above.
|
||||
- Browser SFU subscribers reconcile eligible Host screen publications across connect/activate event ordering, participant/track arrival, and reconnect. Production LiveKit logs only warn/error, and the application service has a bounded same-host LiveKit readiness preflight.
|
||||
- Exact `main@bf32859` passed TypeScript, 613 Web tests, both builds, hygiene, review, and production postflight. Chrome 151 closed direct/browser-relay VP8 cadence and active-audio ceilings; its SFU A/B rejected always-active `LOW` without claiming a deployed single representation, public SFU 60 fps, or real-game synchronization. Exact metrics live in [verification status](./verification-status.md).
|
||||
- Chrome loopback, cap2/cap3, short resource, recovery, MBB, and A/V fixtures are bounded evidence only. They do not establish quality, resource limits, or product policy.
|
||||
|
||||
## Current Priority
|
||||
|
||||
1. Implement, deploy, and physically verify the accepted single-`HIGH` Browser SFU publication; leave direct, browser-relay, content-hint, and routing behavior unchanged.
|
||||
1. Deploy and physically verify the current single-`HIGH` Browser SFU publication; leave direct, browser-relay, content-hint, and routing behavior unchanged.
|
||||
2. Then finish real-network route, recovery, screen-audio and real-game A/V acceptance. Browser VP8 hardware acceleration and mobile Viewer lifecycle remain Accepted Later; Native/executable, distribution, and repository-wide simplification remain later phases.
|
||||
|
||||
The desktop Host background/minimized report is deferred until it reproduces on current production with a real game and synchronized media plus CPU/GPU evidence. The bounded current-Chrome screening found no immediate Host-page lifecycle drop, and removal of the old harmful `contentHint = "motion"` remains a plausible confound rather than a proven background fix. Mobile Viewer playback and assigned-relay survival remain a separate lifecycle gate.
|
||||
|
||||
@@ -226,7 +226,7 @@ when a sender is installed; `LocalVideoTrack.publishOptions` drives encoding
|
||||
recomputation after a track restart; and `LocalTrackPublication.options` is the
|
||||
input to `republishAllTracks()`. Current source keeps those three owners aligned
|
||||
through the existing publisher operation queue and rollback: call the SDK
|
||||
preference API to update its saved state, configure/read back the current `q,h`
|
||||
preference API to update its saved state, configure/read back the exact single
|
||||
sender as the final write, then assign one merged option object to both retained
|
||||
locations. Failure reapplies the previous profile; generation loss cannot
|
||||
retain the result as current publisher state. This changes no capture
|
||||
@@ -408,7 +408,7 @@ time deltas, `remoteId` linkage, and the selected path reached through that RTP
|
||||
stream's transport. Source replacement blocks sampling and invalidates in-flight
|
||||
generations. Direct/relay edges bind that evidence to the
|
||||
current track and PeerConnection. The Host SFU publisher also samples only its
|
||||
active LiveKit sender's `h` representation and binds the result again to the
|
||||
exact active LiveKit sender and binds the result again to the
|
||||
current publication generation; replacement, profile reset, retirement,
|
||||
disconnect, and authoritative resync clear the old identity before another
|
||||
sample can appear. The Host details UI renders that source once as `SFU 发送`,
|
||||
@@ -443,11 +443,10 @@ one unambiguous encoding. Current quality settings do not request a mode, so
|
||||
the requested value remains null and a browser-reported default is not called
|
||||
a mismatch; multiple encodings remain unknown. Inbound stats provide no current
|
||||
standard `scalabilityMode` source, so C does not carry a null-only placeholder.
|
||||
Current source and production still configure the ordered `q,h` publication,
|
||||
but the exact-production gate above rejected it because the active lower
|
||||
representation reduced `HIGH`. ADR-0007 now accepts one Browser SFU `HIGH`;
|
||||
source implementation and the exact single-representation production gate are
|
||||
the next scoped work.
|
||||
The exact-production gate above rejected its ordered `q,h` publication because
|
||||
the active lower representation reduced `HIGH`. Current source now implements
|
||||
ADR-0007's one Browser SFU `HIGH`; production remains `q,h` until the exact
|
||||
single-representation deployment gate passes.
|
||||
|
||||
Official W3C text checked 2026-08-19 defines names ending in `Id` as stats-object
|
||||
references. In particular, outbound [`mediaSourceId`](https://www.w3.org/TR/webrtc-stats/#dom-rtcoutboundrtpstreamstats-mediasourceid)
|
||||
|
||||
@@ -58,7 +58,8 @@ Current high-signal boundary:
|
||||
three sender ceilings. An exact-production VP8 gate sustained about 59.5 fps
|
||||
on direct and Browser-relay paths and rejected the current SFU `q,h`
|
||||
publication because active `LOW` reduced `HIGH`. ADR-0007 accepts one Browser
|
||||
SFU `HIGH`; source and production remain `q,h` until the next scoped change.
|
||||
SFU `HIGH`; current source implements that single representation, while
|
||||
production remains `q,h` until the next application cutover and physical gate.
|
||||
- Ordinary browser ICE owns direct reachability. Do not build port prediction,
|
||||
guessed candidates, NAT classification, TCP probes, quality scores, or
|
||||
quality-driven reparenting. Current-path quality is diagnostic; a healthy
|
||||
|
||||
+3
-3
@@ -22,14 +22,14 @@ This is the current execution index. Git history owns completed timelines; [veri
|
||||
- One event-driven controller owns the committed graph and at most one room-serial child operation. It uses one deterministic candidate list/cursor and one total direct-then-SFU deadline; exact admission and physical resources remain charged through drain, and the pending candidate commits only after an exact-generation decoded-frame proof. A short-lived 100 ms stats observer proves only that candidate inside the existing deadline, while the 2-second sampler remains responsible for active-path diagnostics and decoded-frame stalls. Healthy edges remain sticky and current-path quality does not authorize reparenting.
|
||||
- Ordinary peer ICE is STUN-only and the only application fallback is the dedicated LiveKit SFU over UDP. SFU generations use explicit no-default ingress/egress capacities, exact `reserved | committed | draining` accounting, delete-plus-absence release, one Host publication, and exact Viewer subscription handles. No Screener TURN, ICE/TCP, media TCP, or TLS-relayed media route exists.
|
||||
- Current Browser SFU publisher and subscriber connects supply fresh `autoSubscribe: false` plus `rtcConfig: { iceServers: [] }` options. Ordinary peers retain deployment STUN; LiveKit signaling candidates, server-side public-IP discovery, route deadlines, ports, and retry behavior are unchanged.
|
||||
- Current source leaves video `contentHint` unset and keeps audio `contentHint = "music"`. Browser direct and browser-relay offers contain VP8 as their only media codec, and SFU publication explicitly uses VP8 with no backup codec. Codec UI, quality state, wire fields, fallback media codecs, and share-lifetime codec branches are absent. The SFU publisher still configures ordered `q,h` simulcast and is therefore out of alignment with ADR-0007's accepted single-`HIGH` representation until the next scoped source change. Live 64/128/256 kbps audio-ceiling mutation remains unchanged; ordinary Host and SFU publisher metrics select the exact current audio sender, relays reconcile audio-track additions and removals on the persistent stream, and an SFU reconnect reacquires the current publication/sender or fails the route closed.
|
||||
- Current source leaves video `contentHint` unset and keeps audio `contentHint = "music"`. Browser direct and browser-relay offers contain VP8 as their only media codec, and SFU publication uses exactly one VP8 `HIGH` encoding with `simulcast: false`, no backup codec, no subscriber quality selector, and explicit `dynacast: false`. Codec UI, quality state, wire fields, fallback media codecs, and share-lifetime codec branches are absent. Live 64/128/256 kbps audio-ceiling mutation remains unchanged; ordinary Host and SFU publisher metrics select the exact current sender, relays reconcile audio-track additions and removals on the persistent stream, and an SFU reconnect reacquires the current publication/sender or fails the route closed.
|
||||
- Current source implements typed first-frame Viewer presentation and recovery, Chinese user-facing route/access failures, local connection details without a diagnostic-file export, authenticated Host-only on-demand route snapshots for automatic acceptance, explicit missing-room presentation, and responsive entry controls. Host invitation controls remain independent from two-state room-code entry.
|
||||
- Recommended quality remains exactly `1080p60`, `1080p30`, and `720p30`; current source defaults to `1080p30`. Advanced resolution adds `480p` as `854x480` without adding a fourth preset, and advanced FPS and bitrate remain independent.
|
||||
- Exact `main@bf32859` passed repository hygiene, TypeScript, all 613 Web tests in 44 files, both production builds, focused independent review, and the production artifact/service postflight above. A Chrome 151 production gate then exercised two direct children, one browser-relay child, one SFU subscription, all three audio ceilings, and live source replacement with progressing audio/video. A separate exact-production VP8 gate sustained about 59.5 fps on direct and browser-relay paths, while its SFU A/B proved that the always-active `LOW` materially reduced `HIGH`; exact applicability remains in [verification status](./verification-status.md).
|
||||
- Current source passed repository hygiene, TypeScript, all 607 Web tests in 44 files, and both production builds. Exact production `bf32859` separately passed its recorded source and postflight gates. A Chrome 151 production gate exercised two direct children, one browser-relay child, one SFU subscription, all three audio ceilings, and live source replacement with progressing audio/video. A separate exact-production VP8 gate sustained about 59.5 fps on direct and browser-relay paths, while its SFU A/B proved that the always-active `LOW` materially reduced `HIGH`; the new single-representation source still requires exact deployed proof.
|
||||
|
||||
## Current Milestone
|
||||
|
||||
1. Align Browser SFU publication with ADR-0007's accepted single-`HIGH` VP8 representation, deploy the exact artifact, and physically verify one outbound video encoding with progressing publisher/subscriber media. Direct and browser-relay VP8/no-hint performance is already closed by the exact-production gate.
|
||||
1. Deploy the current single-`HIGH` Browser SFU source, then physically verify exactly one outbound video encoding with progressing publisher/subscriber media. Direct and browser-relay VP8/no-hint performance is already closed by the exact-production gate.
|
||||
2. Finish representative ICE/STUN/SFU acceptance, including route recovery, screen-audio continuity and real-game A/V sync on mobile and heterogeneous networks. Browser port prediction, NAT classification, TCP probing, fake page keepalive, and quality-driven reparenting remain outside the accepted model.
|
||||
|
||||
## Active Boundaries
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Only items in **Now** are executable after their stated decision gate. A branch
|
||||
|
||||
## Now
|
||||
|
||||
1. **Align Browser SFU publication with the accepted single-`HIGH` verdict.** Exact production Chrome 151 closed the intrinsic VP8/no-hint shortfall on direct and browser-relay paths, but proved that the always-active SFU `LOW` materially reduces `HIGH` on the measured public ingress. Replace the current ordered `q,h` publication with one VP8 `HIGH` representation, remove only the now-unowned two-layer helpers and assertions, deploy the exact artifact, and physically verify one outbound video encoding plus progressing publisher/subscriber frames over LiveKit UDP. Do not change direct/browser-relay media, `contentHint`, route ordering, deadlines, or add a bitrate controller.
|
||||
1. **Deploy and prove the current single-`HIGH` Browser SFU publication.** Exact production Chrome 151 closed the intrinsic VP8/no-hint shortfall on direct and browser-relay paths, but proved that the always-active SFU `LOW` materially reduces `HIGH` on the measured public ingress. Current source now uses one VP8 `HIGH` encoding and removes only the unowned two-layer helpers and assertions. Deploy the exact artifact and physically verify exactly one outbound video encoding, no RID or second representation, and progressing publisher/subscriber frames over LiveKit UDP. Do not change direct/browser-relay media, `contentHint`, route ordering, deadlines, or add a bitrate controller.
|
||||
2. **Finish real-network route acceptance.** Measure standard ICE/STUN direct paths, peer relay, SFU ingress/subscription, exact first-frame commit and rollback, relay-ingress repair with subtree retention, disconnect/capacity drain, Pause/Resume, screen-audio continuity and real-game A/V sync, and bounded explicit failure across representative IPv4/IPv6 NATs and mobile networks. Include an all-UDP-blocked case to establish the current failure boundary. Do not add port prediction, NAT classification, TCP probes, guessed candidates, route scores, or quality-driven reparenting.
|
||||
|
||||
## Accepted Later Roadmap
|
||||
|
||||
@@ -35,11 +35,11 @@ Capacity and routing entries below describe the exact tested source or release o
|
||||
## Open Proof Boundaries
|
||||
|
||||
- Routing: exact v10 local direct loopback remains historical evidence for admission-20 initial ingress and exact-candidate first-frame commit timing on that source. Current v12 production still needs route rollback, direct-stage exhaustion and SFU admission, relay-ingress reparent with subtree retention, disconnect/effective-capacity drain, pause/resume, and bounded failure on real browser/media paths. Silent control partitions may wait 30 to 60 seconds for heartbeat detection before the default five-second grace; a non-paused decoded-frame stall may invalidate only that exact child edge.
|
||||
- Quality: exact-production direct and Browser-relay VP8/no-hint cadence is closed. The current dual-representation SFU publication failed ADR-0007's quality gate; source/deployment alignment and exact single-representation publisher/subscriber evidence remain open. Startup ramp is stock BWE behavior and does not authorize an SDP or application bitrate workaround.
|
||||
- Quality: exact-production direct and Browser-relay VP8/no-hint cadence is closed. Current source implements ADR-0007's single-`HIGH` SFU publication; deployment and exact single-representation publisher/subscriber evidence remain open. Startup ramp is stock BWE behavior and does not authorize an SDP or application bitrate workaround.
|
||||
- Host background: deferred until the report reproduces on exact production with a continuously changing real game or replay, a separate direct wired Viewer, synchronized capture/outbound/inbound plus CPU/GPU evidence, and repeated focused/unfocused/hidden/minimized windows. The current-Chrome Browser `getDisplayMedia()` WPF-window screening did not observe an immediate Host-page media drop, and the old harmful `contentHint = "motion"` release remains a plausible confound rather than a proven background cause. Current evidence authorizes no keepalive, Wake Lock, timer, codec switch, route change, or Native fallback.
|
||||
- Relay resources: current source and production expose the authenticated `1/2/3` endpoint-cap boundary, and retained v10 local loopback evidence covers 20 Viewers at cap `2` only for that source. CPU/GPU/upload and heterogeneous-network measurements remain diagnostics, not authority to change the contract.
|
||||
- Room scale: current source and production admit 20 Viewers plus one Host, and the retained v10 local Browser convergence gate covers that bound on direct loopback. A real public-network 1:20 session, quality, and sustained resource matrix remain open.
|
||||
- SFU: production uses one Host publication with exact Viewer subscriptions and no fixed root-count or room-wide lease rule. Browser SFU PCs use the accepted empty-external-ICE-server rule and owner physical media proof closed the reported connection hold. Current production still publishes `q,h`; the accepted single-`HIGH` source/deployment change and its exact physical gate remain open. Real LiveKit lifecycle/JWT replay, resource use, SFU-fed first-child behavior, and broader heterogeneous-network acceptance remain open.
|
||||
- SFU: production uses one Host publication with exact Viewer subscriptions and no fixed root-count or room-wide lease rule. Browser SFU PCs use the accepted empty-external-ICE-server rule and owner physical media proof closed the reported connection hold. Current source publishes one `HIGH` encoding without simulcast or a subscriber layer selector, while production still publishes `q,h`; exact deployment and physical proof remain open. Real LiveKit lifecycle/JWT replay, resource use, SFU-fed first-child behavior, and broader heterogeneous-network acceptance remain open.
|
||||
- Audio: current v12 source and production select the exact active ordinary Host or SFU publisher audio sender, reset samples across track changes or absence, keep relay children synchronized with persistent-stream audio changes, and reacquire exact SFU publications/senders after reconnect. The reported active-source `1 kbps` observation and all three live ceilings are physically closed on current Chrome/production across direct, browser-relay and SFU. Audible game quality, heterogeneous-network changes and real-game A/V synchronization remain part of route acceptance; a muted/idle Opus track may still legitimately remain near 1 kbps.
|
||||
- Observability: current v12 source and production keep local connection details but no Host/Viewer diagnostic downloads or Browser file-export plumbing. The Host-only bounded snapshot stays for automatic acceptance. Chrome 151 supplied selected candidate endpoints and ordinary-route A/V playout timestamps in the current gate, but omitted the SFU A/V timestamp. Target-browser availability and actual values remain open for codec/fmtp and encoder implementation/power efficiency, media-source and encoded FPS, and broader jitter/concealment cases. All retained fields diagnose and have no recovery authority; they do not by themselves prove hardware encode, synchronization quality, or route quality.
|
||||
- Mobile: Android/iOS remain Viewer-only. Autoplay, rotation, iOS lock-screen/page reclamation, background reconnection, and network changes need device evidence. AirPlay/system mirroring is Viewer-local output, not a portable in-app WebRTC output contract.
|
||||
|
||||
@@ -93,19 +93,12 @@ export interface VideoSenderParameterReadback {
|
||||
mismatches: Array<keyof VideoSenderParameterValues>;
|
||||
}
|
||||
|
||||
export interface TwoLayerVideoSenderParameterReadback {
|
||||
low: VideoSenderParameterReadback;
|
||||
high: VideoSenderParameterReadback;
|
||||
}
|
||||
|
||||
export interface AudioSenderParameterReadback {
|
||||
requestedMaxBitrate: number;
|
||||
appliedMaxBitrate: number | null;
|
||||
mismatch: boolean;
|
||||
}
|
||||
|
||||
export const SCREEN_SHARE_LOW_SCALE = 2;
|
||||
|
||||
export function resolveScreenAudioQuality(
|
||||
quality: ScreenAudioQuality | undefined,
|
||||
): ScreenAudioQuality {
|
||||
@@ -118,10 +111,6 @@ export function screenAudioBitrate(
|
||||
return SCREEN_AUDIO_BITRATES[resolveScreenAudioQuality(quality)];
|
||||
}
|
||||
|
||||
export function screenShareLowBitrate(profile: QualityProfile): number {
|
||||
return Math.max(150_000, Math.floor(profile.maxBitrate / 4));
|
||||
}
|
||||
|
||||
export function qualitySettingsEqual(
|
||||
left: QualitySettings,
|
||||
right: QualitySettings,
|
||||
@@ -257,9 +246,8 @@ function requestedScaleResolutionDownBy(
|
||||
function readVideoSenderParameters(
|
||||
parameters: RTCRtpSendParameters,
|
||||
includeAppliedScalabilityMode: boolean,
|
||||
encodingIndex = 0,
|
||||
): VideoSenderParameterValues {
|
||||
const encoding = parameters.encodings[encodingIndex];
|
||||
const encoding = parameters.encodings[0];
|
||||
const scalabilityMode = (
|
||||
encoding as
|
||||
| (RTCRtpEncodingParameters & { scalabilityMode?: unknown })
|
||||
@@ -290,21 +278,6 @@ function senderParameterReadback(
|
||||
return { requested, applied, mismatches };
|
||||
}
|
||||
|
||||
function requiredTwoLayerEncodingIndexes(
|
||||
parameters: RTCRtpSendParameters,
|
||||
): { low: number; high: number } {
|
||||
if (parameters.encodings.length !== 2) {
|
||||
throw new Error("SFU simulcast requires exactly two video encodings");
|
||||
}
|
||||
if (
|
||||
parameters.encodings[0]?.rid !== "q" ||
|
||||
parameters.encodings[1]?.rid !== "h"
|
||||
) {
|
||||
throw new Error("SFU simulcast requires ordered q and h video encodings");
|
||||
}
|
||||
return { low: 0, high: 1 };
|
||||
}
|
||||
|
||||
function sameParameter(
|
||||
key: keyof VideoSenderParameterValues,
|
||||
requested: number | string | null,
|
||||
@@ -376,47 +349,6 @@ export function audioSenderParameterWarning(
|
||||
: `浏览器将音频码率上限改写为 ${Math.round(readback.appliedMaxBitrate / 1_000)} kbps`;
|
||||
}
|
||||
|
||||
export async function configureTwoLayerVideoSender(
|
||||
sender: RTCRtpSender,
|
||||
profile: QualityProfile,
|
||||
): Promise<TwoLayerVideoSenderParameterReadback> {
|
||||
const parameters = sender.getParameters();
|
||||
const indexes = requiredTwoLayerEncodingIndexes(parameters);
|
||||
const highScale = requestedScaleResolutionDownBy(sender, profile);
|
||||
const lowEncoding = parameters.encodings[indexes.low]!;
|
||||
const highEncoding = parameters.encodings[indexes.high]!;
|
||||
|
||||
lowEncoding.maxBitrate = screenShareLowBitrate(profile);
|
||||
lowEncoding.maxFramerate = profile.maxFramerate;
|
||||
lowEncoding.scaleResolutionDownBy = highScale * SCREEN_SHARE_LOW_SCALE;
|
||||
highEncoding.maxBitrate = profile.maxBitrate;
|
||||
highEncoding.maxFramerate = profile.maxFramerate;
|
||||
highEncoding.scaleResolutionDownBy = highScale;
|
||||
parameters.degradationPreference = profile.degradationPreference;
|
||||
|
||||
const requestedLow = readVideoSenderParameters(parameters, false, indexes.low);
|
||||
const requestedHigh = readVideoSenderParameters(parameters, false, indexes.high);
|
||||
await sender.setParameters(parameters);
|
||||
|
||||
const appliedParameters = sender.getParameters();
|
||||
const appliedIndexes = requiredTwoLayerEncodingIndexes(appliedParameters);
|
||||
const appliedLow = readVideoSenderParameters(
|
||||
appliedParameters,
|
||||
false,
|
||||
appliedIndexes.low,
|
||||
);
|
||||
const appliedHigh = readVideoSenderParameters(
|
||||
appliedParameters,
|
||||
false,
|
||||
appliedIndexes.high,
|
||||
);
|
||||
|
||||
return {
|
||||
low: senderParameterReadback(requestedLow, appliedLow),
|
||||
high: senderParameterReadback(requestedHigh, appliedHigh),
|
||||
};
|
||||
}
|
||||
|
||||
const PARAMETER_LABELS = {
|
||||
maxBitrate: "码率上限",
|
||||
maxFramerate: "帧率上限",
|
||||
|
||||
@@ -8,13 +8,10 @@ import type {
|
||||
import {
|
||||
audioSenderParameterWarning,
|
||||
configureScreenAudioSender,
|
||||
configureTwoLayerVideoSender,
|
||||
QUALITY_RESOLUTIONS,
|
||||
configureVideoSender,
|
||||
resolveScreenAudioQuality,
|
||||
screenAudioQualityEqual,
|
||||
SCREEN_SHARE_LOW_SCALE,
|
||||
screenAudioBitrate,
|
||||
screenShareLowBitrate,
|
||||
senderParameterWarning,
|
||||
videoQualitySettingsEqual,
|
||||
type AudioSenderParameterReadback,
|
||||
@@ -191,7 +188,7 @@ export class SfuPublisher {
|
||||
room,
|
||||
videoTrack,
|
||||
sdk.Track.Source.ScreenShare,
|
||||
videoPublishOptions(sdk, profile),
|
||||
videoPublishOptions(profile),
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
@@ -200,7 +197,6 @@ export class SfuPublisher {
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
video,
|
||||
profile,
|
||||
sdk,
|
||||
() => this.owns(room, generation),
|
||||
);
|
||||
if (!videoConfiguration || !this.owns(room, generation)) {
|
||||
@@ -396,7 +392,6 @@ export class SfuPublisher {
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
previousVideo,
|
||||
profile,
|
||||
sdk,
|
||||
() => this.owns(room, generation),
|
||||
);
|
||||
if (!videoConfiguration || !this.owns(room, generation)) {
|
||||
@@ -450,7 +445,6 @@ export class SfuPublisher {
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
previousVideo,
|
||||
previousVideoProfile,
|
||||
sdk,
|
||||
() => this.owns(room, generation),
|
||||
);
|
||||
if (!videoConfiguration || !this.owns(room, generation)) {
|
||||
@@ -537,7 +531,6 @@ export class SfuPublisher {
|
||||
const configured = await configurePublishedVideo(
|
||||
video,
|
||||
profile,
|
||||
sdk,
|
||||
() =>
|
||||
this.owns(room, generation) &&
|
||||
requestedRevision === this.profileRevision &&
|
||||
@@ -569,7 +562,6 @@ export class SfuPublisher {
|
||||
const rolledBack = await configurePublishedVideo(
|
||||
video,
|
||||
previousVideoProfile,
|
||||
sdk,
|
||||
() =>
|
||||
this.owns(room, generation) &&
|
||||
requestedRevision === this.profileRevision &&
|
||||
@@ -1018,7 +1010,6 @@ export class SfuPublisher {
|
||||
identity.accumulator,
|
||||
{
|
||||
trackIdentifier: identity.videoTrack.id,
|
||||
rid: "h",
|
||||
audioTrackIdentifier: identity.audioTrack?.id ?? null,
|
||||
},
|
||||
),
|
||||
@@ -1091,7 +1082,6 @@ async function unpublishTrack(room: Room, published: PublishedTrack): Promise<vo
|
||||
async function configurePublishedVideo(
|
||||
published: PublishedTrack,
|
||||
profile: QualityProfile,
|
||||
sdk: LiveKit,
|
||||
ownsPublication: () => boolean,
|
||||
): Promise<PublishedVideoConfiguration | null> {
|
||||
const videoTrack = published.publication.videoTrack;
|
||||
@@ -1107,7 +1097,7 @@ async function configurePublishedVideo(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const readbacks = await configureTwoLayerVideoSender(sender, profile);
|
||||
const readback = await configureVideoSender(sender, profile);
|
||||
if (!ownsPublication()) {
|
||||
if (previousPreference) {
|
||||
await videoTrack.setDegradationPreference(previousPreference);
|
||||
@@ -1117,18 +1107,13 @@ async function configurePublishedVideo(
|
||||
const retainedPublishOptions = {
|
||||
...published.publication.options,
|
||||
...videoTrack.publishOptions,
|
||||
...videoPublishOptions(sdk, profile),
|
||||
...videoPublishOptions(profile),
|
||||
};
|
||||
published.publication.options = retainedPublishOptions;
|
||||
videoTrack.publishOptions = retainedPublishOptions;
|
||||
const highWarning = senderParameterWarning(readbacks.high);
|
||||
const lowWarning = senderParameterWarning(readbacks.low);
|
||||
return {
|
||||
readback: readbacks.high,
|
||||
warning: mergeQualityWarnings(
|
||||
highWarning,
|
||||
lowWarning ? `低档表示:${lowWarning}` : null,
|
||||
),
|
||||
readback,
|
||||
warning: senderParameterWarning(readback),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1205,27 +1190,15 @@ function mergeQualityWarnings(...warnings: Array<string | null>): string | null
|
||||
return present.length > 0 ? present.join(";") : null;
|
||||
}
|
||||
|
||||
function videoPublishOptions(
|
||||
sdk: LiveKit,
|
||||
profile: QualityProfile,
|
||||
): TrackPublishOptions {
|
||||
const highResolution = QUALITY_RESOLUTIONS[profile.resolution];
|
||||
function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
|
||||
return {
|
||||
backupCodec: false,
|
||||
videoCodec: "vp8",
|
||||
simulcast: true,
|
||||
simulcast: false,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: profile.maxBitrate,
|
||||
maxFramerate: profile.maxFramerate,
|
||||
},
|
||||
screenShareSimulcastLayers: [
|
||||
new sdk.VideoPreset(
|
||||
Math.floor(highResolution.width / SCREEN_SHARE_LOW_SCALE),
|
||||
Math.floor(highResolution.height / SCREEN_SHARE_LOW_SCALE),
|
||||
screenShareLowBitrate(profile),
|
||||
profile.maxFramerate,
|
||||
),
|
||||
],
|
||||
degradationPreference: profile.degradationPreference,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,11 +313,6 @@ export class SfuSubscriber {
|
||||
}
|
||||
this.desiredTrackSids.add(publication.trackSid);
|
||||
publication.setSubscribed(true);
|
||||
if (publication.source === sdk.Track.Source.ScreenShare) {
|
||||
// HIGH is a ceiling. LiveKit's per-subscriber BWE may still forward LOW
|
||||
// while this path is constrained and return to HIGH after recovery.
|
||||
publication.setVideoQuality(sdk.VideoQuality.HIGH);
|
||||
}
|
||||
}
|
||||
|
||||
private rememberHostParticipant(
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
applyCaptureProfile,
|
||||
captureDisplay,
|
||||
configureScreenAudioSender,
|
||||
configureTwoLayerVideoSender,
|
||||
configureVideoSender,
|
||||
matchingQualityProfileId,
|
||||
QUALITY_PROFILES,
|
||||
QUALITY_RESOLUTIONS,
|
||||
qualitySettingsEqual,
|
||||
SCREEN_AUDIO_BITRATES,
|
||||
screenShareLowBitrate,
|
||||
senderParameterWarning,
|
||||
setMediaPaused,
|
||||
} from "../src/client/media/quality.ts";
|
||||
@@ -353,93 +351,6 @@ describe("realtime quality controls", () => {
|
||||
expect(readback.mismatches).not.toContain("scalabilityMode");
|
||||
});
|
||||
|
||||
it("configures ordered LOW and HIGH simulcast encodings without flattening LOW", async () => {
|
||||
let applied = {
|
||||
encodings: [{ rid: "q" }, { rid: "h" }],
|
||||
} as RTCRtpSendParameters;
|
||||
const sender = {
|
||||
track: {
|
||||
getSettings: () => ({ width: 2560, height: 1440 }),
|
||||
},
|
||||
getParameters: () => applied,
|
||||
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
|
||||
applied = parameters;
|
||||
}),
|
||||
} as unknown as RTCRtpSender;
|
||||
|
||||
const readbacks = await configureTwoLayerVideoSender(
|
||||
sender,
|
||||
QUALITY_PROFILES["1080p60"],
|
||||
);
|
||||
|
||||
expect(applied).toMatchObject({
|
||||
degradationPreference: "balanced",
|
||||
encodings: [
|
||||
{
|
||||
rid: "q",
|
||||
maxBitrate: 2_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 8 / 3,
|
||||
},
|
||||
{
|
||||
rid: "h",
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 4 / 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(readbacks.low.requested.maxBitrate).toBe(2_000_000);
|
||||
expect(readbacks.high.requested.maxBitrate).toBe(8_000_000);
|
||||
expect(readbacks.low.mismatches).toEqual([]);
|
||||
expect(readbacks.high.mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the standard LOW bitrate floor for small custom HIGH ceilings", () => {
|
||||
expect(
|
||||
screenShareLowBitrate({
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 400_000,
|
||||
degradationPreference: "balanced",
|
||||
}),
|
||||
).toBe(150_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "drops HIGH",
|
||||
after: { encodings: [{ rid: "q" }] },
|
||||
message: "exactly two video encodings",
|
||||
},
|
||||
{
|
||||
label: "reorders the RIDs",
|
||||
after: { encodings: [{ rid: "h" }, { rid: "q" }] },
|
||||
message: "ordered q and h video encodings",
|
||||
},
|
||||
{
|
||||
label: "adds a third RID",
|
||||
after: { encodings: [{ rid: "q" }, { rid: "h" }, { rid: "f" }] },
|
||||
message: "exactly two video encodings",
|
||||
},
|
||||
])("fails closed when sender readback $label", async ({ after, message }) => {
|
||||
const before = {
|
||||
encodings: [{ rid: "q" }, { rid: "h" }],
|
||||
} as RTCRtpSendParameters;
|
||||
const sender = {
|
||||
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
|
||||
getParameters: vi
|
||||
.fn<() => RTCRtpSendParameters>()
|
||||
.mockReturnValueOnce(before)
|
||||
.mockReturnValue(after as RTCRtpSendParameters),
|
||||
setParameters: vi.fn(async () => undefined),
|
||||
} as unknown as RTCRtpSender;
|
||||
|
||||
await expect(
|
||||
configureTwoLayerVideoSender(sender, QUALITY_PROFILES["1080p60"]),
|
||||
).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it("pauses and resumes the shared video and audio tracks together", () => {
|
||||
const { stream, videoTrack, audioTrack } = createVideoStream();
|
||||
|
||||
|
||||
+21
-173
@@ -8,30 +8,6 @@ import { mergeStatsReports } from "../src/client/webrtc/stats.ts";
|
||||
type EventHandler = (...args: unknown[]) => void;
|
||||
|
||||
const livekit = vi.hoisted(() => {
|
||||
class FakeVideoPreset {
|
||||
readonly encoding: {
|
||||
maxBitrate: number;
|
||||
maxFramerate?: number;
|
||||
};
|
||||
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
maxBitrate: number,
|
||||
maxFramerate?: number,
|
||||
) {
|
||||
this.encoding = { maxBitrate, maxFramerate };
|
||||
}
|
||||
|
||||
get resolution(): { width: number; height: number; frameRate?: number } {
|
||||
return {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
frameRate: this.encoding.maxFramerate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSender {
|
||||
track: MediaStreamTrack;
|
||||
failNextSetParameters = false;
|
||||
@@ -131,32 +107,21 @@ const livekit = vi.hoisted(() => {
|
||||
) => {
|
||||
const localTrack = new FakeLocalTrack(rawTrack);
|
||||
localTrack.publishOptions = options;
|
||||
const simulcastLayers = options.screenShareSimulcastLayers;
|
||||
const highEncoding = options.screenShareEncoding;
|
||||
const screenShareEncoding = options.screenShareEncoding;
|
||||
if (
|
||||
rawTrack.kind === "video" &&
|
||||
options.simulcast === true &&
|
||||
Array.isArray(simulcastLayers) &&
|
||||
simulcastLayers.length === 1 &&
|
||||
simulcastLayers[0] instanceof FakeVideoPreset &&
|
||||
typeof highEncoding === "object" &&
|
||||
highEncoding !== null
|
||||
options.simulcast === false &&
|
||||
typeof screenShareEncoding === "object" &&
|
||||
screenShareEncoding !== null
|
||||
) {
|
||||
const high = highEncoding as {
|
||||
const encoding = screenShareEncoding as {
|
||||
maxBitrate?: number;
|
||||
maxFramerate?: number;
|
||||
};
|
||||
localTrack.sender.parameters.encodings = [
|
||||
{
|
||||
rid: "q",
|
||||
maxBitrate: simulcastLayers[0].encoding.maxBitrate,
|
||||
maxFramerate: simulcastLayers[0].encoding.maxFramerate,
|
||||
scaleResolutionDownBy: 2,
|
||||
},
|
||||
{
|
||||
rid: "h",
|
||||
maxBitrate: high.maxBitrate,
|
||||
maxFramerate: high.maxFramerate,
|
||||
maxBitrate: encoding.maxBitrate,
|
||||
maxFramerate: encoding.maxFramerate,
|
||||
scaleResolutionDownBy: 1,
|
||||
},
|
||||
];
|
||||
@@ -205,15 +170,9 @@ const livekit = vi.hoisted(() => {
|
||||
|
||||
class FakeRemotePublication {
|
||||
subscribed = false;
|
||||
requestedVideoQuality: string | null = null;
|
||||
readonly setSubscribed = vi.fn((subscribed: boolean) => {
|
||||
this.subscribed = subscribed;
|
||||
});
|
||||
readonly setVideoQuality = vi.fn((quality: string) => {
|
||||
if (this.subscribed) {
|
||||
this.requestedVideoQuality = quality;
|
||||
}
|
||||
});
|
||||
|
||||
constructor(
|
||||
readonly trackSid: string,
|
||||
@@ -285,7 +244,6 @@ const livekit = vi.hoisted(() => {
|
||||
FakeRemoteParticipant,
|
||||
FakeRemotePublication,
|
||||
FakeRoom,
|
||||
FakeVideoPreset,
|
||||
state,
|
||||
};
|
||||
});
|
||||
@@ -310,17 +268,11 @@ const Track = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VideoQuality = {
|
||||
HIGH: "high",
|
||||
} as const;
|
||||
|
||||
vi.mock("livekit-client", () => ({
|
||||
AudioPresets: livekit.AudioPresets,
|
||||
Room: livekit.FakeRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoPreset: livekit.FakeVideoPreset,
|
||||
VideoQuality,
|
||||
}));
|
||||
|
||||
class FakeMediaStream {
|
||||
@@ -407,27 +359,11 @@ function senderReport(
|
||||
framesEncoded: number,
|
||||
): RTCStatsReport {
|
||||
return statsReport([
|
||||
{
|
||||
id: "video-out-low",
|
||||
type: "outbound-rtp",
|
||||
timestamp,
|
||||
kind: "video",
|
||||
rid: "q",
|
||||
mediaSourceId: "video-source",
|
||||
bytesSent: Math.floor(bytesSent / 4),
|
||||
framesEncoded: Math.floor(framesEncoded / 4),
|
||||
framesPerSecond: 15,
|
||||
frameWidth: 960,
|
||||
frameHeight: 540,
|
||||
totalEncodeTime: framesEncoded * 0.002,
|
||||
qualityLimitationReason: "none",
|
||||
},
|
||||
{
|
||||
id: "video-out",
|
||||
type: "outbound-rtp",
|
||||
timestamp,
|
||||
kind: "video",
|
||||
rid: "h",
|
||||
transportId: "transport",
|
||||
codecId: "codec",
|
||||
mediaSourceId: "video-source",
|
||||
@@ -558,7 +494,7 @@ describe("SfuPublisher", () => {
|
||||
captureHeight: 1080,
|
||||
captureFramesPerSecond: 60,
|
||||
trackIdentifier: previousVideo.id,
|
||||
rtpRid: "h",
|
||||
rtpRid: null,
|
||||
mediaSourceFramesPerSecond: 59,
|
||||
framesPerSecond: 57,
|
||||
resolution: "1920x1080",
|
||||
@@ -699,7 +635,7 @@ describe("SfuPublisher", () => {
|
||||
expect(livekit.state.rooms[0]?.disconnect).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("publishes exactly the ordered q and h video encodings", async () => {
|
||||
it("publishes exactly one VP8 video encoding", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
const video = track("video", "video-1");
|
||||
const audio = track("audio", "audio-1");
|
||||
@@ -715,21 +651,11 @@ describe("SfuPublisher", () => {
|
||||
source: Track.Source.ScreenShare,
|
||||
backupCodec: false,
|
||||
videoCodec: "vp8",
|
||||
simulcast: true,
|
||||
simulcast: false,
|
||||
screenShareEncoding: {
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
},
|
||||
screenShareSimulcastLayers: [
|
||||
expect.objectContaining({
|
||||
width: 960,
|
||||
height: 540,
|
||||
encoding: {
|
||||
maxBitrate: 2_000_000,
|
||||
maxFramerate: 60,
|
||||
},
|
||||
}),
|
||||
],
|
||||
degradationPreference: "maintain-resolution",
|
||||
});
|
||||
expect(
|
||||
@@ -743,22 +669,13 @@ describe("SfuPublisher", () => {
|
||||
});
|
||||
expect(sender.setParameters).toHaveBeenCalledOnce();
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
expect.objectContaining({
|
||||
rid: "q",
|
||||
maxBitrate: 2_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
rid: "h",
|
||||
{
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 1,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
sender.parameters.encodings.every((encoding) => encoding.active !== false),
|
||||
).toBe(true);
|
||||
expect(sender.parameters.encodings[0]).not.toHaveProperty("rid");
|
||||
expect(publisher.getSenderParameters()).toEqual({
|
||||
requested: {
|
||||
maxBitrate: 8_000_000,
|
||||
@@ -858,13 +775,6 @@ describe("SfuPublisher", () => {
|
||||
degradationPreference: "balanced",
|
||||
encodings: [
|
||||
expect.objectContaining({
|
||||
rid: "q",
|
||||
maxBitrate: 750_000,
|
||||
maxFramerate: 30,
|
||||
scaleResolutionDownBy: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
rid: "h",
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 30,
|
||||
scaleResolutionDownBy: 1,
|
||||
@@ -1184,13 +1094,6 @@ describe("SfuPublisher", () => {
|
||||
degradationPreference: "maintain-framerate",
|
||||
encodings: [
|
||||
{
|
||||
rid: "q",
|
||||
maxBitrate: 750_000,
|
||||
maxFramerate: 30,
|
||||
scaleResolutionDownBy: 2,
|
||||
},
|
||||
{
|
||||
rid: "h",
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 30,
|
||||
scaleResolutionDownBy: 1,
|
||||
@@ -1328,41 +1231,6 @@ describe("SfuPublisher", () => {
|
||||
expect(publisher.getQualityWarning()).toContain("码率上限");
|
||||
});
|
||||
|
||||
it("reports a LOW rewrite while retaining HIGH sender readback", 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, index) =>
|
||||
index === 0
|
||||
? { ...encoding, maxBitrate: 200_000 }
|
||||
: { ...encoding },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
publisher.updateProfile({
|
||||
resolution: "1080p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 5_000_000,
|
||||
degradationPreference: "balanced",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(publisher.getSenderParameters()).toMatchObject({
|
||||
requested: { maxBitrate: 5_000_000 },
|
||||
applied: { maxBitrate: 5_000_000 },
|
||||
mismatches: [],
|
||||
});
|
||||
expect(publisher.getQualityWarning()).toContain("低档表示");
|
||||
expect(publisher.getQualityWarning()).toContain("码率上限");
|
||||
});
|
||||
|
||||
it("retains a visible warning when SFU sender parameters are rejected", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
@@ -1396,12 +1264,6 @@ describe("SfuPublisher", () => {
|
||||
degradationPreference: "maintain-resolution",
|
||||
encodings: [
|
||||
expect.objectContaining({
|
||||
rid: "q",
|
||||
maxBitrate: 2_000_000,
|
||||
maxFramerate: 60,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
rid: "h",
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
}),
|
||||
@@ -1411,7 +1273,7 @@ describe("SfuPublisher", () => {
|
||||
expect(publisher.getQualityWarning()).not.toContain("unsupported");
|
||||
});
|
||||
|
||||
it("retains a LOW rewrite warning after rolling back sender parameters", async () => {
|
||||
it("retains a sender rewrite warning after rolling back parameters", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
|
||||
@@ -1422,11 +1284,10 @@ describe("SfuPublisher", () => {
|
||||
.mockImplementationOnce(async (parameters) => {
|
||||
sender.parameters = {
|
||||
...parameters,
|
||||
encodings: parameters.encodings.map((encoding, index) =>
|
||||
index === 0
|
||||
? { ...encoding, maxBitrate: 1_500_000 }
|
||||
: { ...encoding },
|
||||
),
|
||||
encodings: parameters.encodings.map((encoding) => ({
|
||||
...encoding,
|
||||
maxBitrate: 1_500_000,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1441,12 +1302,11 @@ describe("SfuPublisher", () => {
|
||||
|
||||
expect(publisher.getSenderParameters()).toMatchObject({
|
||||
requested: { maxBitrate: 8_000_000 },
|
||||
applied: { maxBitrate: 8_000_000 },
|
||||
mismatches: [],
|
||||
applied: { maxBitrate: 1_500_000 },
|
||||
mismatches: ["maxBitrate"],
|
||||
});
|
||||
expect(publisher.getQualityWarning()).toContain("应用 SFU 发送参数失败");
|
||||
expect(publisher.getQualityWarning()).not.toContain("unsupported");
|
||||
expect(publisher.getQualityWarning()).toContain("低档表示");
|
||||
expect(publisher.getQualityWarning()).toContain("码率上限");
|
||||
});
|
||||
|
||||
@@ -1648,7 +1508,6 @@ describe("SfuSubscriber", () => {
|
||||
|
||||
expect(subscriber.activate()).toBe(true);
|
||||
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
|
||||
expect(hostVideo.setVideoQuality).toHaveBeenCalledWith(VideoQuality.HIGH);
|
||||
});
|
||||
|
||||
it("reconciles a Host participant that appears after activation", async () => {
|
||||
@@ -1666,10 +1525,9 @@ describe("SfuSubscriber", () => {
|
||||
room.emit(RoomEvent.ParticipantConnected, host);
|
||||
|
||||
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
|
||||
expect(hostVideo.setVideoQuality).toHaveBeenCalledWith(VideoQuality.HIGH);
|
||||
});
|
||||
|
||||
it("keeps the assigned screen subscription at a HIGH ceiling", async () => {
|
||||
it("subscribes only to assigned Host screen tracks", async () => {
|
||||
const gate = deferred();
|
||||
livekit.state.connectGate = gate.promise;
|
||||
const streams: Array<MediaStream | null> = [];
|
||||
@@ -1711,19 +1569,9 @@ describe("SfuSubscriber", () => {
|
||||
expect(hostVideo.setSubscribed).not.toHaveBeenCalled();
|
||||
expect(streams).toEqual([]);
|
||||
|
||||
hostVideo.setVideoQuality(VideoQuality.HIGH);
|
||||
expect(hostVideo.requestedVideoQuality).toBeNull();
|
||||
hostVideo.setVideoQuality.mockClear();
|
||||
|
||||
expect(subscriber.activate()).toBe(true);
|
||||
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
|
||||
expect(hostAudio.setSubscribed).toHaveBeenCalledWith(true);
|
||||
expect(hostVideo.setVideoQuality).toHaveBeenCalledWith(VideoQuality.HIGH);
|
||||
expect(hostVideo.requestedVideoQuality).toBe(VideoQuality.HIGH);
|
||||
expect(hostVideo.setSubscribed.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hostVideo.setVideoQuality.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(hostAudio.setVideoQuality).not.toHaveBeenCalled();
|
||||
expect(hostCamera.setSubscribed).not.toHaveBeenCalled();
|
||||
expect(viewerScreen.setSubscribed).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
Reference in new issue
Block a user