diff --git a/docs/project-memory.md b/docs/project-memory.md index 0588c13..8c91786 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -24,7 +24,7 @@ Last updated: 2026-08-21 - Use direct host P2P for one or two viewers, then the bounded controller for every room when `PEER_ASSISTED_MEDIA=true`. Room `1` is historical smoke, not a runtime gate; resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile evidence remain separate. - Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost. - Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate. -- Production Web prefers H.264 through standard ordering, keeps the browser fallback list, and requests SFU H.264 with `backupCodec=false`; VP8 is used only when H.264 is unavailable. This is not a second encoder stack or shared-encode proof. ADR-0006's browser bridge remains no-go. +- Web defaults to browser/LiveKit codec negotiation; advanced settings offer Automatic, H.264, or VP8 for the next share while retaining fallback codecs and `backupCodec=false`. A codec choice neither proves hardware encoding nor shared encode. ADR-0006's browser bridge remains no-go. - Mobile endpoints are Web Viewer-only. Current Android/iOS browsers do not expose Web Host capture; AirPlay/system mirroring is Viewer-local output. - ADR-0005: direct/peer UDP -> bounded SFU roots -> selected-edge TURN. The deployed controller caps pending/answered `peer-selected` at one per room; Host ingress is independent. A pre-`ecc794d` exact-source/production-media canary proves active Host ingress only. Ordinary peers stay STUN-only. - `PEER_ASSISTED_MEDIA=true` enables isolated all-room controllers; room `1` is historical. Keep sticky P2P and one recovery attempt per layer. Deployed healthy-SFU MBB covers one root and retained parent after reauth/recovery, gated by media proof, Host2/browser1 overlap and cooldown. It excludes `peer-selected`; active-SFU loss aborts into normal recovery. Capacity `0 -> 1`, multi-root and browser gates remain open. @@ -51,7 +51,7 @@ Last updated: 2026-08-21 - Production requires independent `SITE_ACCESS_PASSWORD`: its stateless cookie authorizes creation/Host and code-only Viewer attempts; private grants stay direct, while public/private code entry needs site access alone/plus room password. Env/endpoint/cookie/nginx naming migrated atomically; failures remain neutral; there are no accounts/JWT/session rows. Local `gate:access-privacy` proves fragment isolation, site/grant transport-log absence, and raw room-password SQLite absence; room-password WebSocket/log ingress and live/headful evidence remain open. - Production uses nginx, Node.js 24.19.0, coturn 4.17.2, and LiveKit 1.13.5 on UDP 7882 (TCP fallback off). Ordinary peer ICE receives no TURN; the selected-edge tuple is configured only for the current controller edge, with no credential pre-advertised to ordinary peers. Coturn retains the old authenticated-relay ports. Services are healthy with zero restarts. - ADR-0005 rejects `PEER_ASSISTED_ROOM_IDS`; `PEER_ASSISTED_MEDIA=true` enables every room and ordinary peers stay STUN-only. Initial or active Host ingress may consume one bound relay-only grant; ready/abort clears it and failure restores peer baseline. Stale room-ID config fails startup; `screener-v2` is the only deployed wire. -- Defaults are clarity-first; balanced/fluid stay explicit. P2P writes before offer or explicit source/profile changes. Web SFU uses LiveKit 2.22.0 `q,h`; SFU/UDP and selected-TURN work; BWE is open. The lifecycle candidate retains sender/SDK/restart/republish state with rollback; Host A+B, capture and production remain open. +- Defaults are balanced with automatic codec; clarity/fluid and H.264/VP8 remain explicit. Codec is locked during a share. Web SFU uses LiveKit 2.22.0 `q,h`; SFU/UDP and selected-TURN work; BWE, Host A+B and production remain open. - Chrome 151 five-Viewer 720p30 cap2 evidence: Host/relay fanout two; both relay children +80 decoded frames/~1.1 MB; every Viewer decoded; clean stop. Functional only; resource and heterogeneous-network behavior remain open. ## Provisional Quality Targets diff --git a/docs/status.md b/docs/status.md index 36798f8..3c2cd21 100644 --- a/docs/status.md +++ b/docs/status.md @@ -7,7 +7,7 @@ Last updated: 2026-08-21 Production at `https://share.bonfire.icu` is exact `6ccb516a47261054f91dfa2fafa408d39ced59fc`; immutable source/dist artifacts are 619,674/358,621 bytes, with hashes in deployment. Cutover: 2,048 ms lock/1,272 ms stop-health/888 ms switch-health. SQLite v3/five rooms has SHA-256 `aef724ae52c4107be8ec8791e72f8dcaa11b0ec849fb432d022e2cfb9cfe0974`, owner/mode `screener:screener`/0600. Four services are active/running at `NRestarts=0`; rollbacks are `1331fbdd`, then `261e980` and `691863e`. Ordinary ICE is STUN-only; limits are SFU roots <=2, one `peer-selected` attempt excluding Host ingress, and selected UDP TTL 120. This rollout ran no browser/media/SFU/TURN/performance canary; earlier exact-`16f6eab` Host-ingress proof remains separate. -Stable SFU, route/transport truth, preview notice, entry/nickname UI and Web relay cap2 are live. Native is source-only. Production prefers H.264 with negotiated VP8 fallback. +Stable SFU, route/transport truth, preview notice, entry/nickname UI and Web relay cap2 are live. Native is source-only. The controlled production A/B uses browser/LiveKit codec defaults; explicit codec controls are source-complete. ## Execution Principle @@ -15,7 +15,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu ## Current Snapshot -- Capture precedes rooms; source/quality changes and AV pause preserve peers. Defaults are clarity-first; balanced/fluid stay explicit. P2P writes before offer or explicit changes, never on answer. The SFU lifecycle candidate retains sender/SDK/restart/republish options with rollback. Browser degradation and Host SFU A+B remain open. +- Capture precedes rooms; source/quality changes and AV pause preserve peers. Defaults are balanced and automatic codec; clarity/fluid plus H.264/VP8 stay explicit, with codec locked during a share. Browser degradation and Host SFU A+B remain open. - Production `screener-v2` admits private fragment grants directly. Code-only Viewers need site access, then public rooms accept the code while private rooms also require their password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and its cookie are deployed atomically; raw credentials are never stored. - Source UI uses adjacent nickname editing, distinct site/room prompts, and a shared room-code form. Status and details stay neutral before media proof; current evidence labels green P2P/yellow `SFU fallback`, while relay evidence adds `TURN` plus only a locally observed protocol. - Native v2 is source-only (memory rooms, 300s reclaim); it follows authenticated direct-child assignments up to two and fails unsupported SFU/selected ingress boundedly. diff --git a/docs/方案设计.md b/docs/方案设计.md index 69718ad..1c90a29 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -104,7 +104,7 @@ Web Host 在 `authenticate` 中显式发送 `viewerPresence: true` capability, ## 信令协议 -WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-access cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v2` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 site access;Web Host 可选发 `viewerPasswordSettings: true` capability,Native v2 不发送且不接收任何密码设置新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。 +WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-access cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v3` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 site access;Web Host 可选发 `viewerPasswordSettings: true` capability,Native v2 不发送且不接收任何密码设置新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。 主要消息: @@ -152,11 +152,11 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate | 1080p30 | 1920x1080、30 fps | 5 Mbps/peer | | 720p30 | 1280x720、30 fps | 3 Mbps/peer | -浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。Current source 保留有界手动质量控制:视频 track 使用 `contentHint = "motion"`,返回的音频 track 使用 `contentHint = "music"`;二者都是内容提示而不是 codec 或质量档位。三档推荐质量与折叠高级区初始值统一采用 `degradationPreference = "maintain-resolution"`(clarity-first),并允许显式选择 `balanced` 或 `maintain-framerate`(fluid)。高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。本期屏幕音频只有一个固定媒体默认,不改面板名称也不增加音频 UI。 +浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。Current source 保留有界手动质量控制:视频 track 使用 `contentHint = "motion"`,返回的音频 track 使用 `contentHint = "music"`;二者都是内容提示而不是 codec 或质量档位。三档推荐质量与折叠高级区初始值统一采用 `degradationPreference = "balanced"`,并允许显式选择 clarity-first 或 fluid。高级区将设置限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps、三种 degradation preference,以及 `自动 | H.264 | VP8`;codec 直播中锁定并于下一次分享生效。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率、码率或硬编。本期屏幕音频只有一个固定媒体默认,不增加音频 UI。 每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。浏览器负责具体降级,实际结果由 stats 观测;拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。 -Web 视频端不做 SDP munging。P2P 与浏览器 relay 在首个 offer 前读取标准 sender capabilities;存在 H.264 时用 `setCodecPreferences()` 将全部 H.264 条目置前,同时保留完整的 browser codec/repair 列表,能力缺失或 API 拒绝时维持浏览器默认。SFU publication 同样在 H.264 能力存在时请求 `videoCodec: "h264"`,否则请求 VP8,并始终保持 `backupCodec: false`,因此不会为兼容回退同时维护第二份 codec publication。实际 outbound codec/profile 与 stats 是协商结果真相。这个可逆偏好只要求定向互通和回退验证;硬件、CPU/GPU、游戏 FPS 和目标网络证据用于性能结论及未来动态选择,不作为上线前置。AV1 仍不得仅凭压缩率推断默认启用。唯一获准的 Web SDP 变换是下述 Viewer answer 的 Opus stereo receive preference,不得借此扩展视频码率、codec 或通用 munging。 +Web 视频端不做视频 SDP munging。`automatic` 不调用 P2P `setCodecPreferences()` 且不覆盖 LiveKit 默认 codec;显式 H.264/VP8 在首个 offer/publication 前应用,P2P 与 browser relay 仍保留完整 browser codec/repair 回退列表,SFU 始终保持 `backupCodec: false`。codec 改动不热切换现有连接。实际 outbound codec/profile、encoder implementation 与 stats 是结果真相;浏览器支持 H.264 不证明命中硬编。AV1 仍不得仅凭压缩率推断默认启用。唯一获准的 Web SDP 变换是下述 Viewer answer 的 Opus stereo receive preference,不得借此扩展视频码率、codec 或通用 munging。 `audio: true` 只是请求。若 `getAudioTracks()` 为空,分享者界面必须显示“当前来源没有可共享音频”。Web 端同时传入 `windowAudio: "window"` 与 `systemAudio: "include"`:窗口选项请求该窗口/应用音频,整个屏幕选项允许用户勾选系统音频;标签页使用浏览器自身的标签页音频选择。浏览器可以忽略这些 picker hints,且没有对应的标准来源类型读回,因此 UI 不额外推断或标注来源。系统音频由用户显式选择,可能包含语音或通知;video-only 始终可用。 @@ -343,7 +343,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝; - SQLite 房间库从 `1` 自增、重启恢复、v1/v2 -> v3 单事务迁移、nullable 32-byte grant 与 48-byte password material CHECK、畸形值 fail-closed、rotate/revoke/password 写失败原子性和数据最小化;数据库路径无 site access 时启动失败; - STUN/TURN UDP URL 严格校验、生产 STUN 必需、selected-edge tuple 完整性/TTL/secret 独立性、issuer HMAC/expiry/opaque tag、当前 route/session/revision/old-new connection generation 与 one-use rebuild guard,以及 ordinary peer、未命中房间和 Native 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界; -- 单一 `screener-v2` 信令 literal、v1 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; +- 单一 `screener-v3` 信令 literal、v2 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;screen audio SDP helper 覆盖 CRLF、existing/missing fmtp、unknown key preservation、幂等、payload number、rejected/non-audio,以及 empty/no-Opus/ambiguous/parser 异常原对象 fallback,`ViewerPeer` 覆盖 initial/restart/rebuild 时同一结果的 local/signaling 一致性,SFU publisher 精确断言 high-quality stereo/forceStereo/DTX 且不覆盖 RED; - HTTP/WS 集成:nginx per-source site-access `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured site access 与 Host token,Viewer 要么持 exact-room grant,要么先有 site access 再按 room policy;site-access cookie 不绕过 private grant/password,grant/password 不能建房、取得 Host 或跨房。覆盖 fragment/sessionStorage/replaceState、无 site access 的 public/password code-only 统一拒绝、通过 site access 后的 public/password 进入、不含明文于 URL/浏览器持久存储/SQLite/log、v2 Native wire,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; - 客户端策略与 stats 解析:会话替换不重连,ordinary ICE、LiveKit 与 selected-edge TURN protocol 分离,远端 TURN transport 保持未知; @@ -400,7 +400,7 @@ STUN-only;只有 controller-selected edge 可接收一次短期 grant。该边 固定 `2` 是当前上线档而非永久产品上限;后续统一 evidence-driven controller 可在独立验收后使用离散 `0/1/2/3`,不按 device/UA 分支,并由持续 sender bitrate、`availableOutgoingBitrate`、区间 encode time、`qualityLimitationReason`、loss/RTT 健康窗、server deployment hard ceiling 与 cooldown 共同限界。本次 slice 不实现该动态控制。H.264、hardware encode 或 shared encode 可降低编码成本或同观感码率,但每个 child 的网络副本仍独立存在。 -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、`maintain-resolution`,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一完整设置;普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送同步消息都会被拒绝。启用 SFU 时 publisher 也使用这一设置,不另建一套质量协议。 +Peer-assisted 信令只接受一个严格对象:`resolution` 为 `720p | 1080p | 1440p`,`maxFramerate` 为 15 至 60 的整数,`maxBitrate` 为 2,000,000 至 12,000,000 的整数,`degradationPreference` 为 `maintain-resolution | balanced | maintain-framerate`,可选 `videoCodec` 为 `automatic | h264 | vp8`,其余字段和越界值拒绝。服务端用房间内存 Map 保存完整设置,默认 1080p60、8 Mbps、`balanced`、`automatic`,不写 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一设置;本 wire 由 `screener-v3` 隔离旧页面,普通 P2P 严格鉴权结构不变。SFU publisher 复用同一设置。 Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired settings,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;desired settings 在入队前同步更新,已排队操作执行时读取最新值,使快速连续修改保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 1f17863..019519d 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -55,7 +55,7 @@ - [ ] Windows 应用音频:Windows 11 Native sender 使用本地 opaque window target 绑定 HWND、PID 与进程创建时间,只捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享;“全部系统声音”只能由分享者显式选择。普通 Windows 10 不具备当前 process-loopback API 门槛,必须明确标为未解决/不支持而不是伪兼容。浏览器无法证明或隔离任意进程音频时必须如实提示,原生捕获失败、无声或进程退出不得静默扩大为整机捕获。 - [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。启用 selected-edge TURN canary 时只报告脱敏的授权阶段与 selected candidate type,不输出 URL、username、credential、地址或原始 candidate;自检只覆盖当前 UDP 媒体梯级。 - [ ] 受限网络增强:短期鉴权 TURN 只能由完整、默认关闭的部署 tuple 与当前 room controller 启用,并且只能在 direct/peer UDP 与 SFU/UDP 均已按有界梯级失败后,向 controller 选中的当前异常 edge 一次性授权。普通 peer PC 从创建起保持 STUN-only;不得向整个 participant、房间或所有 peer connections 预发 TURN candidates。凭据必须绑定当前授权 session 与 route/connection generation、短期有效、内存态、一次性消费且不进入 URL、日志、浏览器持久化或 SQLite。媒体保持 UDP-only,全部路径耗尽后明确失败;TURN 不得被描述为更高清的传输方式。 -- [x] Web H.264 优先与协商回退:P2P/浏览器 relay 在首个 offer 前用标准 `setCodecPreferences()` 把浏览器发送能力中的全部 H.264 条目置前,同时保留完整的 repair 与其他 codec 列表;SFU 在端点支持 H.264 时请求 H.264 并保持 `backupCodec = false`。能力缺失或服务端不能采用时回退 VP8,不维护第二套应用编码链。实际协商 codec/profile 和 stats 是结果真相;目标设备/生产证据约束性能宣传与未来自适应选择,但完整 CPU/GPU/游戏矩阵不再阻塞这个可逆标准偏好。AV1 仍不得仅凭压缩率推断默认启用。 +- [x] Web 编码选择:高级视频设置提供 `自动 | H.264 | VP8`,默认自动使用浏览器/LiveKit 协商;显式选择只在下一次分享前用标准 codec preference 生效,直播中锁定。P2P/浏览器 relay 保留完整 repair 与其他 codec 回退列表,SFU 保持 `backupCodec = false`,不维护第二套应用编码链。实际 codec/profile、encoder implementation 与 stats 是结果真相;H.264 可选不等于浏览器必走硬编,AV1 仍不得仅凭压缩率推断默认启用。 - [ ] 诊断页面:用户可导出不含敏感地址和凭据的连接诊断信息。 ### P2 - 可选功能 diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts index a2ca58e..5e62ff7 100644 --- a/src/client/media/quality.ts +++ b/src/client/media/quality.ts @@ -4,6 +4,7 @@ import { type QualityProfileId, type QualityResolution, type QualitySettings, + type VideoCodecPreference, } from "../../shared/protocol"; import { displayMediaOptions } from "./audio-capture"; @@ -12,6 +13,7 @@ export type { QualityProfileId, QualityResolution, QualitySettings, + VideoCodecPreference, } from "../../shared/protocol"; export type QualityProfile = QualitySettings; @@ -22,13 +24,15 @@ export const QUALITY_PROFILES = { resolution: "1080p", maxFramerate: 30, maxBitrate: 5_000_000, - degradationPreference: "maintain-resolution", + degradationPreference: "balanced", + videoCodec: "automatic", }, "720p30": { resolution: "720p", maxFramerate: 30, maxBitrate: 3_000_000, - degradationPreference: "maintain-resolution", + degradationPreference: "balanced", + videoCodec: "automatic", }, } as const satisfies Record; @@ -53,6 +57,12 @@ export const DEGRADATION_PREFERENCE_LABELS = { "maintain-framerate": "流畅优先", } as const satisfies Record; +export const VIDEO_CODEC_PREFERENCE_LABELS = { + automatic: "自动", + h264: "H.264", + vp8: "VP8", +} as const satisfies Record; + export interface VideoSenderParameterValues { maxBitrate: number | null; maxFramerate: number | null; @@ -87,7 +97,8 @@ export function qualitySettingsEqual( left.resolution === right.resolution && left.maxFramerate === right.maxFramerate && left.maxBitrate === right.maxBitrate && - left.degradationPreference === right.degradationPreference + left.degradationPreference === right.degradationPreference && + (left.videoCodec ?? "automatic") === (right.videoCodec ?? "automatic") ); } @@ -95,7 +106,13 @@ export function matchingQualityProfileId( settings: QualitySettings, ): QualityProfileId | null { for (const id of Object.keys(QUALITY_PROFILES) as QualityProfileId[]) { - if (qualitySettingsEqual(settings, QUALITY_PROFILES[id])) { + const profile = QUALITY_PROFILES[id]; + if ( + settings.resolution === profile.resolution && + settings.maxFramerate === profile.maxFramerate && + settings.maxBitrate === profile.maxBitrate && + settings.degradationPreference === profile.degradationPreference + ) { return id; } } diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 66cde1a..763fc1f 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -80,6 +80,8 @@ import { type DegradationPreference, type QualityProfileId, type QualitySettings, + type VideoCodecPreference, + VIDEO_CODEC_PREFERENCE_LABELS, } from "../media/quality"; import { HostSfuRoute } from "../media/host-sfu-route"; import { @@ -607,7 +609,24 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setAdvancedQuality(settings); } + function changeVideoCodec(videoCodec: VideoCodecPreference): void { + if (phase === "starting" || phase === "live") { + return; + } + const next = { ...qualitySettingsRef.current, videoCodec }; + qualitySettingsRef.current = next; + setQualitySettings(next); + setAdvancedQuality((current) => ({ ...current, videoCodec })); + } + async function changeQuality(nextProfile: QualitySettings): Promise { + if ( + phase === "live" && + (nextProfile.videoCodec ?? "automatic") !== + (qualitySettingsRef.current.videoCodec ?? "automatic") + ) { + return; + } if (phase !== "live") { commitQuality(nextProfile); return; @@ -1807,7 +1826,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { switchingSource || changingQuality } - onClick={() => void changeQuality(QUALITY_PROFILES[id])} + onClick={() => + void changeQuality({ + ...QUALITY_PROFILES[id], + videoCodec: + qualitySettingsRef.current.videoCodec ?? "automatic", + }) + } > {QUALITY_PROFILE_LABELS[id]} @@ -1915,6 +1940,39 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ))} +
+ 视频编码 +
+ {( + Object.keys( + VIDEO_CODEC_PREFERENCE_LABELS, + ) as VideoCodecPreference[] + ).map((codec) => ( + + ))} +
+